From 9db1dc71e8b9770b865bf1e1e9a1e6672013c0be Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 22 Sep 2026 20:12:32 +0000 Subject: [PATCH 01/21] library: AI shelf and Magic default to Claude Opus 5.5 (Wolf, 2026-09-22) --- src/lib/library/aishelf/engine.ts | 6 +++--- src/lib/library/magic/engine.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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. */ From cd740e3cb8ef1d1029b989692f142379c9d2ac34 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 22 Sep 2026 20:51:22 +0000 Subject: [PATCH 02/21] widget: Copilot defaults to Claude Sonnet 5 Thinking is disabled explicitly on every Claude call, since Sonnet 5 runs adaptive thinking when the field is omitted, and the reply caps grow from 600 to 800 and 360 to 480 tokens for its larger tokenizer. Authorized by Wolf on 2026-09-22. Co-Authored-By: Claude Opus 5.5 --- src/lib/widget/llmClient.ts | 5 ++++- src/pages/api/concierge-landing.ts | 3 ++- src/pages/api/concierge.ts | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/widget/llmClient.ts b/src/lib/widget/llmClient.ts index 29d80bec..fdf171ad 100644 --- a/src/lib/widget/llmClient.ts +++ b/src/lib/widget/llmClient.ts @@ -10,7 +10,10 @@ export const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY; export const OPENAI_KEY = process.env.OPENAI_API_KEY; -export const CLAUDE_MODEL = 'claude-sonnet-4-6'; +/* Sonnet 5 runs adaptive thinking when `thinking` is omitted and counts + about 30% more tokens than Sonnet 4.6, so every call below disables + thinking and leaves max_tokens headroom for the forced tool reply. */ +export const CLAUDE_MODEL = 'claude-sonnet-5'; export const OPENAI_MODEL = 'gpt-4.1'; export const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; diff --git a/src/pages/api/concierge-landing.ts b/src/pages/api/concierge-landing.ts index 79a1d94c..a05bd4b9 100644 --- a/src/pages/api/concierge-landing.ts +++ b/src/pages/api/concierge-landing.ts @@ -83,7 +83,7 @@ async function callClaude( headers: anthropicHeaders(), body: JSON.stringify({ model: CLAUDE_MODEL, - max_tokens: 360, + max_tokens: 480, system: [ { type: 'text', text: system, cache_control: { type: 'ephemeral' } }, ], @@ -106,6 +106,7 @@ async function callClaude( }, ], tool_choice: { type: 'tool', name: 'submit_landing_line' }, + thinking: { type: 'disabled' }, }), }); if (!r.ok) return null; diff --git a/src/pages/api/concierge.ts b/src/pages/api/concierge.ts index 924fb200..9b4241ff 100644 --- a/src/pages/api/concierge.ts +++ b/src/pages/api/concierge.ts @@ -144,6 +144,7 @@ async function callClaudeJsonStream( }, ], tool_choice: { type: 'tool', name: toolName }, + thinking: { type: 'disabled' }, }), }); if (!r.ok || !r.body) return null; @@ -256,6 +257,7 @@ async function callClaudeJson( }, ], tool_choice: { type: 'tool', name: toolName }, + thinking: { type: 'disabled' }, }), }); if (!r.ok) return null; @@ -1220,7 +1222,7 @@ async function synthesise( userBlock, 'submit_reply', decisionSchema, - 600, + 800, onText, ) : await callClaudeJson( @@ -1228,7 +1230,7 @@ async function synthesise( userBlock, 'submit_reply', decisionSchema, - 600, + 800, ); if (raw == null) { raw = await callOpenAIJson(system, userBlock, 400); From d6c6b74b7fdc73987cf273e8bf473eb1ba03eeb7 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 22 Sep 2026 20:57:55 +0000 Subject: [PATCH 03/21] widget: Copilot's Claude calls go through the subscription relay The concierge and landing routes no longer hold a paid Anthropic key. They ask claude-sonnet-5 at low effort through the relay, which moves a call from track t1 to t2 to t3 on a rate limit, and read the reply JSON out of the text since the relay runs one turn without tools. A streaming caller now gets the whole reply at once. Authorized by Wolf on 2026-09-22. Co-Authored-By: Claude Opus 5.5 --- src/lib/widget/llmClient.ts | 56 ++++--- src/pages/api/concierge-landing.ts | 95 ++++-------- src/pages/api/concierge.ts | 229 ++++------------------------- 3 files changed, 97 insertions(+), 283 deletions(-) diff --git a/src/lib/widget/llmClient.ts b/src/lib/widget/llmClient.ts index fdf171ad..45894e7b 100644 --- a/src/lib/widget/llmClient.ts +++ b/src/lib/widget/llmClient.ts @@ -1,33 +1,55 @@ /** * 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; +import { + askRelay, + parseJsonReply, + relayConfigured, +} from '@lib/library/magic/relay'; + export const OPENAI_KEY = process.env.OPENAI_API_KEY; -/* Sonnet 5 runs adaptive thinking when `thinking` is omitted and counts - about 30% more tokens than Sonnet 4.6, so every call below disables - thinking and leaves max_tokens headroom for the forced tool reply. */ export const CLAUDE_MODEL = 'claude-sonnet-5'; export const OPENAI_MODEL = 'gpt-4.1'; -export const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; export const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'; -export function anthropicHeaders(): Record { - if (!ANTHROPIC_KEY) { - throw new Error('ANTHROPIC_API_KEY is not set'); +export const claudeConfigured = relayConfigured; + +/** 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, so the caller can fall back. */ +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 { diff --git a/src/pages/api/concierge-landing.ts b/src/pages/api/concierge-landing.ts index a05bd4b9..f6943960 100644 --- a/src/pages/api/concierge-landing.ts +++ b/src/pages/api/concierge-landing.ts @@ -1,10 +1,8 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { - ANTHROPIC_KEY, - ANTHROPIC_URL, - anthropicHeaders, - CLAUDE_MODEL, + askClaudeJson, + claudeConfigured, OPENAI_KEY, OPENAI_MODEL, OPENAI_URL, @@ -76,65 +74,34 @@ async function callClaude( system: string, user: string, ): Promise { - if (!ANTHROPIC_KEY) return null; - try { - const r = await fetch(ANTHROPIC_URL, { - method: 'POST', - headers: anthropicHeaders(), - body: JSON.stringify({ - model: CLAUDE_MODEL, - max_tokens: 480, - system: [ - { type: 'text', text: system, cache_control: { type: 'ephemeral' } }, - ], - messages: [{ role: 'user', content: user }], - tools: [ - { - name: 'submit_landing_line', - description: 'Submit the landing-page reaction.', - input_schema: { - type: 'object', - properties: { - text: { type: 'string' }, - suggestions: { - type: 'array', - items: { type: 'string' }, - }, - }, - required: ['text'], - }, - }, - ], - tool_choice: { type: 'tool', name: 'submit_landing_line' }, - thinking: { type: 'disabled' }, - }), - }); - if (!r.ok) return null; - const data = (await r.json()) as { - content?: Array<{ - type?: string; - name?: string; - input?: { text?: string; suggestions?: unknown }; - }>; - }; - const tool = (data?.content ?? []).find( - b => b?.type === 'tool_use' && b?.name === 'submit_landing_line', - ); - const txt = tool?.input?.text; - if (typeof txt !== 'string') return null; - const sugRaw = tool?.input?.suggestions; - const suggestions = Array.isArray(sugRaw) - ? (sugRaw as unknown[]) - .filter( - (s): s is string => typeof s === 'string' && s.trim().length > 0, - ) - .map(s => s.replace(/\s+/g, ' ').trim().slice(0, 60)) - .slice(0, 4) - : []; - return { text: txt, suggestions }; - } catch { - return null; - } + const input = await askClaudeJson<{ text?: unknown; suggestions?: unknown }>( + system, + user, + { + type: 'object', + properties: { + text: { type: 'string' }, + suggestions: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['text'], + }, + 480, + ); + const txt = input?.text; + if (typeof txt !== 'string') return null; + const sugRaw = input?.suggestions; + const suggestions = Array.isArray(sugRaw) + ? (sugRaw as unknown[]) + .filter( + (s): s is string => typeof s === 'string' && s.trim().length > 0, + ) + .map(s => s.replace(/\s+/g, ' ').trim().slice(0, 60)) + .slice(0, 4) + : []; + return { text: txt, suggestions }; } async function callOpenAI( @@ -320,7 +287,7 @@ export default async function handler( if (req.method !== 'POST') { return res.status(405).json({ error: 'method_not_allowed' }); } - if (!OPENAI_KEY && !ANTHROPIC_KEY) { + if (!OPENAI_KEY && !claudeConfigured()) { return res.status(200).json({ text: '' }); } diff --git a/src/pages/api/concierge.ts b/src/pages/api/concierge.ts index 9b4241ff..27b26dfc 100644 --- a/src/pages/api/concierge.ts +++ b/src/pages/api/concierge.ts @@ -15,10 +15,8 @@ import { } from '@lib/copilotSafety'; import { inSameFamily, isMetaTurn } from '@lib/widget/conciergeHelpers'; import { - ANTHROPIC_KEY, - ANTHROPIC_URL, - anthropicHeaders, - CLAUDE_MODEL, + askClaudeJson, + claudeConfigured, OPENAI_KEY, OPENAI_MODEL, OPENAI_URL, @@ -90,12 +88,10 @@ const RAG_BASE = process.env.UXCORE_RAG_BASE_URL; const CF_ID = process.env.CF_ACCESS_CLIENT_ID; const CF_SECRET = process.env.CF_ACCESS_CLIENT_SECRET; -/* Provider selection — Anthropic wins when its key is present (better - voice fidelity for the keepsimple-team peer voice; gpt-4o/4.1 drift - to marketing-default sludge). Falls back to OpenAI when the key - isn't there so the widget stays alive during the credential drop. - Constants + headers shared with /api/concierge-landing via - src/lib/widget/llmClient.ts. */ +/* Provider selection — Claude wins when the subscription relay is wired + (better voice fidelity for the keepsimple-team peer voice; gpt-4o/4.1 + drift to marketing-default sludge). Falls back to OpenAI when it isn't. + Shared with /api/concierge-landing via src/lib/widget/llmClient.ts. */ type JsonValue = | string @@ -105,175 +101,6 @@ type JsonValue = | JsonValue[] | { [k: string]: JsonValue }; -/* Streaming variant: same call as callClaudeJson but uses - Anthropic's SSE stream. Each time the `text` field inside the - tool's input JSON grows, onText is invoked with the new full - string. Returns the final parsed JSON value (or null on error). - The text-extraction regex is tolerant of mid-construction strings — - we only fire onText when the captured prefix actually grew. */ -async function callClaudeJsonStream( - system: string, - userBlock: string, - toolName: string, - toolSchema: object, - maxTokens: number, - onText: (currentText: string) => void, -): Promise { - if (!ANTHROPIC_KEY) return null; - try { - const r = await fetch(ANTHROPIC_URL, { - method: 'POST', - headers: anthropicHeaders(), - body: JSON.stringify({ - model: CLAUDE_MODEL, - max_tokens: maxTokens, - stream: true, - system: [ - { - type: 'text', - text: system, - cache_control: { type: 'ephemeral' }, - }, - ], - messages: [{ role: 'user', content: userBlock }], - tools: [ - { - name: toolName, - description: 'Submit the structured reply.', - input_schema: toolSchema, - }, - ], - tool_choice: { type: 'tool', name: toolName }, - thinking: { type: 'disabled' }, - }), - }); - if (!r.ok || !r.body) return null; - const reader = r.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; - let partialJson = ''; - let lastEmitted = ''; - const tryEmitText = () => { - const m = partialJson.match(/"text"\s*:\s*"((?:\\.|[^"\\])*)/); - if (!m) return; - const captured = m[1]; - /* Decode JSON escapes safely; on a mid-escape tail (\\), trim - the trailing backslash so JSON.parse won't throw. */ - const safe = captured.endsWith('\\') ? captured.slice(0, -1) : captured; - let decoded: string; - try { - decoded = JSON.parse('"' + safe + '"'); - } catch { - decoded = safe; - } - if (decoded.length > lastEmitted.length) { - lastEmitted = decoded; - onText(decoded); - } - }; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - let nl: number; - while ((nl = buf.indexOf('\n')) !== -1) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - if (!line.startsWith('data:')) continue; - const payload = line.slice(5).trim(); - if (!payload || payload === '[DONE]') continue; - try { - const evt = JSON.parse(payload) as { - type?: string; - delta?: { - type?: string; - partial_json?: string; - }; - }; - if ( - evt.type === 'content_block_delta' && - evt.delta?.type === 'input_json_delta' && - typeof evt.delta.partial_json === 'string' - ) { - partialJson += evt.delta.partial_json; - tryEmitText(); - } - } catch { - /* malformed event line — skip */ - } - } - } - /* Parse the fully accumulated JSON. If the model truncated, salvage - the text we have so far. */ - try { - return JSON.parse(partialJson) as JsonValue; - } catch { - const m = partialJson.match(/"text"\s*:\s*"((?:\\.|[^"\\])*)"/); - if (m) { - try { - const text = JSON.parse('"' + m[1] + '"'); - return { kind: 'answer', text } as JsonValue; - } catch { - return null; - } - } - return null; - } - } catch { - return null; - } -} - -async function callClaudeJson( - system: string, - userBlock: string, - toolName: string, - toolSchema: object, - maxTokens: number, -): Promise { - if (!ANTHROPIC_KEY) return null; - try { - const r = await fetch(ANTHROPIC_URL, { - method: 'POST', - headers: anthropicHeaders(), - body: JSON.stringify({ - model: CLAUDE_MODEL, - max_tokens: maxTokens, - /* Cache the (large, static-per-locale) system prompt across - calls. 5-min TTL is plenty for an interactive session. */ - system: [ - { - type: 'text', - text: system, - cache_control: { type: 'ephemeral' }, - }, - ], - messages: [{ role: 'user', content: userBlock }], - tools: [ - { - name: toolName, - description: 'Submit the structured reply.', - input_schema: toolSchema, - }, - ], - tool_choice: { type: 'tool', name: toolName }, - thinking: { type: 'disabled' }, - }), - }); - if (!r.ok) return null; - const data = (await r.json()) as { - content?: Array<{ type?: string; name?: string; input?: JsonValue }>; - }; - const blocks = Array.isArray(data?.content) ? data.content : []; - const tool = blocks.find( - b => b?.type === 'tool_use' && b?.name === toolName, - ); - return tool?.input ?? null; - } catch { - return null; - } -} - async function callOpenAIJson( system: string, userBlock: string, @@ -1090,7 +917,7 @@ async function synthesise( lastPick: { url: string; title: string; tier: 'high' | 'mid' | 'low' } | null, onText?: (currentText: string) => void, ): Promise { - if (!ANTHROPIC_KEY && !OPENAI_KEY) return null; + if (!claudeConfigured() && !OPENAI_KEY) return null; const baseSystem = lang === 'ru' ? SYSTEM_RU : SYSTEM_EN; const forceNote = @@ -1210,28 +1037,26 @@ async function synthesise( required: ['kind', 'text'], }; - /* Try Anthropic first when its key is configured; fall back to - OpenAI if Claude errors out or the key isn't there. Both return - the same shape, so validation below is provider-agnostic. - When `onText` is provided we use the streaming Anthropic path - so the caller can forward tokens to the visitor live. */ - let raw = - onText !== undefined - ? await callClaudeJsonStream( - system, - userBlock, - 'submit_reply', - decisionSchema, - 800, - onText, - ) - : await callClaudeJson( - system, - userBlock, - 'submit_reply', - decisionSchema, - 800, - ); + /* Try Claude first through the subscription relay; fall back to + OpenAI if every track fails or the relay isn't wired. Both return + the same shape, so validation below is provider-agnostic. The relay + answers in one piece, so a streaming caller gets the whole reply + text at once. */ + let raw = await askClaudeJson( + system, + userBlock, + decisionSchema, + 800, + ); + if ( + onText && + raw && + typeof raw === 'object' && + !Array.isArray(raw) && + typeof raw.text === 'string' + ) { + onText(raw.text); + } if (raw == null) { raw = await callOpenAIJson(system, userBlock, 400); } From bac80e94117ba8312e2c1b94b0fe4c563d384244 Mon Sep 17 00:00:00 2001 From: manager Date: Tue, 22 Sep 2026 20:59:37 +0000 Subject: [PATCH 04/21] widget: drop the paid OpenAI fallback from Copilot Claude through the subscription relay is the only model the concierge and landing routes call. When every track is spent the reply is empty and the widget stays quiet instead of switching to a paid key. Authorized by Wolf on 2026-09-22. Co-Authored-By: Claude Opus 5.5 --- src/lib/widget/llmClient.ts | 17 +------- src/pages/api/concierge-landing.ts | 58 ++-------------------------- src/pages/api/concierge.ts | 62 +++++------------------------- 3 files changed, 13 insertions(+), 124 deletions(-) diff --git a/src/lib/widget/llmClient.ts b/src/lib/widget/llmClient.ts index 45894e7b..961458dd 100644 --- a/src/lib/widget/llmClient.ts +++ b/src/lib/widget/llmClient.ts @@ -14,18 +14,13 @@ import { relayConfigured, } from '@lib/library/magic/relay'; -export const OPENAI_KEY = process.env.OPENAI_API_KEY; - export const CLAUDE_MODEL = 'claude-sonnet-5'; -export const OPENAI_MODEL = 'gpt-4.1'; - -export const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'; export const claudeConfigured = relayConfigured; /** 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, so the caller can fall back. */ + * not parse; the caller then stays quiet. */ export async function askClaudeJson( system: string, user: string, @@ -51,13 +46,3 @@ export async function askClaudeJson( return null; } } - -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/pages/api/concierge-landing.ts b/src/pages/api/concierge-landing.ts index f6943960..a51a05f1 100644 --- a/src/pages/api/concierge-landing.ts +++ b/src/pages/api/concierge-landing.ts @@ -1,13 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { - askClaudeJson, - claudeConfigured, - OPENAI_KEY, - OPENAI_MODEL, - OPENAI_URL, - openAIHeaders, -} from '../../lib/widget/llmClient'; +import { askClaudeJson, claudeConfigured } from '../../lib/widget/llmClient'; import { formatPageIdentity, resolvePageIdentity, @@ -104,50 +97,6 @@ async function callClaude( return { text: txt, suggestions }; } -async function callOpenAI( - system: string, - user: string, -): Promise { - if (!OPENAI_KEY) return null; - try { - const r = await fetch(OPENAI_URL, { - method: 'POST', - headers: openAIHeaders(), - body: JSON.stringify({ - model: OPENAI_MODEL, - temperature: 0.85, - max_tokens: 320, - response_format: { type: 'json_object' }, - messages: [ - { role: 'system', content: system }, - { role: 'user', content: user }, - ], - }), - }); - if (!r.ok) return null; - const data = await r.json(); - const content = data?.choices?.[0]?.message?.content; - if (typeof content !== 'string') return null; - const parsed = JSON.parse(content) as { - text?: string; - suggestions?: unknown; - }; - if (typeof parsed.text !== 'string') return null; - const sugRaw = parsed.suggestions; - const suggestions = Array.isArray(sugRaw) - ? (sugRaw as unknown[]) - .filter( - (s): s is string => typeof s === 'string' && s.trim().length > 0, - ) - .map(s => s.replace(/\s+/g, ' ').trim().slice(0, 60)) - .slice(0, 4) - : []; - return { text: parsed.text, suggestions }; - } catch { - return null; - } -} - const SYSTEM_EN = `You ARE the keepsimple team — speak as us, first-person plural ("we", "our"). The user just opened a card from our chat and landed on a page on our site. Walk up to their desk and drop a short note in TWO beats: BEAT 1 — a sharp angle on the SUBJECT (an opinion, a tradeoff, a wry observation). Not a description of the page. @@ -287,7 +236,7 @@ export default async function handler( if (req.method !== 'POST') { return res.status(405).json({ error: 'method_not_allowed' }); } - if (!OPENAI_KEY && !claudeConfigured()) { + if (!claudeConfigured()) { return res.status(200).json({ text: '' }); } @@ -345,8 +294,7 @@ export default async function handler( `Prior bot answer: ${safePrevAnswer || '—'}`, ].join('\n'); - let result = await callClaude(system, userMsg); - if (result == null) result = await callOpenAI(system, userMsg); + const result = await callClaude(system, userMsg); const text = (result?.text ?? '').trim(); const suggestions = (result?.suggestions ?? []).filter(s => s.length > 0); return res.status(200).json({ text, suggestions }); diff --git a/src/pages/api/concierge.ts b/src/pages/api/concierge.ts index 27b26dfc..7704da70 100644 --- a/src/pages/api/concierge.ts +++ b/src/pages/api/concierge.ts @@ -14,14 +14,7 @@ import { scrubPii, } from '@lib/copilotSafety'; import { inSameFamily, isMetaTurn } from '@lib/widget/conciergeHelpers'; -import { - askClaudeJson, - claudeConfigured, - OPENAI_KEY, - OPENAI_MODEL, - OPENAI_URL, - openAIHeaders, -} from '@lib/widget/llmClient'; +import { askClaudeJson, claudeConfigured } from '@lib/widget/llmClient'; import { formatPageIdentity, type PageIdentity, @@ -88,10 +81,10 @@ const RAG_BASE = process.env.UXCORE_RAG_BASE_URL; const CF_ID = process.env.CF_ACCESS_CLIENT_ID; const CF_SECRET = process.env.CF_ACCESS_CLIENT_SECRET; -/* Provider selection — Claude wins when the subscription relay is wired - (better voice fidelity for the keepsimple-team peer voice; gpt-4o/4.1 - drift to marketing-default sludge). Falls back to OpenAI when it isn't. - Shared with /api/concierge-landing via src/lib/widget/llmClient.ts. */ +/* Claude through the subscription relay is the only model (Wolf, + 2026-09-22): no paid fallback, so when every track is spent the + reply is null and the widget stays quiet. Shared with + /api/concierge-landing via src/lib/widget/llmClient.ts. */ type JsonValue = | string @@ -101,37 +94,6 @@ type JsonValue = | JsonValue[] | { [k: string]: JsonValue }; -async function callOpenAIJson( - system: string, - userBlock: string, - maxTokens: number, -): Promise { - if (!OPENAI_KEY) return null; - try { - const r = await fetch(OPENAI_URL, { - method: 'POST', - headers: openAIHeaders(), - body: JSON.stringify({ - model: OPENAI_MODEL, - temperature: 0.7, - max_tokens: maxTokens, - response_format: { type: 'json_object' }, - messages: [ - { role: 'system', content: system }, - { role: 'user', content: userBlock }, - ], - }), - }); - if (!r.ok) return null; - const data = await r.json(); - const content = data?.choices?.[0]?.message?.content; - if (typeof content !== 'string') return null; - return JSON.parse(content) as JsonValue; - } catch { - return null; - } -} - const COOKIE_NAME = 'aux_sid'; const WINDOW_MS = 10 * 60 * 1000; const LIMIT = 20; @@ -917,7 +879,7 @@ async function synthesise( lastPick: { url: string; title: string; tier: 'high' | 'mid' | 'low' } | null, onText?: (currentText: string) => void, ): Promise { - if (!claudeConfigured() && !OPENAI_KEY) return null; + if (!claudeConfigured()) return null; const baseSystem = lang === 'ru' ? SYSTEM_RU : SYSTEM_EN; const forceNote = @@ -1037,12 +999,9 @@ async function synthesise( required: ['kind', 'text'], }; - /* Try Claude first through the subscription relay; fall back to - OpenAI if every track fails or the relay isn't wired. Both return - the same shape, so validation below is provider-agnostic. The relay - answers in one piece, so a streaming caller gets the whole reply - text at once. */ - let raw = await askClaudeJson( + /* The relay answers in one piece, so a streaming caller gets the + whole reply text at once. */ + const raw = await askClaudeJson( system, userBlock, decisionSchema, @@ -1057,9 +1016,6 @@ async function synthesise( ) { onText(raw.text); } - if (raw == null) { - raw = await callOpenAIJson(system, userBlock, 400); - } if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { return null; } From c1aaa5f258fe9a66026413b8cf39259988a17282 Mon Sep 17 00:00:00 2001 From: manager Date: Wed, 23 Sep 2026 13:52:34 +0000 Subject: [PATCH 05/21] fix(library): cover hotspots find their library by owner id The cover's buildings were bound to usernames. Two owners renamed themselves on production (Mary13 is now Mary, alinamarg is now Alina), so their buildings matched no library and the hover card showed only a name with no About or object counts. Hotspots now carry the owner's account id, which survives a rename and is the same on staging, whose database is a copy of production's. Co-Authored-By: Claude Opus 5.5 --- .../InteractiveCover/InteractiveCover.tsx | 18 +++++++--------- .../InteractiveCover/coverHotspots.ts | 21 +++++++++++++------ src/local-types/library/library.ts | 2 ++ src/utils/library/mapStrapiLibraries.ts | 1 + 4 files changed, 26 insertions(+), 16 deletions(-) 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/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/utils/library/mapStrapiLibraries.ts b/src/utils/library/mapStrapiLibraries.ts index ac0f73dd..771f7a0e 100644 --- a/src/utils/library/mapStrapiLibraries.ts +++ b/src/utils/library/mapStrapiLibraries.ts @@ -180,6 +180,7 @@ export function mapStrapiLibraryEntryToCard( return { id, + userId: attributes.user?.data?.id, username, libraryName, description, From fe006e017d4c61fbef970b4f802821305dede6ed Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 19:11:40 +0000 Subject: [PATCH 06/21] feat(ai-atlas): Terminal pushes the Atlas guide, the page follows without a rebuild POST /api/ai-atlas/guide takes the Terminal's full guide behind a bearer key (AI_ATLAS_PUSH_KEY), cuts it to the fields the page renders, refuses one the adapter cannot draw, stores it on the container's persistent mount and revalidates /ai-atlas in every locale. One journal line per push. The page reads the stored guide in getStaticProps and falls back to the bundled one, so the guide leaves the client chunk and is still never a standalone public file. A tile the Terminal removes is left off the map instead of crashing the render. Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/adapter.ts | 13 ++++-- src/lib/aiAtlas/store.ts | 45 ++++++++++++++++++ src/lib/aiAtlas/stripGuide.ts | 62 +++++++++++++++++++++++++ src/pages/ai-atlas.tsx | 20 ++++++-- src/pages/api/ai-atlas/guide.ts | 82 +++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 src/lib/aiAtlas/store.ts create mode 100644 src/lib/aiAtlas/stripGuide.ts create mode 100644 src/pages/api/ai-atlas/guide.ts diff --git a/src/lib/aiAtlas/adapter.ts b/src/lib/aiAtlas/adapter.ts index 6c5774e2..daff8dbc 100644 --- a/src/lib/aiAtlas/adapter.ts +++ b/src/lib/aiAtlas/adapter.ts @@ -86,11 +86,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. diff --git a/src/lib/aiAtlas/store.ts b/src/lib/aiAtlas/store.ts new file mode 100644 index 00000000..56e203ac --- /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, UTC, success or refusal alike. */ +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..1b6cba2f --- /dev/null +++ b/src/lib/aiAtlas/stripGuide.ts @@ -0,0 +1,62 @@ +/* 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 } 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', + ); + +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)) + 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 guide = { + 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']), + ), + }, + }; + /* 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/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index c78a5805..44a81bd1 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, @@ -7,7 +8,7 @@ import React, { } from 'react'; import { adaptGuide, copy } from '@lib/aiAtlas/adapter'; -import guide from '@lib/aiAtlas/guide.json'; +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. */ /* ============================================================ @@ -2109,7 +2113,7 @@ export function AiAtlasApp({ ); } -export default function AiAtlasPage() { +export default function AiAtlasPage({ guide }: { guide: any }) { return ( <> ); } + +export const getStaticProps: GetStaticProps = async () => { + const { readStoredGuide } = await import('@lib/aiAtlas/store'); + const guide = (await readStoredGuide()) ?? bundledGuide; + /* The push regenerates the page at once; the timer only covers a + redeploy, whose build carries the bundled guide until it rolls. */ + return { props: { guide }, revalidate: 300 }; +}; diff --git a/src/pages/api/ai-atlas/guide.ts b/src/pages/api/ai-atlas/guide.ts new file mode 100644 index 00000000..2ffea546 --- /dev/null +++ b/src/pages/api/ai-atlas/guide.ts @@ -0,0 +1,82 @@ +// motion-passport: exempt — a server route; nothing here is drawn. +import { createHash, timingSafeEqual } from 'crypto'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { journalPush, writeStoredGuide } from '@lib/aiAtlas/store'; +import { stripGuide } from '@lib/aiAtlas/stripGuide'; + +/** + * POST /api/ai-atlas/guide + * + * The Terminal pushes its Atlas guide here on every Atlas deploy, so + * keepsimple.io/ai-atlas follows the Terminal's content without a rebuild + * (Wolf, 2026-09-24). The body is the Terminal's full guide; it is cut to + * the fields the page renders before anything is stored, stored on the + * container's persistent mount, and the page is regenerated in every locale. + * + * Auth: `Authorization: Bearer `. Without the variable + * set on the server the route refuses every push. + * + * Nothing is readable here: the guide reaches visitors only as the HTML of + * /ai-atlas, never as a standalone file. + */ + +export const config = { api: { bodyParser: { sizeLimit: '1mb' } } }; + +const PAGES = ['/ai-atlas', '/ru/ai-atlas', '/hy/ai-atlas']; + +const digest = (value: string) => createHash('sha256').update(value).digest(); + +const authorised = (header: string | undefined) => { + const key = process.env.AI_ATLAS_PUSH_KEY; + if (!key || !header?.startsWith('Bearer ')) return false; + return timingSafeEqual(digest(header.slice(7)), digest(key)); +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'POST only' }); + } + if (!process.env.AI_ATLAS_PUSH_KEY) { + await journalPush({ verdict: 'refused', reason: 'key not configured' }); + return res.status(503).json({ error: 'push key not configured' }); + } + if (!authorised(req.headers.authorization)) { + await journalPush({ verdict: 'refused', reason: 'bad key' }); + return res.status(401).json({ error: 'unauthorised' }); + } + + const result = stripGuide(req.body); + if ('error' in result) { + await journalPush({ verdict: 'refused', reason: result.error }); + return res.status(400).json({ error: result.error }); + } + + const { guide } = result; + await writeStoredGuide(guide); + + const failed: string[] = []; + for (const page of PAGES) { + try { + await res.revalidate(page); + } catch { + failed.push(page); + } + } + + const summary = { + generatedAt: guide.generatedAt, + steps: guide.steps.length, + entries: guide.entries.length, + nodes: guide.system.nodes.length, + bytes: JSON.stringify(guide).length, + revalidated: PAGES.filter(p => !failed.includes(p)), + failed, + }; + await journalPush({ verdict: failed.length ? 'stored' : 'live', ...summary }); + return res.status(failed.length ? 502 : 200).json(summary); +} From 0efbc60f9b7da39c63e25cbc8af6f4c084c01488 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 19:11:40 +0000 Subject: [PATCH 07/21] feat(ai-atlas): Terminal pushes the Atlas guide, the page follows without a rebuild POST /api/ai-atlas/guide takes the Terminal's full guide behind a bearer key (AI_ATLAS_PUSH_KEY), cuts it to the fields the page renders, refuses one the adapter cannot draw, stores it on the container's persistent mount and revalidates /ai-atlas in every locale. One journal line per push. The page reads the stored guide in getStaticProps and falls back to the bundled one, so the guide leaves the client chunk and is still never a standalone public file. A tile the Terminal removes is left off the map instead of crashing the render. Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/adapter.ts | 13 ++++-- src/lib/aiAtlas/store.ts | 45 ++++++++++++++++++ src/lib/aiAtlas/stripGuide.ts | 62 +++++++++++++++++++++++++ src/pages/ai-atlas.tsx | 20 ++++++-- src/pages/api/ai-atlas/guide.ts | 82 +++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 src/lib/aiAtlas/store.ts create mode 100644 src/lib/aiAtlas/stripGuide.ts create mode 100644 src/pages/api/ai-atlas/guide.ts diff --git a/src/lib/aiAtlas/adapter.ts b/src/lib/aiAtlas/adapter.ts index 6c5774e2..daff8dbc 100644 --- a/src/lib/aiAtlas/adapter.ts +++ b/src/lib/aiAtlas/adapter.ts @@ -86,11 +86,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. diff --git a/src/lib/aiAtlas/store.ts b/src/lib/aiAtlas/store.ts new file mode 100644 index 00000000..56e203ac --- /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, UTC, success or refusal alike. */ +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..1b6cba2f --- /dev/null +++ b/src/lib/aiAtlas/stripGuide.ts @@ -0,0 +1,62 @@ +/* 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 } 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', + ); + +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)) + 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 guide = { + 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']), + ), + }, + }; + /* 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/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index c78a5805..44a81bd1 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, @@ -7,7 +8,7 @@ import React, { } from 'react'; import { adaptGuide, copy } from '@lib/aiAtlas/adapter'; -import guide from '@lib/aiAtlas/guide.json'; +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. */ /* ============================================================ @@ -2109,7 +2113,7 @@ export function AiAtlasApp({ ); } -export default function AiAtlasPage() { +export default function AiAtlasPage({ guide }: { guide: any }) { return ( <> ); } + +export const getStaticProps: GetStaticProps = async () => { + const { readStoredGuide } = await import('@lib/aiAtlas/store'); + const guide = (await readStoredGuide()) ?? bundledGuide; + /* The push regenerates the page at once; the timer only covers a + redeploy, whose build carries the bundled guide until it rolls. */ + return { props: { guide }, revalidate: 300 }; +}; diff --git a/src/pages/api/ai-atlas/guide.ts b/src/pages/api/ai-atlas/guide.ts new file mode 100644 index 00000000..2ffea546 --- /dev/null +++ b/src/pages/api/ai-atlas/guide.ts @@ -0,0 +1,82 @@ +// motion-passport: exempt — a server route; nothing here is drawn. +import { createHash, timingSafeEqual } from 'crypto'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { journalPush, writeStoredGuide } from '@lib/aiAtlas/store'; +import { stripGuide } from '@lib/aiAtlas/stripGuide'; + +/** + * POST /api/ai-atlas/guide + * + * The Terminal pushes its Atlas guide here on every Atlas deploy, so + * keepsimple.io/ai-atlas follows the Terminal's content without a rebuild + * (Wolf, 2026-09-24). The body is the Terminal's full guide; it is cut to + * the fields the page renders before anything is stored, stored on the + * container's persistent mount, and the page is regenerated in every locale. + * + * Auth: `Authorization: Bearer `. Without the variable + * set on the server the route refuses every push. + * + * Nothing is readable here: the guide reaches visitors only as the HTML of + * /ai-atlas, never as a standalone file. + */ + +export const config = { api: { bodyParser: { sizeLimit: '1mb' } } }; + +const PAGES = ['/ai-atlas', '/ru/ai-atlas', '/hy/ai-atlas']; + +const digest = (value: string) => createHash('sha256').update(value).digest(); + +const authorised = (header: string | undefined) => { + const key = process.env.AI_ATLAS_PUSH_KEY; + if (!key || !header?.startsWith('Bearer ')) return false; + return timingSafeEqual(digest(header.slice(7)), digest(key)); +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'POST only' }); + } + if (!process.env.AI_ATLAS_PUSH_KEY) { + await journalPush({ verdict: 'refused', reason: 'key not configured' }); + return res.status(503).json({ error: 'push key not configured' }); + } + if (!authorised(req.headers.authorization)) { + await journalPush({ verdict: 'refused', reason: 'bad key' }); + return res.status(401).json({ error: 'unauthorised' }); + } + + const result = stripGuide(req.body); + if ('error' in result) { + await journalPush({ verdict: 'refused', reason: result.error }); + return res.status(400).json({ error: result.error }); + } + + const { guide } = result; + await writeStoredGuide(guide); + + const failed: string[] = []; + for (const page of PAGES) { + try { + await res.revalidate(page); + } catch { + failed.push(page); + } + } + + const summary = { + generatedAt: guide.generatedAt, + steps: guide.steps.length, + entries: guide.entries.length, + nodes: guide.system.nodes.length, + bytes: JSON.stringify(guide).length, + revalidated: PAGES.filter(p => !failed.includes(p)), + failed, + }; + await journalPush({ verdict: failed.length ? 'stored' : 'live', ...summary }); + return res.status(failed.length ? 502 : 200).json(summary); +} From bd984964f41e3d8aa809bf2c03e875c431bebeb2 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 19:21:45 +0000 Subject: [PATCH 08/21] fix(ai-atlas): address review on the guide push route A failed store write is journaled and answered with a 500. Requests without the key leave no journal line, so strangers cannot grow it. The page re-checks the stored guide against the adapter it ships and falls back to the bundled guide. An empty system.nodes gets a clear refusal. Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/store.ts | 2 +- src/lib/aiAtlas/stripGuide.ts | 2 +- src/pages/ai-atlas.tsx | 6 +++++- src/pages/api/ai-atlas/guide.ts | 12 +++++++++--- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/aiAtlas/store.ts b/src/lib/aiAtlas/store.ts index 56e203ac..afef2bc2 100644 --- a/src/lib/aiAtlas/store.ts +++ b/src/lib/aiAtlas/store.ts @@ -31,7 +31,7 @@ export async function writeStoredGuide(guide: any): Promise { await fs.rename(tmp, GUIDE); } -/** One line per push, UTC, success or refusal alike. */ +/** 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 }); diff --git a/src/lib/aiAtlas/stripGuide.ts b/src/lib/aiAtlas/stripGuide.ts index 1b6cba2f..19ea673d 100644 --- a/src/lib/aiAtlas/stripGuide.ts +++ b/src/lib/aiAtlas/stripGuide.ts @@ -30,7 +30,7 @@ export function stripGuide(input: any): StripResult { 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)) + 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' }; diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 44a81bd1..90422336 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -2149,7 +2149,11 @@ export default function AiAtlasPage({ guide }: { guide: any }) { export const getStaticProps: GetStaticProps = async () => { const { readStoredGuide } = await import('@lib/aiAtlas/store'); - const guide = (await readStoredGuide()) ?? bundledGuide; + const { stripGuide } = await import('@lib/aiAtlas/stripGuide'); + /* A stored guide is checked again against the adapter this build + ships; one it can no longer draw falls back to the bundled guide. */ + const stored = stripGuide(await readStoredGuide()); + const guide = 'guide' in stored ? stored.guide : bundledGuide; /* The push regenerates the page at once; the timer only covers a redeploy, whose build carries the bundled guide until it rolls. */ return { props: { guide }, revalidate: 300 }; diff --git a/src/pages/api/ai-atlas/guide.ts b/src/pages/api/ai-atlas/guide.ts index 2ffea546..ed42e0ec 100644 --- a/src/pages/api/ai-atlas/guide.ts +++ b/src/pages/api/ai-atlas/guide.ts @@ -41,12 +41,12 @@ export default async function handler( res.setHeader('Allow', 'POST'); return res.status(405).json({ error: 'POST only' }); } + /* Requests without the key are not the Terminal's pushes and leave no + journal line, so traffic from strangers cannot grow the journal. */ if (!process.env.AI_ATLAS_PUSH_KEY) { - await journalPush({ verdict: 'refused', reason: 'key not configured' }); return res.status(503).json({ error: 'push key not configured' }); } if (!authorised(req.headers.authorization)) { - await journalPush({ verdict: 'refused', reason: 'bad key' }); return res.status(401).json({ error: 'unauthorised' }); } @@ -57,7 +57,13 @@ export default async function handler( } const { guide } = result; - await writeStoredGuide(guide); + try { + await writeStoredGuide(guide); + } catch (e) { + const error = `store failed: ${(e as Error).message}`; + await journalPush({ verdict: 'failed', reason: error }); + return res.status(500).json({ error }); + } const failed: string[] = []; for (const page of PAGES) { From 7138b1586a22405af34e54b8901e55941f1e7363 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 19:21:45 +0000 Subject: [PATCH 09/21] fix(ai-atlas): address review on the guide push route A failed store write is journaled and answered with a 500. Requests without the key leave no journal line, so strangers cannot grow it. The page re-checks the stored guide against the adapter it ships and falls back to the bundled guide. An empty system.nodes gets a clear refusal. Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/store.ts | 2 +- src/lib/aiAtlas/stripGuide.ts | 2 +- src/pages/ai-atlas.tsx | 6 +++++- src/pages/api/ai-atlas/guide.ts | 12 +++++++++--- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/aiAtlas/store.ts b/src/lib/aiAtlas/store.ts index 56e203ac..afef2bc2 100644 --- a/src/lib/aiAtlas/store.ts +++ b/src/lib/aiAtlas/store.ts @@ -31,7 +31,7 @@ export async function writeStoredGuide(guide: any): Promise { await fs.rename(tmp, GUIDE); } -/** One line per push, UTC, success or refusal alike. */ +/** 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 }); diff --git a/src/lib/aiAtlas/stripGuide.ts b/src/lib/aiAtlas/stripGuide.ts index 1b6cba2f..19ea673d 100644 --- a/src/lib/aiAtlas/stripGuide.ts +++ b/src/lib/aiAtlas/stripGuide.ts @@ -30,7 +30,7 @@ export function stripGuide(input: any): StripResult { 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)) + 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' }; diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 44a81bd1..90422336 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -2149,7 +2149,11 @@ export default function AiAtlasPage({ guide }: { guide: any }) { export const getStaticProps: GetStaticProps = async () => { const { readStoredGuide } = await import('@lib/aiAtlas/store'); - const guide = (await readStoredGuide()) ?? bundledGuide; + const { stripGuide } = await import('@lib/aiAtlas/stripGuide'); + /* A stored guide is checked again against the adapter this build + ships; one it can no longer draw falls back to the bundled guide. */ + const stored = stripGuide(await readStoredGuide()); + const guide = 'guide' in stored ? stored.guide : bundledGuide; /* The push regenerates the page at once; the timer only covers a redeploy, whose build carries the bundled guide until it rolls. */ return { props: { guide }, revalidate: 300 }; diff --git a/src/pages/api/ai-atlas/guide.ts b/src/pages/api/ai-atlas/guide.ts index 2ffea546..ed42e0ec 100644 --- a/src/pages/api/ai-atlas/guide.ts +++ b/src/pages/api/ai-atlas/guide.ts @@ -41,12 +41,12 @@ export default async function handler( res.setHeader('Allow', 'POST'); return res.status(405).json({ error: 'POST only' }); } + /* Requests without the key are not the Terminal's pushes and leave no + journal line, so traffic from strangers cannot grow the journal. */ if (!process.env.AI_ATLAS_PUSH_KEY) { - await journalPush({ verdict: 'refused', reason: 'key not configured' }); return res.status(503).json({ error: 'push key not configured' }); } if (!authorised(req.headers.authorization)) { - await journalPush({ verdict: 'refused', reason: 'bad key' }); return res.status(401).json({ error: 'unauthorised' }); } @@ -57,7 +57,13 @@ export default async function handler( } const { guide } = result; - await writeStoredGuide(guide); + try { + await writeStoredGuide(guide); + } catch (e) { + const error = `store failed: ${(e as Error).message}`; + await journalPush({ verdict: 'failed', reason: error }); + return res.status(500).json({ error }); + } const failed: string[] = []; for (const page of PAGES) { From 4732e4f13d3e194ffccccd2597e9c673aa30ccc5 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 20:17:13 +0000 Subject: [PATCH 10/21] ai-atlas: Grok joins the Engine and Models cards, Delivered work names the cross-vendor review Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/features.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 3e789e520490334d8092ec0e1b484f8fec06aa17 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 20:19:01 +0000 Subject: [PATCH 11/21] ai-atlas: Wolf's card texts can arrive with the pushed guide A push may carry cards (card id to paragraphs); a card listed there replaces its copy in features.ts, so text edits reach /ai-atlas without a build. Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/adapter.ts | 9 +++++---- src/lib/aiAtlas/stripGuide.ts | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/lib/aiAtlas/adapter.ts b/src/lib/aiAtlas/adapter.ts index daff8dbc..b033190b 100644 --- a/src/lib/aiAtlas/adapter.ts +++ b/src/lib/aiAtlas/adapter.ts @@ -6,11 +6,12 @@ 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); - export function adaptGuide(guide: any) { + /* 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]) { diff --git a/src/lib/aiAtlas/stripGuide.ts b/src/lib/aiAtlas/stripGuide.ts index 19ea673d..0f33b4e8 100644 --- a/src/lib/aiAtlas/stripGuide.ts +++ b/src/lib/aiAtlas/stripGuide.ts @@ -19,6 +19,21 @@ const isRecordList = (value: unknown) => 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 <= 4000), + )); + export type StripResult = { guide: any } | { error: string }; export function stripGuide(input: any): StripResult { @@ -34,8 +49,10 @@ export function stripGuide(input: any): StripResult { 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' }; + if (!isCards(input.cards)) + return { error: 'cards must map a card id to a list of paragraphs' }; - const guide = { + const guide: any = { generatedAt: input.generatedAt, steps: input.steps.map((s: any) => pick(s, ['id', 'title', 'location', 'text', 'children']), @@ -49,6 +66,7 @@ export function stripGuide(input: any): StripResult { ), }, }; + if (input.cards) guide.cards = input.cards; /* 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 { From 0bd4d306113888ddd30d40c4c5fa7739d1978d47 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 20:17:13 +0000 Subject: [PATCH 12/21] ai-atlas: Grok joins the Engine and Models cards, Delivered work names the cross-vendor review Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/features.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 1a7ef295777bedbb144e7b43d8d0873f26d85a52 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 20:19:01 +0000 Subject: [PATCH 13/21] ai-atlas: Wolf's card texts can arrive with the pushed guide A push may carry cards (card id to paragraphs); a card listed there replaces its copy in features.ts, so text edits reach /ai-atlas without a build. Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/adapter.ts | 9 +++++---- src/lib/aiAtlas/stripGuide.ts | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/lib/aiAtlas/adapter.ts b/src/lib/aiAtlas/adapter.ts index daff8dbc..b033190b 100644 --- a/src/lib/aiAtlas/adapter.ts +++ b/src/lib/aiAtlas/adapter.ts @@ -6,11 +6,12 @@ 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); - export function adaptGuide(guide: any) { + /* 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]) { diff --git a/src/lib/aiAtlas/stripGuide.ts b/src/lib/aiAtlas/stripGuide.ts index 19ea673d..0f33b4e8 100644 --- a/src/lib/aiAtlas/stripGuide.ts +++ b/src/lib/aiAtlas/stripGuide.ts @@ -19,6 +19,21 @@ const isRecordList = (value: unknown) => 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 <= 4000), + )); + export type StripResult = { guide: any } | { error: string }; export function stripGuide(input: any): StripResult { @@ -34,8 +49,10 @@ export function stripGuide(input: any): StripResult { 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' }; + if (!isCards(input.cards)) + return { error: 'cards must map a card id to a list of paragraphs' }; - const guide = { + const guide: any = { generatedAt: input.generatedAt, steps: input.steps.map((s: any) => pick(s, ['id', 'title', 'location', 'text', 'children']), @@ -49,6 +66,7 @@ export function stripGuide(input: any): StripResult { ), }, }; + if (input.cards) guide.cards = input.cards; /* 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 { From 7450f41eb2ee49c5f4a5d055f200a3de149f4dcc Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 24 Sep 2026 20:24:27 +0000 Subject: [PATCH 14/21] ai-atlas: the Terminal's push sets the map and every page word A push may now carry, beside cards: - stages: per stage, in step order, a label and up to three tiles; - copy: any page text key (copy.json strings, ring and node labels, SEO title and description) mapped to new text. Each is validated before storing; the map keeps its built-in selection and words for anything the push leaves out. Co-Authored-By: Claude Opus 5.5 --- src/lib/aiAtlas/adapter.ts | 102 +++++++++++++++++++++++++++------- src/lib/aiAtlas/stripGuide.ts | 53 +++++++++++++++++- src/pages/ai-atlas.tsx | 27 +++++---- 3 files changed, 146 insertions(+), 36 deletions(-) diff --git a/src/lib/aiAtlas/adapter.ts b/src/lib/aiAtlas/adapter.ts index b033190b..5694a280 100644 --- a/src/lib/aiAtlas/adapter.ts +++ b/src/lib/aiAtlas/adapter.ts @@ -6,7 +6,47 @@ export const copy: any = { linesValue: (n: number) => String(n), introInhabitantsTpl: () => '', }; +/* 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), +]); + +/* 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. */ @@ -37,7 +77,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'], @@ -46,7 +95,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'], @@ -140,6 +189,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', @@ -165,13 +216,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, @@ -179,13 +230,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, @@ -196,31 +247,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/stripGuide.ts b/src/lib/aiAtlas/stripGuide.ts index 0f33b4e8..4cb427a0 100644 --- a/src/lib/aiAtlas/stripGuide.ts +++ b/src/lib/aiAtlas/stripGuide.ts @@ -5,7 +5,7 @@ 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 } from './adapter'; +import { adaptGuide, MAX_TILES_PER_STAGE, PAGE_TEXT_KEYS } from './adapter'; const pick = (obj: any, keys: string[]) => Object.fromEntries( @@ -34,6 +34,46 @@ const isCards = (value: unknown) => paragraphs.every(p => typeof p === 'string' && 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 <= 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; +}; + export type StripResult = { guide: any } | { error: string }; export function stripGuide(input: any): StripResult { @@ -51,6 +91,14 @@ export function stripGuide(input: any): StripResult { return { error: 'a step has no children list' }; 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 text' }; + 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, @@ -67,6 +115,9 @@ export function stripGuide(input: any): StripResult { }, }; 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 { diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 90422336..e63d132d 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -1560,7 +1560,7 @@ export function AiAtlasApp({ {t.welcomeBanner}
- TERMINAL DOCUMENTATION + {t.metaLabel}
@@ -1662,10 +1662,7 @@ export function AiAtlasApp({ - + {data.projects.members .slice(0, -1) .map((p: any, i: number) => { @@ -2073,7 +2070,7 @@ export function AiAtlasApp({ setFocusedNode(e.target.value || null); }} > - + {Object.entries(data.dossiers).map(([id, d]: any) => (