mirror-mind 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ // CR203: a turn's persona, by its subject, or none.
2
+ //
3
+ // Until CR203 a turn always had a persona: a keyword count chose one, and when
4
+ // nothing scored the session's kept answering. Measured on production data, a
5
+ // third of consecutive turns switched persona and specialists from unrelated
6
+ // domains answered. The Navigator's criterion: a wrong persona is worse than
7
+ // none, because it puts a false specialist into the answer, while no persona
8
+ // only loses one domain's lens.
9
+ //
10
+ // So a turn's persona is, in order:
11
+ //
12
+ // 1. the router's answer: a Decisions model through OpenRouter's Decisions
13
+ // API, asked one Choice (which persona) and one Noul (does the subject
14
+ // belong to any of them) in one request; the Choice is taken only when
15
+ // the Noul is at or above the threshold and it names a known persona;
16
+ // 2. else the persona the turn's Journey names as its own default
17
+ // (`default_persona` in the Journey's metadata): the Journey's word, not
18
+ // an inference;
19
+ // 3. else none.
20
+ //
21
+ // Every failure lands on none: no key, a timeout, a refusal, a malformed
22
+ // answer, an unknown persona. The router never throws into a turn. Both the
23
+ // doorman's delta and the full load call `routeTurnPersona`, so the two paths
24
+ // cannot disagree.
25
+ //
26
+ // The route is `alpha`. Its request and answer shapes live here and nowhere
27
+ // else, pinned by a contract test, so a change in the shape fails a test, not
28
+ // a turn.
29
+
30
+ import { readFileSync } from "node:fs";
31
+ import type { Database, StatementRunner } from "#db/database.ts";
32
+ import { descriptorsByLayer } from "#descriptor/descriptorRead.ts";
33
+ import { logLlmCall } from "#observability/llmCalls.ts";
34
+ import {
35
+ DEFAULT_PERSONA_ROUTER_THRESHOLD,
36
+ hasOpenRouterKey,
37
+ type ProviderConfig,
38
+ resolveLlmTimeoutMs,
39
+ resolvePersonaRouterModel,
40
+ resolvePersonaRouterThreshold,
41
+ resolveProviderConfig,
42
+ } from "#providers/config.ts";
43
+ import { createOpenRouterClient, type OpenRouterClient } from "#providers/openrouter.ts";
44
+ import { sliceCodePoints } from "./reception.ts";
45
+
46
+ /** A persona as the router offers it: its key and what it is for. */
47
+ export interface RoutablePersona {
48
+ readonly key: string;
49
+ readonly description: string;
50
+ }
51
+
52
+ export interface PersonaRouteInput {
53
+ readonly prompt: string;
54
+ readonly journey: string | null;
55
+ readonly personas: readonly RoutablePersona[];
56
+ }
57
+
58
+ /** Why the router answered what it did; content-free. */
59
+ export type PersonaRoutingOutcome =
60
+ | "chosen"
61
+ | "below_threshold"
62
+ | "unknown_persona"
63
+ | "malformed"
64
+ | "failed"
65
+ | "no_key"
66
+ | "no_personas";
67
+
68
+ export interface PersonaRouting {
69
+ readonly persona: string | null;
70
+ readonly outcome: PersonaRoutingOutcome;
71
+ /** The model asked; absent when no call was made. */
72
+ readonly model?: string;
73
+ readonly noul?: number;
74
+ readonly latencyMs?: number;
75
+ readonly costUsd?: number | null;
76
+ }
77
+
78
+ export interface PersonaRouter {
79
+ route(input: PersonaRouteInput): Promise<PersonaRouting>;
80
+ }
81
+
82
+ /** The Decisions route, under `/api`, not the chat client's `/api/v1`. */
83
+ export const DECISIONS_BASE_URL = "https://openrouter.ai/api";
84
+ export const DECISIONS_PATH = "/alpha/decisions";
85
+
86
+ /** The bounds the eval measured with: what the model reads of each part. */
87
+ const REQUEST_CHARS = 2000;
88
+ const DESCRIPTION_CHARS = 280;
89
+ const NOUL_DESCRIPTION_CHARS = 90;
90
+
91
+ /** The request body, as the eval of 2026-11-07 sent it. Pure. */
92
+ export function decisionsRequest(model: string, input: PersonaRouteInput): unknown {
93
+ const criteria = Object.fromEntries(
94
+ input.personas.map((persona) => [
95
+ persona.key,
96
+ sliceCodePoints(persona.description, DESCRIPTION_CHARS),
97
+ ]),
98
+ );
99
+ const listed = input.personas
100
+ .map(
101
+ (persona) =>
102
+ `${persona.key}: ${sliceCodePoints(persona.description, NOUL_DESCRIPTION_CHARS)}`,
103
+ )
104
+ .join("; ");
105
+ return {
106
+ model,
107
+ state: { journey: input.journey, request: sliceCodePoints(input.prompt, REQUEST_CHARS) },
108
+ questions: {
109
+ persona: {
110
+ type: "choice",
111
+ instructions:
112
+ "Which specialist should answer this request, judging only by the subject of `request`?",
113
+ criteria,
114
+ },
115
+ fits: {
116
+ type: "noul",
117
+ instructions: `Does the subject of \`request\` clearly belong to the domain of one specific specialist from this list: ${listed}?`,
118
+ criteria: {
119
+ true: "The request asks for expertise that one listed specialist clearly owns.",
120
+ false:
121
+ "The request is a plain instruction, a confirmation, a general question, or its subject belongs to no listed specialist.",
122
+ },
123
+ },
124
+ },
125
+ };
126
+ }
127
+
128
+ function record(value: unknown): Record<string, unknown> | null {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value)
130
+ ? (value as Record<string, unknown>)
131
+ : null;
132
+ }
133
+
134
+ /** The answer read against the personas offered and the threshold. Pure. */
135
+ export function readDecisionsAnswer(
136
+ body: unknown,
137
+ personas: readonly RoutablePersona[],
138
+ threshold: number,
139
+ ): PersonaRouting {
140
+ const answers = record(record(body)?.answers);
141
+ const choice = record(answers?.persona)?.choice;
142
+ const noul = record(answers?.fits)?.noul;
143
+ const cost = record(record(body)?.usage)?.cost;
144
+ const costUsd = typeof cost === "number" && Number.isFinite(cost) ? cost : null;
145
+ if (typeof choice !== "string" || typeof noul !== "number" || !(noul >= 0 && noul <= 1)) {
146
+ return { persona: null, outcome: "malformed", costUsd };
147
+ }
148
+ if (noul < threshold) return { persona: null, outcome: "below_threshold", noul, costUsd };
149
+ if (!personas.some((persona) => persona.key === choice)) {
150
+ return { persona: null, outcome: "unknown_persona", noul, costUsd };
151
+ }
152
+ return { persona: choice, outcome: "chosen", noul, costUsd };
153
+ }
154
+
155
+ export interface LivePersonaRouterOptions {
156
+ /** Where the key and the settings are read; a host passes its own. */
157
+ env?: Record<string, string | undefined>;
158
+ /** Tests only: a client in place of the live one. */
159
+ createClient?: (config: ProviderConfig) => OpenRouterClient;
160
+ /** Tests only: the clock latency is measured with. */
161
+ clock?: () => number;
162
+ }
163
+
164
+ /**
165
+ * The router over OpenRouter's Decisions API: one attempt, bounded like
166
+ * reception (10 s), since a person's turn waits on it. The eval's p95 was 1.1 s.
167
+ */
168
+ export class LivePersonaRouter implements PersonaRouter {
169
+ readonly #env: Record<string, string | undefined>;
170
+ readonly #createClient: (config: ProviderConfig) => OpenRouterClient;
171
+ readonly #clock: () => number;
172
+
173
+ constructor(options: LivePersonaRouterOptions = {}) {
174
+ this.#env = options.env ?? process.env;
175
+ this.#createClient = options.createClient ?? ((config) => createOpenRouterClient(config));
176
+ this.#clock = options.clock ?? (() => performance.now());
177
+ }
178
+
179
+ async route(input: PersonaRouteInput): Promise<PersonaRouting> {
180
+ if (input.personas.length === 0) return { persona: null, outcome: "no_personas" };
181
+ if (!hasOpenRouterKey(this.#env)) return { persona: null, outcome: "no_key" };
182
+ const env = this.#env;
183
+ const model = resolvePersonaRouterModel({ env });
184
+ const started = this.#clock();
185
+ try {
186
+ const client = this.#createClient({
187
+ ...resolveProviderConfig("openrouter", { env }),
188
+ baseUrl: DECISIONS_BASE_URL,
189
+ });
190
+ const body = await client.postJson(DECISIONS_PATH, decisionsRequest(model, input), {
191
+ timeoutMs: resolveLlmTimeoutMs("reception", { env }),
192
+ maxRetries: 0,
193
+ });
194
+ const latencyMs = Math.round(this.#clock() - started);
195
+ return {
196
+ ...readDecisionsAnswer(body, input.personas, resolvePersonaRouterThreshold({ env })),
197
+ model,
198
+ latencyMs,
199
+ };
200
+ } catch {
201
+ return {
202
+ persona: null,
203
+ outcome: "failed",
204
+ model,
205
+ latencyMs: Math.round(this.#clock() - started),
206
+ };
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Where a replay run finds its router's recorded answers. A family's replay
213
+ * reaches no network, and without this its turns could only ever have none:
214
+ * the route from the command to the session row would go untested.
215
+ */
216
+ export const PERSONA_ROUTER_REPLAY_ENV = "MIRROR_TS_PERSONA_ROUTER_REPLAY";
217
+
218
+ export interface PersonaRouterReplayFixture {
219
+ readonly kind: "persona_router";
220
+ /** The first answer whose `contains` is in the prompt, case aside, is the route's body. */
221
+ readonly answers: readonly { readonly contains: string; readonly body: unknown }[];
222
+ }
223
+
224
+ /**
225
+ * Recorded Decisions bodies, read through the same `readDecisionsAnswer` the
226
+ * live route's are. A prompt no answer matches is below the threshold. No
227
+ * model is named, so a replay writes no ledger row.
228
+ */
229
+ export class ReplayPersonaRouter implements PersonaRouter {
230
+ readonly #fixture: PersonaRouterReplayFixture;
231
+ readonly #threshold: number;
232
+
233
+ constructor(fixture: PersonaRouterReplayFixture, threshold = DEFAULT_PERSONA_ROUTER_THRESHOLD) {
234
+ this.#fixture = fixture;
235
+ this.#threshold = threshold;
236
+ }
237
+
238
+ async route(input: PersonaRouteInput): Promise<PersonaRouting> {
239
+ const prompt = input.prompt.toLowerCase();
240
+ const answer = this.#fixture.answers.find((candidate) =>
241
+ prompt.includes(candidate.contains.toLowerCase()),
242
+ );
243
+ if (!answer) return { persona: null, outcome: "below_threshold" };
244
+ const {
245
+ model: _model,
246
+ latencyMs: _latency,
247
+ ...read
248
+ } = readDecisionsAnswer(answer.body, input.personas, this.#threshold);
249
+ return read;
250
+ }
251
+ }
252
+
253
+ export function loadReplayPersonaRouter(path: string): ReplayPersonaRouter {
254
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
255
+ const fixture = record(parsed);
256
+ if (fixture?.kind !== "persona_router" || !Array.isArray(fixture.answers)) {
257
+ throw new Error(`${PERSONA_ROUTER_REPLAY_ENV} is not a persona_router fixture: ${path}`);
258
+ }
259
+ return new ReplayPersonaRouter(fixture as unknown as PersonaRouterReplayFixture);
260
+ }
261
+
262
+ /**
263
+ * Every persona with what it is for: its own `description` (seeded from its
264
+ * YAML), else its generated descriptor, else the start of its content. The
265
+ * description comes first because it is the persona's declared purpose and
266
+ * what the eval measured with; the rerun on the Core's code with descriptors
267
+ * first doubled the wrong picks, since 11 of 28 production personas had none
268
+ * and fell back to a heading.
269
+ */
270
+ export function routablePersonas(db: Database): RoutablePersona[] {
271
+ const descriptors = new Map(
272
+ descriptorsByLayer(db, "persona").map((row) => [row.key, row.descriptor]),
273
+ );
274
+ return db
275
+ .prepare("SELECT key, content, metadata FROM identity WHERE layer = 'persona' ORDER BY key")
276
+ .all()
277
+ .map((row) => {
278
+ const key = String(row.key);
279
+ return {
280
+ key,
281
+ description:
282
+ declaredDescription(row.metadata) ||
283
+ descriptors.get(key)?.trim() ||
284
+ sliceCodePoints(String(row.content), 200),
285
+ };
286
+ });
287
+ }
288
+
289
+ function declaredDescription(metadata: unknown): string {
290
+ if (typeof metadata !== "string") return "";
291
+ try {
292
+ const description = record(JSON.parse(metadata))?.description;
293
+ return typeof description === "string" ? description.trim() : "";
294
+ } catch {
295
+ return "";
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The persona a Journey names as its own default, when it names one that
301
+ * exists. Read from the Journey's metadata (`default_persona`).
302
+ */
303
+ export function journeyDefaultPersona(db: Database, journey: string | null): string | null {
304
+ if (!journey) return null;
305
+ const row = db
306
+ .prepare("SELECT metadata FROM identity WHERE layer = 'journey' AND key = ?")
307
+ .get(journey);
308
+ if (typeof row?.metadata !== "string") return null;
309
+ let named: unknown;
310
+ try {
311
+ named = record(JSON.parse(row.metadata))?.default_persona;
312
+ } catch {
313
+ return null;
314
+ }
315
+ if (typeof named !== "string" || !named.trim()) return null;
316
+ const exists = db
317
+ .prepare("SELECT 1 AS found FROM identity WHERE layer = 'persona' AND key = ?")
318
+ .get(named.trim());
319
+ return exists ? named.trim() : null;
320
+ }
321
+
322
+ export interface TurnPersona {
323
+ readonly persona: string | null;
324
+ /** Where the persona came from; null when the turn has none. */
325
+ readonly source: "router" | "journey" | null;
326
+ }
327
+
328
+ export interface RouteTurnPersonaInput {
329
+ readonly prompt: string;
330
+ readonly journey: string | null;
331
+ /** Absent: no call is made, and the turn falls to the Journey's default or none. */
332
+ readonly router?: PersonaRouter;
333
+ /** Where the call's ledger row goes; content-free. */
334
+ readonly onRouting?: (routing: PersonaRouting) => void;
335
+ }
336
+
337
+ /** The turn's persona: the router's answer, else the Journey's default, else none. */
338
+ export async function routeTurnPersona(
339
+ db: Database,
340
+ input: RouteTurnPersonaInput,
341
+ ): Promise<TurnPersona> {
342
+ if (input.router) {
343
+ const personas = routablePersonas(db);
344
+ let routing: PersonaRouting;
345
+ try {
346
+ routing = await input.router.route({
347
+ prompt: input.prompt,
348
+ journey: input.journey,
349
+ personas,
350
+ });
351
+ } catch {
352
+ routing = { persona: null, outcome: "failed" };
353
+ }
354
+ input.onRouting?.(routing);
355
+ const chosen = routing.persona;
356
+ if (chosen !== null && personas.some((persona) => persona.key === chosen)) {
357
+ return { persona: chosen, source: "router" };
358
+ }
359
+ }
360
+ const fallback = journeyDefaultPersona(db, input.journey);
361
+ return fallback ? { persona: fallback, source: "journey" } : { persona: null, source: null };
362
+ }
363
+
364
+ /**
365
+ * One `llm_calls` row for a call that was made, without the prompt or the
366
+ * answer in any logging mode: the subject of a person's turn is not the
367
+ * ledger's to keep.
368
+ */
369
+ export function personaRoutingLedger(
370
+ db: StatementRunner,
371
+ context: { sessionId?: string | null; now?: () => string } = {},
372
+ ): (routing: PersonaRouting) => void {
373
+ return (routing) => {
374
+ if (!routing.model) return;
375
+ logLlmCall(
376
+ db,
377
+ {
378
+ role: "persona_routing",
379
+ model: routing.model,
380
+ prompt: "",
381
+ response: "",
382
+ latencyMs: routing.latencyMs ?? null,
383
+ costUsd: routing.costUsd ?? null,
384
+ sessionId: context.sessionId ?? null,
385
+ },
386
+ context.now ? { now: context.now } : {},
387
+ );
388
+ };
389
+ }
@@ -161,6 +161,31 @@ export function resolveExtractionReasoning(
161
161
  return { model, effort: null, source: null, invalid: setting.invalid };
162
162
  }
163
163
 
164
+ /**
165
+ * CR203: the Decisions model that routes a turn's persona, and the Noul score
166
+ * at or above which its choice is taken. The eval of 2026-11-07 chose the pair
167
+ * (`docs/project/refinement/rs010-cv22-oracle-and-port-hygiene/`); they are
168
+ * settings so a later model replaces it without a code change, measured again
169
+ * on a labelled set first. An empty or invalid value reads as unset: a typo
170
+ * must not turn routing off, or on for every turn.
171
+ */
172
+ export const DEFAULT_PERSONA_ROUTER_MODEL = "liquid/d1-ultra";
173
+ export const DEFAULT_PERSONA_ROUTER_THRESHOLD = 0.6;
174
+
175
+ export function resolvePersonaRouterModel(options: ModelPinOptions = {}): string {
176
+ const env = options.env ?? process.env;
177
+ return env.MEMORY_PERSONA_ROUTER_MODEL?.trim() || DEFAULT_PERSONA_ROUTER_MODEL;
178
+ }
179
+
180
+ export function resolvePersonaRouterThreshold(options: ModelPinOptions = {}): number {
181
+ const env = options.env ?? process.env;
182
+ const raw = env.MEMORY_PERSONA_ROUTER_THRESHOLD?.trim() ?? "";
183
+ const parsed = Number(raw);
184
+ return raw !== "" && Number.isFinite(parsed) && parsed >= 0 && parsed <= 1
185
+ ? parsed
186
+ : DEFAULT_PERSONA_ROUTER_THRESHOLD;
187
+ }
188
+
164
189
  // Observability (AI-09, CV9.E2.S13): mirrors Python's MEMORY_LOG_LLM_CALLS
165
190
  // resolution exactly -- off | metadata | full, default metadata (never a
166
191
  // silent full -- bodies are opt-in only). Legacy "1" maps to "full" for