diff --git a/LIBRARY.md b/LIBRARY.md index 4a4b3c91..a94d6c3d 100644 --- a/LIBRARY.md +++ b/LIBRARY.md @@ -57,17 +57,25 @@ owner's address. Phones and tablets stay read-only, so a signed-in owner without a library reads "Use a desktop to create your library" there. A visitor at an address no library answers to reads "No such library". -The AI shelf and the magic books are behind the `library-ai` account flag, -read from `GET /api/users/me` as `featureNames` (LIBRARY_AI_FLAG). The page -draws neither surface without it, and `/api/library/ai-shelf` and -`/api/library/magic-book` answer 403 without it through `ownerOfLibrary`, so -the hidden shelf is not the gate. Both surfaces are the owner's alone on -every account: a visitor never sees them. An operator hands the flag out in +The AI shelf and the magic books open to an owner who holds the `library-ai` +account flag, read from `GET /api/users/me` as `featureNames` +(LIBRARY_AI_FLAG), or whose library holds more than 15 books +(LIBRARY_AI_BOOKS_OVER, Wolf, 2026-09-25). Only objects of type book count, +on every shelf, private ones included; the count is read from the library on +every check, so the AI arrives with the sixteenth book and leaves if the +library drops back to fifteen, while the flag holds regardless. One check, +`opensLibraryAi` in `src/lib/library/flags.ts`, decides for the page and the +routes: the page draws neither surface without it, and +`/api/library/ai-shelf` and `/api/library/magic-book` answer 403 without it +through `ownerOfLibrary`, so the hidden shelf is not the gate. The AI shelf +itself stays locked until 30 books (AI_SHELF_MIN_BOOKS). Both surfaces are +the owner's alone on every account: a visitor never sees them. An operator hands the flag out in the CMS admin panel (the user's Feature Flags relation) or ahead of signup through the Mail Permission List; it takes effect on the account's next page load, no new sign-in. Wolf names the accounts, one at a time, to the agent; -on 2026-09-12 they are Alina, Mary, Lemongrass and Wolf. Cover, video and -audio autofill stay open to everyone: they cost no model call. +on 2026-09-12 they are Alina, Mary, Lemongrass and Wolf. On 2026-09-25 +Lilith and Maksim got it for holding more than 15 books, before the rule +above shipped. Cover, video and audio autofill stay open to everyone: they cost no model call. A library carries `hidden`, set only in the CMS admin panel (the content API refuses it on update). Hidden, it leaves the home list and diff --git a/scripts/release/library-batch-check.cjs b/scripts/release/library-batch-check.cjs index 478a697b..01f27456 100644 --- a/scripts/release/library-batch-check.cjs +++ b/scripts/release/library-batch-check.cjs @@ -3,6 +3,7 @@ const path = require('node:path'); const vm = require('node:vm'); const assert = require('node:assert/strict'); const ts = require('typescript'); + process.chdir(path.resolve(__dirname, '../..')); function load(file, mocks = {}) { const exports = {}; @@ -279,27 +280,61 @@ function check() { // LIBRARY ACCESS. Creation needs no flag; the AI does, and the routes // behind it check the same flag the page draws by, so a direct call is // stopped where the shelf is not drawn. - const { holdsFlag } = load('src/lib/library/flags.ts'); + const flags = load('src/lib/library/flags.ts', { + '@constants/library/common': { + LIBRARY_AI_FLAG: 'library-ai', + LIBRARY_AI_BOOKS_OVER: 15, + }, + }); + const { holdsFlag, countBooks, opensLibraryAi } = flags; assert(holdsFlag({ featureNames: ['library-ai'] }, 'library-ai')); assert(!holdsFlag({ featureNames: ['can-create-library'] }, 'library-ai')); assert(!holdsFlag({ featureNames: 'library-ai' }, 'library-ai')); assert(!holdsFlag({}, 'library-ai')); assert(!holdsFlag(null, 'library-ai')); + // More than 15 books opens the AI without the flag (Wolf, 2026-09-25); + // only books count, on every shelf. + const shelf = (...types) => ({ + attributes: { + objects: { data: types.map(type => ({ attributes: { type } })) }, + }, + }); + const libraryOf = (...shelves) => ({ + attributes: { singleShelves: { data: shelves } }, + }); + const fifteen = libraryOf( + shelf(...Array(10).fill('book'), 'audio', 'video'), + shelf(...Array(5).fill('book'), 'audio'), + ); + const sixteen = libraryOf( + shelf(...Array(10).fill('book')), + shelf(...Array(6).fill('book')), + ); + assert.equal(countBooks(fifteen), 15); + assert.equal(countBooks(sixteen), 16); + assert.equal(countBooks(null), 0); + assert(!opensLibraryAi({}, fifteen)); + assert(opensLibraryAi({}, sixteen)); + assert(opensLibraryAi({ featureNames: ['library-ai'] }, fifteen)); + assert(!opensLibraryAi(null, null)); // The constants file carries icon components for its sample cards; the // icons are not what is checked here. const common = load('src/constants/library/common.ts', { '@icons/library/svg': new Proxy({}, { get: () => () => null }), }); assert.equal(common.LIBRARY_AI_FLAG, 'library-ai'); + assert.equal(common.LIBRARY_AI_BOOKS_OVER, 15); assert.equal(common.MAX_OBJECTS_PER_LIBRARY, 300); for (const route of [ 'src/pages/api/library/ai-shelf.ts', 'src/pages/api/library/magic-book.ts', ]) - assert( - fs.readFileSync(route, 'utf8').includes('flag: LIBRARY_AI_FLAG'), - route, - ); + assert(fs.readFileSync(route, 'utf8').includes('libraryAi: true'), route); + assert( + fs + .readFileSync('src/layouts/library/Library/Library.tsx', 'utf8') + .includes('opensLibraryAi(accountData, library)'), + ); for (const file of [ 'src/layouts/library/Library/Library.tsx', 'src/layouts/library/Home/Home.tsx', diff --git a/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx b/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx index c08b3871..bfe07e81 100644 --- a/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx +++ b/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx @@ -67,11 +67,7 @@ function Hotspot({ // 768–1920px shows the wide artwork; 1920px+ swaps to the panorama, which // frames the buildings differently and so carries its own geometry. - const label = - library?.libraryName ?? - (hotspot.username - ? `${hotspot.username}'s library` - : 'Nothing but ghosts...'); + const label = library?.libraryName ?? 'Nothing but ghosts...'; const { hit, highlight, card } = isUltraWide ? hotspot.ultraWide : hotspot.wide; @@ -259,11 +255,13 @@ export function InteractiveCover({ - library.username?.toLowerCase() === - hotspot.username?.toLowerCase(), - )} + library={ + hotspot.ownerId === undefined + ? undefined + : libraries.find( + library => library.userId === hotspot.ownerId, + ) + } mode={mode} activeId={activeId} setActiveId={setActiveId} diff --git a/src/components/library/organisms/InteractiveCover/coverHotspots.ts b/src/components/library/organisms/InteractiveCover/coverHotspots.ts index 3ceae2e9..d4dedba3 100644 --- a/src/components/library/organisms/InteractiveCover/coverHotspots.ts +++ b/src/components/library/organisms/InteractiveCover/coverHotspots.ts @@ -39,7 +39,13 @@ export interface CoverHotspot { * Derived from `wide` (see `toUltraWide`), with optional per-hotspot tweaks. */ ultraWide: HotspotGeometry; - username?: string; + /** + * Account id of the library's owner. Bound by id, not username: owners rename + * themselves (Mary13 became Mary, alinamarg became Alina) and a username + * binding then silently drops the library's data from the card. Staging's + * database is a copy of production's, so the ids hold on both. + */ + ownerId?: number; } // At the 1920px breakpoint the full-bleed cover frame is 1920px wide and the @@ -109,13 +115,13 @@ const applyOverride = ( const makeHotspot = ( id: string, wide: HotspotGeometry, - username?: string, + ownerId?: number, ultraWideOverride?: GeometryOverride, ): CoverHotspot => ({ id, wide, ultraWide: applyOverride(toUltraWide(wide), ultraWideOverride), - username, + ownerId, }); // Hit boxes are sized to the glow silhouette each hotspot lights up, so the @@ -139,7 +145,8 @@ export const coverHotspots: CoverHotspot[] = [ }, card: { left: 53.0, top: 19.01 }, }, - 'Wolf', + // Wolf + 7, ), makeHotspot( 'house-1', @@ -193,7 +200,8 @@ export const coverHotspots: CoverHotspot[] = [ }, card: { left: 30.62, top: 26.4 }, }, - 'Mary13', + // Mary + 10, { hit: { top: 60.83 }, highlight: { left: 35.552, top: 28 }, @@ -215,6 +223,7 @@ export const coverHotspots: CoverHotspot[] = [ }, // Wolf, 2026-09-11: this library stands on the lantern now, not on the // house above the water. - 'alinamarg', + // Alina + 538, ), ]; diff --git a/src/constants/library/common.ts b/src/constants/library/common.ts index 086ad0f8..2aeae658 100644 --- a/src/constants/library/common.ts +++ b/src/constants/library/common.ts @@ -97,6 +97,10 @@ export const LIBRARY_OBJECTS_FULL_MESSAGE = // library needs no flag since 2026-09-12. export const LIBRARY_AI_FLAG = 'library-ai'; +// A library holding more books than this opens the AI to its owner without the +// flag (Wolf, 2026-09-25). Only objects of type book count. +export const LIBRARY_AI_BOOKS_OVER = 15; + export const SHELF_FULL_MESSAGE = 'This shelf is full.'; // The library-level twin, worded the same way so the two limits read as one diff --git a/src/layouts/library/Library/Library.tsx b/src/layouts/library/Library/Library.tsx index 3b0ba3c3..9d163753 100644 --- a/src/layouts/library/Library/Library.tsx +++ b/src/layouts/library/Library/Library.tsx @@ -27,7 +27,6 @@ import React, { } from 'react'; import { - LIBRARY_AI_FLAG, LIBRARY_FULL_MESSAGE, LIBRARY_SHELVES_REFETCH_EVENT, MAX_OBJECTS_PER_LIBRARY, @@ -61,7 +60,7 @@ import { keepFavoriteFields, sortFavorites, } from '@lib/library/favorites'; -import { holdsFlag } from '@lib/library/flags'; +import { opensLibraryAi } from '@lib/library/flags'; import { libraryPath } from '@lib/library/libraryPath'; import { objectIdFromSlug } from '@lib/library/objectSlug'; import { @@ -268,12 +267,13 @@ export function LibraryTemplate({ // and on first paint, so the markup hydrates identically everywhere. const canEditHere = viewAsOwner && supportsEditing; - // The AI shelf and the magic books are behind the `library-ai` account - // flag from GET /api/users/me; the routes behind them check the same flag, - // so this decides what is drawn, not what is allowed. Creating a library + // The AI shelf and the magic books open with the `library-ai` account flag + // or with more than LIBRARY_AI_BOOKS_OVER books in this library; the routes + // behind them run the same check, so this decides what is drawn, not what + // is allowed. Creating a library // needs no flag: any signed-in owner of this address bootstraps one from // their first shelf. - const hasLibraryAi = holdsFlag(accountData, LIBRARY_AI_FLAG); + const hasLibraryAi = opensLibraryAi(accountData, library); // The magic books are read once the owner is known to be editing here: // desktop, own library, not previewing as a guest, and flagged. The diff --git a/src/lib/aiAtlas/adapter.ts b/src/lib/aiAtlas/adapter.ts index 6c5774e2..edeacea2 100644 --- a/src/lib/aiAtlas/adapter.ts +++ b/src/lib/aiAtlas/adapter.ts @@ -6,11 +6,72 @@ export const copy: any = { linesValue: (n: number) => String(n), introInhabitantsTpl: () => '', }; -/* Wolf's prose for a card, or the guide's own text when he has none. */ -const describe = (id: string, fallback: string[]) => - features[id] || fallback.filter(Boolean); +/* Page words the Terminal may replace with a push, beyond copy.json. */ +export const PAGE_TEXT_DEFAULTS: Record = { + brandTitle: 'Wolf’s Terminal', + apexLabel: 'WOLF', + apexSub: 'direction', + orderLabel: 'The Order', + resourceAgents: 'Colleagues', + resourceMemory: 'Memory', + resourceTools: 'Tools', + resourceModels: 'Models', + ringOrderLabel: 'I · Ownership', + ringDevEnvLabel: 'II · Resources', + ringProjectsLabel: 'III · Task lifecycle', + ringTerritoriesLabel: 'IV · Mechanisms', + ringOrderTitle: 'Ownership', + ringDevEnvTitle: 'Resources', + ringDevEnvDesc: + 'What every stage of a task draws on: colleagues, memory, tools, models.', + ringProjectsTitle: 'Task lifecycle', + ringProjectsDesc: + 'The rings are the stages of one task, clockwise from Project to Result.', + ringTerritoriesTitle: 'Mechanisms', + ringTerritoriesDesc: 'Every topic opens its own card.', + metaLabel: 'TERMINAL DOCUMENTATION', + topicsPlaceholder: 'The Atlas', + stagesAria: 'Project to Result, clockwise', +}; + +/* Keys of copy.json a push may replace: the plain strings. */ +export const PAGE_TEXT_KEYS = new Set([ + ...Object.keys(base).filter(key => typeof (base as any)[key] === 'string'), + ...Object.keys(PAGE_TEXT_DEFAULTS), +]); + +/* Labels drawn inside map nodes and rings: short, or they spill over the + map. A push keeps them to SHORT_LABEL_MAX characters. */ +export const SHORT_LABEL_KEYS = new Set([ + 'brandTitle', + 'apexLabel', + 'apexSub', + 'orderLabel', + 'resourceAgents', + 'resourceMemory', + 'resourceTools', + 'resourceModels', + 'ringOrderLabel', + 'ringDevEnvLabel', + 'ringProjectsLabel', + 'ringTerritoriesLabel', + 'metaLabel', + 'topicsPlaceholder', +]); +export const SHORT_LABEL_MAX = 40; + +/* How many tiles one stage can carry before its arc runs into the next. */ +export const MAX_TILES_PER_STAGE = 3; export function adaptGuide(guide: any) { + /* Words pushed with the guide as `copy` win over the built-in ones. */ + const text = (key: string) => + guide.copy?.[key] ?? PAGE_TEXT_DEFAULTS[key] ?? (copy as any)[key]; + /* Wolf's prose for a card: pushed with the guide as `cards` when the + Terminal sends it, else the copy in features.ts, else the guide's own + text. */ + const describe = (id: string, fallback: string[]) => + guide.cards?.[id] || features[id] || fallback.filter(Boolean); const dossiers: any = {}; const entries = new Map(guide.entries.map((entry: any) => [entry.id, entry])); for (const entry of [...guide.entries, ...guide.system.nodes]) { @@ -36,7 +97,16 @@ export function adaptGuide(guide: any) { voice; they are not drawn. */ const systemRef = (id: string) => dossiers['system-' + id] ? 'system-' + id : id; - const labels = ['Project', 'Task', 'Dispatch', 'Prepare', 'Work', 'Result']; + /* A push may set each stage's label and the tiles drawn on it as + `stages`; otherwise the map keeps the selection below. */ + const labels = [ + 'Project', + 'Task', + 'Dispatch', + 'Prepare', + 'Work', + 'Result', + ].map((label, i) => guide.stages?.[i]?.label ?? label); const angles = [210, 270, 330, 30, 90, 150]; const chosen = [ ['keys', 'backlog'], @@ -45,7 +115,7 @@ export function adaptGuide(guide: any) { ['global', 'local', 'session-resume'], ['work-checks', 'sendto', 'human-collab'], ['history', 'decisions', 'discipline'], - ]; + ].map((tiles, i) => guide.stages?.[i]?.tiles ?? tiles); const topicToStage: any = {}; const support = [ ['order'], @@ -86,11 +156,14 @@ export function adaptGuide(guide: any) { territoryArc: chosen[index].length > 2 ? 54 : 36, childrenArc: chosen[index].length > 2 ? 46 : 22, territoryLabel: '', - children: chosen[index].map(child => ({ - id: child, - label: (entries.get(child) as any).title, - kind: 'filled', - })), + /* A tile the Terminal has since removed is left off the map. */ + children: chosen[index] + .filter(child => entries.has(child)) + .map(child => ({ + id: child, + label: (entries.get(child) as any).title, + kind: 'filled', + })), }; }); /* What lights up together on hover, beyond a stage and its own tiles. @@ -136,6 +209,8 @@ export function adaptGuide(guide: any) { ]; const t: any = { ...copy, + ...PAGE_TEXT_DEFAULTS, + ...(guide.copy || {}), securityLayers: layerIds.map((id, index) => ({ n: index + 1, side: index % 2 ? 'right' : 'left', @@ -161,13 +236,13 @@ export function adaptGuide(guide: any) { ), }; dossiers['ring:order'] = { - title: 'Ownership', + title: text('ringOrderTitle'), desc: nodeDesc('order'), rows: [], }; dossiers['ring:devEnv'] = { - title: 'Resources', - desc: 'What every stage of a task draws on: colleagues, memory, tools, models.', + title: text('ringDevEnvTitle'), + desc: text('ringDevEnvDesc'), rows: ['agents', 'memory', 'tools', 'models'].map(id => ({ k: 'resource', v: node(id).title, @@ -175,13 +250,13 @@ export function adaptGuide(guide: any) { })), }; dossiers['ring:projects'] = { - title: 'Task lifecycle', - desc: 'The rings are the stages of one task, clockwise from Project to Result.', + title: text('ringProjectsTitle'), + desc: text('ringProjectsDesc'), rows: projects.map((p: any) => ({ k: 'stage', v: p.label, ref: p.id })), }; dossiers['ring:territories'] = { - title: 'Mechanisms', - desc: 'Every topic opens its own card.', + title: text('ringTerritoriesTitle'), + desc: text('ringTerritoriesDesc'), rows: guide.entries.map((e: any) => ({ k: 'mechanism', v: e.title, @@ -192,31 +267,40 @@ export function adaptGuide(guide: any) { topicToStage, relations, copy: t, - brand: { title: 'Wolf’s Terminal', kanji: '天' }, + brand: { title: text('brandTitle'), kanji: '天' }, ringLabels: { - order: { label: 'I · Ownership', theta: 270, offset: 0.09 }, - devEnv: { label: 'II · Resources', theta: 270 }, - projects: { label: 'III · Task lifecycle', theta: 270, offset: 0.08 }, - territories: { label: 'IV · Mechanisms', theta: 270 }, + order: { label: text('ringOrderLabel'), theta: 270, offset: 0.09 }, + devEnv: { label: text('ringDevEnvLabel'), theta: 270 }, + projects: { + label: text('ringProjectsLabel'), + theta: 270, + offset: 0.08, + }, + territories: { label: text('ringTerritoriesLabel'), theta: 270 }, }, apex: { id: 'wolf', - label: 'WOLF', + label: text('apexLabel'), cjk: '天', - sub: 'direction', + sub: text('apexSub'), diamond: 'gold', }, order: { r: 0.2, - member: { id: 'order', label: 'The Order', diamond: 'blue', theta: 270 }, + member: { + id: 'order', + label: text('orderLabel'), + diamond: 'blue', + theta: 270, + }, }, devEnv: { r: 0.39, members: [ - ['agents', 'Colleagues'], - ['memory', 'Memory'], - ['tools', 'Tools'], - ['models', 'Models'], + ['agents', text('resourceAgents')], + ['memory', text('resourceMemory')], + ['tools', text('resourceTools')], + ['models', text('resourceModels')], ].map(([id, label], i) => ({ id: dossiers['system-' + id] ? 'system-' + id : id, label, diff --git a/src/lib/aiAtlas/features.ts b/src/lib/aiAtlas/features.ts index 05af2e78..f83f8e9d 100644 --- a/src/lib/aiAtlas/features.ts +++ b/src/lib/aiAtlas/features.ts @@ -91,7 +91,7 @@ const features: Record = { /* ---------- dispatch ---------- */ // card: Engine project: [ - 'Every project tile runs on Claude or on OpenAI’s Codex. I choose the engine per project, and Terminal runs the agent inside that project’s folder with that engine.', + 'Every project tile runs on Claude or on OpenAI’s Codex. I choose the engine per project, and Terminal runs the agent inside that project’s folder with that engine. Grok is the third one. xAI’s model runs on the same tiles, on one login, and Terminal keeps every Grok turn in its own archive, so nothing that lane produces can be lost.', ], // card: Engine switch 'engine-switch': [ @@ -258,7 +258,7 @@ const features: Record = { /* ---------- the result ---------- */ // card: Delivered work 'delivered-work': [ - 'The agent reports what changed and what it checked. The work itself stays in the project as files, commits and deployments. I read the report against that evidence, not on its own.', + 'The agent reports what changed and what it checked. The work itself stays in the project as files, commits and deployments. I read the report against that evidence, not on its own. Done is not the agent’s word either. Before the report reaches me, a reviewer on a different vendor’s model reads the task in my own words and a snapshot of the project. It has none of the author’s context, so it cannot share the author’s blind spots. GPT does that review, Grok steps in when GPT is down, and it is never a Claude checking a Claude.', ], // card: Review result 'review-result': [ @@ -328,7 +328,7 @@ const features: Record = { ], // card: Models (resource ring) models: [ - 'The thinking happens at the provider. Today that is Claude and OpenAI’s Codex, several subscriptions of each, and the tools stay on my server whichever one is thinking.', + 'The thinking happens at the provider. Today that is Claude and OpenAI’s Codex, several subscriptions of each, and the tools stay on my server whichever one is thinking. xAI’s Grok is the third provider on the same terms.', 'The switch between them is my own code, so a third engine is a slot on it and not a rewrite. The groundwork for local models is in place: the box is chosen, the model is picked and the plan is written. It is not running yet, and the atlas says so.', ], // card: Terminal diff --git a/src/lib/aiAtlas/store.ts b/src/lib/aiAtlas/store.ts new file mode 100644 index 00000000..afef2bc2 --- /dev/null +++ b/src/lib/aiAtlas/store.ts @@ -0,0 +1,45 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +/** + * Where the Atlas guide pushed by the Terminal lives between deploys, and + * the journal every push leaves. + * + * The frontend container has one persistent mount, `logs/library-magic/`, + * which outlives every redeploy; the guide sits in its own folder there so + * no new volume is needed. Server-only: read from getStaticProps and the + * push route, never from the browser. The file holds the stripped guide + * only, the same fields the page renders into its HTML. + */ + +const ROOT = path.join(process.cwd(), 'logs', 'library-magic', 'ai-atlas'); +const GUIDE = path.join(ROOT, 'guide.json'); +const JOURNAL = path.join(ROOT, 'journal.jsonl'); + +export async function readStoredGuide(): Promise { + try { + return JSON.parse(await fs.readFile(GUIDE, 'utf8')); + } catch { + return null; + } +} + +export async function writeStoredGuide(guide: any): Promise { + await fs.mkdir(ROOT, { recursive: true }); + const tmp = `${GUIDE}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(guide)); + await fs.rename(tmp, GUIDE); +} + +/** One line per push that carries the key, UTC: stored, refused or failed. */ +export async function journalPush(entry: Record) { + try { + await fs.mkdir(ROOT, { recursive: true }); + await fs.appendFile( + JOURNAL, + JSON.stringify({ at: new Date().toISOString(), ...entry }) + '\n', + ); + } catch { + /* A journal failure never fails the push. */ + } +} diff --git a/src/lib/aiAtlas/stripGuide.ts b/src/lib/aiAtlas/stripGuide.ts new file mode 100644 index 00000000..34510f68 --- /dev/null +++ b/src/lib/aiAtlas/stripGuide.ts @@ -0,0 +1,172 @@ +/* The Terminal's guide cut down to the fields /ai-atlas renders. + The Terminal's export carries source references (file, line, sha256), + its own placement notes, a tool inventory and cross-link sentences in + its own voice. None of it is drawn on keepsimple.io/ai-atlas, so none + of it is kept. The same cut as scripts/ai-atlas/strip-guide.mjs, which + refreshes the bundled fallback; the two keep the same field lists. */ + +import { + adaptGuide, + MAX_TILES_PER_STAGE, + PAGE_TEXT_KEYS, + SHORT_LABEL_KEYS, + SHORT_LABEL_MAX, +} from './adapter'; + +const pick = (obj: any, keys: string[]) => + Object.fromEntries( + keys.filter(k => obj[k] !== undefined).map(k => [k, obj[k]]), + ); + +const isRecordList = (value: unknown) => + Array.isArray(value) && + value.every( + item => + item && typeof item === 'object' && typeof (item as any).id === 'string', + ); + +/* Wolf's card texts, pushed by the Terminal: card id to paragraphs. Only + plain strings pass; a card listed here replaces its copy in features.ts. */ +const isCards = (value: unknown) => + value === undefined || + (!!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.entries(value).every( + ([id, paragraphs]) => + /^[a-z0-9-]+$/.test(id) && + Array.isArray(paragraphs) && + paragraphs.length > 0 && + paragraphs.every( + p => typeof p === 'string' && p.length > 0 && p.length <= 4000, + ), + )); + +/* Page words, pushed by the Terminal: a copy key to its new text. Only + keys the page already has pass. */ +const isCopy = (value: unknown) => + value === undefined || + (!!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.entries(value).every( + ([key, words]) => + PAGE_TEXT_KEYS.has(key) && + typeof words === 'string' && + words.length > 0 && + words.length <= (SHORT_LABEL_KEYS.has(key) ? SHORT_LABEL_MAX : 4000), + )); + +/* The map, pushed by the Terminal: per stage, in step order, an optional + label and the tiles drawn on it. Every tile must be an entry. */ +const stagesError = (value: unknown, steps: number, ids: Set) => { + if (value === undefined) return null; + if (!Array.isArray(value) || value.length !== steps) + return `stages must list all ${steps} stages in step order`; + for (const stage of value) { + if (!stage || typeof stage !== 'object') return 'a stage is not an object'; + if ( + stage.label !== undefined && + (typeof stage.label !== 'string' || stage.label.length > 40) + ) + return 'a stage label must be text up to 40 characters'; + if (stage.tiles === undefined) continue; + if ( + !Array.isArray(stage.tiles) || + stage.tiles.length === 0 || + stage.tiles.length > MAX_TILES_PER_STAGE + ) + return `a stage draws 1 to ${MAX_TILES_PER_STAGE} tiles`; + const missing = stage.tiles.find((id: unknown) => !ids.has(id as string)); + if (missing !== undefined) return `tile ${missing} is not an entry`; + } + return null; +}; + +/* Every kept field has one shape, checked here rather than trusted to the + adapter: a title that is not text would pass adaptGuide and break the page + only when a visitor opens that dossier. */ +const TEXT_FIELDS = ['title', 'location', 'text', 'role', 'basis']; +const LIST_FIELDS = ['detail', 'children']; +const FIELD_MAX = 4000; +const isText = (v: unknown) => typeof v === 'string' && v.length <= FIELD_MAX; + +const fieldsError = (list: any[], name: string) => { + for (const item of list) { + const bad = + TEXT_FIELDS.find(k => item[k] !== undefined && !isText(item[k])) ?? + LIST_FIELDS.find( + k => + item[k] !== undefined && + !(Array.isArray(item[k]) && item[k].every(isText)), + ); + if (bad) + return `${name} ${item.id}: ${bad} must be ${ + LIST_FIELDS.includes(bad) ? 'a list of texts' : 'text' + } up to ${FIELD_MAX} characters`; + } + return null; +}; + +export type StripResult = { guide: any } | { error: string }; + +export function stripGuide(input: any): StripResult { + if (!input || typeof input !== 'object') + return { error: 'body is not a JSON object' }; + if (typeof input.generatedAt !== 'string') + return { error: 'generatedAt missing' }; + if (!isRecordList(input.steps) || input.steps.length === 0) + return { error: 'steps missing or malformed' }; + if (!isRecordList(input.entries) || input.entries.length === 0) + return { error: 'entries missing or malformed' }; + if (!isRecordList(input.system?.nodes) || input.system.nodes.length === 0) + return { error: 'system.nodes missing or malformed' }; + if (input.steps.some((s: any) => !Array.isArray(s.children))) + return { error: 'a step has no children list' }; + const fields = + fieldsError(input.steps, 'step') ?? + fieldsError(input.entries, 'entry') ?? + fieldsError(input.system.nodes, 'node'); + if (fields) return { error: fields }; + if (!isCards(input.cards)) + return { error: 'cards must map a card id to a list of paragraphs' }; + if (!isCopy(input.copy)) + return { + error: `copy must map a known page text key to non-empty text, labels up to ${SHORT_LABEL_MAX} characters`, + }; + const stages = stagesError( + input.stages, + input.steps.length, + new Set(input.entries.map((e: any) => e.id)), + ); + if (stages) return { error: stages }; + + const guide: any = { + generatedAt: input.generatedAt, + steps: input.steps.map((s: any) => + pick(s, ['id', 'title', 'location', 'text', 'children']), + ), + entries: input.entries.map((e: any) => + pick(e, ['id', 'title', 'text', 'detail', 'children']), + ), + system: { + nodes: input.system.nodes.map((n: any) => + pick(n, ['id', 'title', 'role', 'detail', 'basis']), + ), + }, + }; + if (input.cards) guide.cards = input.cards; + if (input.copy) guide.copy = input.copy; + if (input.stages) + guide.stages = input.stages.map((s: any) => pick(s, ['label', 'tiles'])); + /* The page must be able to draw what is stored; a guide it cannot draw + is refused and the page keeps the last one that worked. */ + try { + adaptGuide(guide); + } catch (e) { + return { + error: `the page cannot render this guide: ${(e as Error).message}`, + }; + } + return { guide }; +} diff --git a/src/lib/library/aishelf/engine.ts b/src/lib/library/aishelf/engine.ts index 84c9eb45..a33e8b13 100644 --- a/src/lib/library/aishelf/engine.ts +++ b/src/lib/library/aishelf/engine.ts @@ -36,8 +36,8 @@ import { verifyBook } from '@lib/library/magic/verify'; * hard constraint on the call, not a filter after it. */ -export const AI_SHELF_MODEL = 'claude-opus-5'; -/** Wolf's setting for the Library's picks: Opus 5 at high effort. */ +export const AI_SHELF_MODEL = 'claude-opus-5-5'; +/** Wolf's setting for the Library's picks: Opus 5.5 at high effort. */ export const AI_SHELF_EFFORT = 'high' as const; /** Books the library must hold before the shelf opens (Wolf, 2026-09-10). */ @@ -64,7 +64,7 @@ const STRETCH_SLOTS = [3, 7, 11]; * high effort took 107 and 117. Latency here is mostly what the model * writes, so the ask is cut to what the board actually needs plus a little, * and the second pass covers a candidate no source could confirm. Nothing - * about the picks themselves is lowered: still opus 5, still high effort. + * about the picks themselves is lowered: still opus 5.5, still high effort. */ const ASK: Record = { fit: 12, stretch: 5 }; /** Book sources queried at once. */ diff --git a/src/lib/library/flags.ts b/src/lib/library/flags.ts index 9dcd8d42..466ffeca 100644 --- a/src/lib/library/flags.ts +++ b/src/lib/library/flags.ts @@ -1,3 +1,10 @@ +import { + LIBRARY_AI_BOOKS_OVER, + LIBRARY_AI_FLAG, +} from '@constants/library/common'; + +import type { StrapiLibraryEntry } from '@local-types/library/library'; + /** * Account feature flags, as `GET /api/users/me` reports them in * `featureNames`. One pure check, shared by the page (what is drawn) and the @@ -7,3 +14,29 @@ export const holdsFlag = ( me: { featureNames?: unknown } | null | undefined, flag: string, ): boolean => Array.isArray(me?.featureNames) && me.featureNames.includes(flag); + +/** Books in the library across every shelf, private ones included. Audio and + * video do not count. */ +export const countBooks = ( + library: StrapiLibraryEntry | null | undefined, +): number => + (library?.attributes.singleShelves?.data ?? []).reduce( + (sum, shelf) => + sum + + (shelf.attributes.objects?.data ?? []).filter( + object => object.attributes.type === 'book', + ).length, + 0, + ); + +/** + * The AI shelf and the magic books open to an owner who holds the + * `library-ai` flag, or whose library holds more than LIBRARY_AI_BOOKS_OVER + * books (Wolf, 2026-09-25). The count is read from the library each time, so + * the AI arrives with the book that crosses the line and nothing is stored. + */ +export const opensLibraryAi = ( + me: { featureNames?: unknown } | null | undefined, + library: StrapiLibraryEntry | null | undefined, +): boolean => + holdsFlag(me, LIBRARY_AI_FLAG) || countBooks(library) > LIBRARY_AI_BOOKS_OVER; diff --git a/src/lib/library/magic/engine.ts b/src/lib/library/magic/engine.ts index eb545b59..0e01e176 100644 --- a/src/lib/library/magic/engine.ts +++ b/src/lib/library/magic/engine.ts @@ -18,8 +18,8 @@ import { verifyBook } from './verify'; * Every candidate is verified against a book source before it is shown. */ -export const MAGIC_MODEL = 'claude-opus-5'; -/** Wolf's setting for the Library's picks: Opus 5 at high effort. */ +export const MAGIC_MODEL = 'claude-opus-5-5'; +/** Wolf's setting for the Library's picks: Opus 5.5 at high effort. */ export const MAGIC_EFFORT = 'high' as const; /** Which subscription track and model answered a run. */ diff --git a/src/lib/library/owner.ts b/src/lib/library/owner.ts index 6bed5496..ddfe7a14 100644 --- a/src/lib/library/owner.ts +++ b/src/lib/library/owner.ts @@ -2,7 +2,7 @@ import type { NextApiRequest } from 'next'; import type { StrapiLibraryEntry } from '@local-types/library/library'; -import { holdsFlag } from '@lib/library/flags'; +import { opensLibraryAi } from '@lib/library/flags'; /** * Who is asking, and do they own the library they are asking about. Every @@ -45,14 +45,15 @@ export interface OwnerWording { signIn?: string; /** A session, but not the owner of this library. */ forbidden?: string; - /** The owner, but without the account flag this surface needs. */ + /** The owner, but the surface is not open to their account. */ locked?: string; } /** What the surface needs of the account beyond owning the library. */ export interface OwnerRequires { - /** A `featureNames` entry from /api/users/me, e.g. LIBRARY_AI_FLAG. */ - flag?: string; + /** The AI shelf and the magic books: the `library-ai` flag, or more than + * LIBRARY_AI_BOOKS_OVER books in this library (opensLibraryAi). */ + libraryAi?: boolean; } export interface OwnerCheck { @@ -99,9 +100,9 @@ export async function ownerOfLibrary( userId: me.id, }; - // The owner, but the surface is behind an account flag they do not hold: - // the page does not draw it, and this is what stops a direct call. - if (requires.flag && !holdsFlag(me, requires.flag)) + // The owner, but the surface is not open to their account: the page does + // not draw it, and this is what stops a direct call. + if (requires.libraryAi && !opensLibraryAi(me, library)) return { status: 403, error: wording.locked ?? 'This surface is not open to your account.', diff --git a/src/lib/widget/llmClient.ts b/src/lib/widget/llmClient.ts index 29d80bec..961458dd 100644 --- a/src/lib/widget/llmClient.ts +++ b/src/lib/widget/llmClient.ts @@ -1,38 +1,48 @@ /** * Shared LLM-client constants for the concierge API routes. * - * Both /api/concierge and /api/concierge-landing duplicate the same - * URL + header + env-key handling for Anthropic and OpenAI. This - * module centralises those bits so route files only own their own - * prompts and response-parsing logic. + * Claude is reached only through the subscription relay (Wolf, + * 2026-09-22): the Terminal's tracks t1, t2 and t3, switched on a rate + * limit, never a paid API key. See src/lib/library/magic/relay.ts. The + * relay runs one CLI turn without tools, so the reply schema is asked + * for in the prompt and the JSON is read out of the text. */ -export const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY; -export const OPENAI_KEY = process.env.OPENAI_API_KEY; +import { + askRelay, + parseJsonReply, + relayConfigured, +} from '@lib/library/magic/relay'; -export const CLAUDE_MODEL = 'claude-sonnet-4-6'; -export const OPENAI_MODEL = 'gpt-4.1'; +export const CLAUDE_MODEL = 'claude-sonnet-5'; -export const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; -export const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'; +export const claudeConfigured = relayConfigured; -export function anthropicHeaders(): Record { - if (!ANTHROPIC_KEY) { - throw new Error('ANTHROPIC_API_KEY is not set'); +/** One Claude turn through the relay, answered as JSON matching `schema`. + * Null when the relay is not wired, every track failed, or the reply did + * not parse; the caller then stays quiet. */ +export async function askClaudeJson( + system: string, + user: string, + schema: object, + maxTokens: number, +): Promise { + if (!relayConfigured()) return null; + try { + const reply = await askRelay({ + model: CLAUDE_MODEL, + system, + prompt: + `${user}\n\nReply with one JSON object and nothing else. ` + + `It must match this JSON Schema:\n${JSON.stringify(schema)}`, + maxTokens, + effort: 'low', + }); + return parseJsonReply(reply.text); + } catch (error) { + console.warn( + `[widget] claude relay: ${error instanceof Error ? error.message : 'failed'}`, + ); + return null; } - return { - 'Content-Type': 'application/json', - 'x-api-key': ANTHROPIC_KEY, - 'anthropic-version': '2023-06-01', - }; -} - -export function openAIHeaders(): Record { - if (!OPENAI_KEY) { - throw new Error('OPENAI_API_KEY is not set'); - } - return { - 'Content-Type': 'application/json', - Authorization: `Bearer ${OPENAI_KEY}`, - }; } diff --git a/src/local-types/library/library.ts b/src/local-types/library/library.ts index 972fb6ad..b7500b12 100644 --- a/src/local-types/library/library.ts +++ b/src/local-types/library/library.ts @@ -149,6 +149,8 @@ export interface IUpdateLibraryPayload { /** Mapped row for `LibraryCard` on the home page */ export interface HomeLibraryCardView { id: number; + /** Owner's account id. Stable across username changes, unlike `username`. */ + userId?: number; username?: string; libraryName: string; description: string; diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index c78a5805..f0882679 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -1,3 +1,4 @@ +import type { GetStaticProps } from 'next'; import React, { useEffect, useLayoutEffect, @@ -6,8 +7,8 @@ import React, { useState, } from 'react'; -import { adaptGuide, copy } from '@lib/aiAtlas/adapter'; -import guide from '@lib/aiAtlas/guide.json'; +import { adaptGuide, copy, PAGE_TEXT_DEFAULTS } from '@lib/aiAtlas/adapter'; +import bundledGuide from '@lib/aiAtlas/guide.json'; import { securityPassage, securityRadii } from '@lib/aiAtlas/securityPassage'; import SeoGenerator from '@components/SeoGenerator'; @@ -43,8 +44,11 @@ function useHasHover() { return hasHover; } -/* The Atlas content is bundled into the page and rendered on the server. - It is never served as a standalone file: the guide describes the private +/* The Atlas content is rendered on the server. The Terminal pushes its + guide to /api/ai-atlas/guide on every Atlas deploy; the stripped copy on + the container's persistent mount is what the page renders, and the + bundled guide.json is the fallback until the first push lands. It is + never served as a standalone file: the guide describes the private Terminal and only what the page draws may leave this host. */ /* ============================================================ @@ -1556,7 +1560,7 @@ export function AiAtlasApp({ {t.welcomeBanner}
- TERMINAL DOCUMENTATION + {t.metaLabel}
@@ -1658,10 +1662,7 @@ export function AiAtlasApp({ - + {data.projects.members .slice(0, -1) .map((p: any, i: number) => { @@ -2069,7 +2070,7 @@ export function AiAtlasApp({ setFocusedNode(e.target.value || null); }} > - + {Object.entries(data.dossiers).map(([id, d]: any) => (