mirror-mind 0.40.1 → 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.
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [v0.41.0 — A Persona When the Subject Asks for One](v0.41.0.md) — minor release of CR203 and CR204: a Mirror Mode turn's persona is routed by its subject, by a Decisions model through OpenRouter taken only above a threshold, else the Journey's `default_persona`, else none; a turn never inherits the session's persona, and a turn with none tells the model not to sign; every routing failure is none; one content-free `llm_calls` row per call; `mirror-mind/desktop` returns `personaName` beside the persona's id. No migration.
15
16
  - [v0.40.1 — The Bins You Named, or Why Not](v0.40.1.md) — patch release of CR197: the plugin's hook wrappers and MCP entry use `MIRROR_BIN` only as an absolute path to a directory holding the bin they need, the rule `MIRROR_NODE` follows since v0.39.5; a relative value is never used, so a project's own `bin/mirror-hook` cannot run, and a refused value is named with its reason, once a day in `hooks.log` and on the MCP entry's stderr; the forward-slash `C:/…` form Windows machines set counts as absolute, in CI under Git Bash. Also carries CV9's re-baseline (documents only). No migration.
16
17
  - [v0.40.0 — A Reply Stays in Its Own Conversation, and a Journey Can Go](v0.40.0.md) — minor release closing the first release train: CR151, a session's transcript fills only the conversation that session is bound to, each reply stamped with its transcript time, where it used to fill every open reply-less conversation in the transcript's window, another session's included; a transcript no session owns backfills nothing; and CR200, previewed as v0.40.0-next.1, `mirror-mind/desktop` deletes a Journey with every record that belongs to it, by digest, behind a verified backup. Update every install that has a home. No migration.
17
18
  - [v0.40.0-next.1 — A Journey Can Go, Showing What Goes With It](v0.40.0-next.1.md) — preview under npm's `next`, read by no updater, for Nautilus for Mirror (the release train): CR200, `mirror-mind/desktop` gains `previewJourneyDeletion` and `deleteJourneyWithRecords`, deleting a Journey with every record that belongs to it after a preview that counts it, by digest, behind a verified backup; child Journeys refuse. Terminal users receive it with v0.40.0. No migration.
@@ -0,0 +1,116 @@
1
+ ---
2
+ digest: >
3
+ v0.41.0 routes each Mirror Mode turn's persona by what the turn is about, and lets a turn
4
+ have none. A Decisions model chooses among your personas and says whether the subject
5
+ belongs to any of them; its choice is taken only above a threshold, else the persona the
6
+ turn's Journey names as its default, else none. A turn no longer inherits the session's
7
+ persona, and a turn with none tells the model not to sign. `mirror-mind/desktop` also hands
8
+ its host the persona's own name beside its id. One new model call per turn, made beside
9
+ the embedding the turn already makes. No schema change, no migration.
10
+ ---
11
+
12
+ # v0.41.0 — A Persona When the Subject Asks for One
13
+
14
+ **Date:** 2026-11-08
15
+
16
+ ## Highlights
17
+
18
+ - **A turn's persona follows its subject, or there is none**
19
+ ([CR203](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr203-route-a-turns-persona-by-its-subject-and-let-it-have-none.md)).
20
+ In Mirror Mode, on the delta of an ordinary turn and on the full load alike, the persona
21
+ is, in order: the one you named (`mirror load --persona`); the router's answer, a
22
+ Decisions model through OpenRouter's Decisions API asked which persona the subject
23
+ belongs to and whether it belongs to any, taken only at or above a threshold; the persona
24
+ the turn's Journey names as `default_persona` in its metadata; or none. It is never the
25
+ session's by inheritance.
26
+ - **None is said, not left silent.** A turn with no persona delivers
27
+ `persona: none` and the clause
28
+ `answer as the mirror; no persona this turn: do not sign, and do not answer as an earlier persona`,
29
+ so the model does not keep signing as the last persona it answered as.
30
+ - **Every routing failure is none, and the turn goes on.** No key, a timeout, a refusal, a
31
+ malformed answer or a persona that does not exist each give a turn with no persona. One
32
+ attempt, bounded like reception (`MEMORY_LLM_TIMEOUT_RECEPTION`, 10 s by default).
33
+ - **Two settings.** `MEMORY_PERSONA_ROUTER_MODEL` (`liquid/d1-ultra`) and
34
+ `MEMORY_PERSONA_ROUTER_THRESHOLD` (`0.6`), in [`REFERENCE.md`](../../REFERENCE.md); an
35
+ invalid value reads as unset.
36
+ - **The host gets the persona's name**
37
+ ([CR204](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr204-hand-the-host-the-personas-name-with-its-id.md)).
38
+ `deliverMirrorContext` in `mirror-mind/desktop` returns `personaName`, the persona's own
39
+ `name` from its metadata, or `null`; `persona` stays the id. Additive.
40
+ - No schema change, no migration.
41
+
42
+ ## Where We Started
43
+
44
+ A keyword count chose every turn's persona, and when no keyword scored, the session's
45
+ persona kept answering. Measured on the maintainer's own data over five days (550
46
+ deliveries), every turn had a persona, the persona changed on one turn in three, and
47
+ specialists from unrelated domains answered: health, customer support and QA personas on a
48
+ book-writing Journey. The maintainer's criterion: a wrong persona is worse than none,
49
+ because it puts a false specialist into the answer, while no persona only loses one
50
+ domain's lens and keeps identity, voice and memories.
51
+
52
+ ## What Changed
53
+
54
+ **One router for both paths.** `ts/src/mirror/personaRouter.ts` owns the Decisions route,
55
+ which OpenRouter marks `alpha`: its request and answer shapes live in that module and a
56
+ contract test pins them, so a change in the route fails a test, not a turn. The doorman's
57
+ delta and the full load call the same rule, so the two cannot disagree. The delta asks the
58
+ router and embeds the prompt at the same time, so the call adds no wait of its own to the
59
+ delivery. Each persona is offered with its own `description`, else its generated
60
+ descriptor, else the start of its content.
61
+
62
+ **The session records the turn's answer.** A session's persona is now the last turn's,
63
+ none included. Which persona sections the conversation already holds is kept apart
64
+ (`doorman_personas` in the session's metadata), so a section is delivered the first time
65
+ its persona answers and not again; a full load starts that list over, and a compaction
66
+ clears it.
67
+
68
+ **Reception still runs** on a full load, for the Journey and the turn's shaping. It no
69
+ longer chooses the persona. Its prompt is unchanged.
70
+
71
+ **The ledger.** Each routing call writes one `llm_calls` row, `role = persona_routing`,
72
+ with the model, latency and cost, and an empty prompt and response in every logging mode:
73
+ the subject of your turn is not the ledger's to keep.
74
+
75
+ **Measured before the release.** The router was chosen by an eval of 100 labelled turns
76
+ from the maintainer's production data
77
+ ([report](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr203-persona-routing-eval-2026-11-07.md)).
78
+ The same 100, rerun through this release's code on a copy of a home with 28 personas: 93
79
+ right, 3 wrong, 4 missed (the keyword router: 35 wrong), at 0.61 s median and 1.32 s at
80
+ p95, about US$ 0.00008 per turn. The private turns stay out of this repository; a
81
+ synthetic set runs in CI.
82
+
83
+ **Model-behavior gate.** This release changes no extraction model, embedding model or
84
+ `prompts.ts` prompt, so the gate's letter does not apply; it does add a model call and a
85
+ line the model reads, and the rerun above is its evidence in place of `eval --all`, which
86
+ does not probe routing.
87
+
88
+ ## Conscious Exclusions
89
+
90
+ - **No command sets a Journey's `default_persona` yet.** The Core reads it; setting it is a
91
+ follow-up. Two of the rerun's three wrong picks are a Journey's own writing persona on
92
+ turns that asked for none, the case a default resolves.
93
+ - **The no-persona clause is a first wording**, one constant (`NO_PERSONA_CLAUSE`), to be
94
+ settled in prompt review.
95
+ - **`detect-persona` stays** as a command, an MCP tool and a package export. It no longer
96
+ decides a turn.
97
+ - **Reception's prompt still lists the personas**: its bytes are digest-pinned, and
98
+ changing it is its own decision.
99
+ - **A full load's worst case grows**: reception, then routing, each bounded at 10 s. A
100
+ delta's is unchanged.
101
+ - **Two personas covering one domain** remain content for their owner to merge.
102
+
103
+ ## Upgrading
104
+
105
+ ```bash
106
+ mirror runtime update
107
+ ```
108
+
109
+ Nothing to configure: the router uses the OpenRouter key Mirror already reads. The routing
110
+ calls are the `persona_routing` rows of `mirror inspect llm-calls`. Turns without a persona
111
+ are expected: they answer as the mirror, unsigned.
112
+
113
+ ## Next Horizon
114
+
115
+ A command and a host call to set a Journey's `default_persona`; prompt review of the
116
+ no-persona clause; Nautilus for Mirror showing the persona's name from `personaName`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Codex, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.40.1",
4
+ "version": "0.41.0",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -4,7 +4,6 @@ import type { Database, WritableDatabase } from "#db/database.ts";
4
4
  import {
5
5
  type DoormanDecision,
6
6
  decideDoorman,
7
- doormanPersonas,
8
7
  forgetDoormanDelivery,
9
8
  type MemoryScore,
10
9
  readDoormanSessionState,
@@ -16,6 +15,12 @@ import {
16
15
  logMirrorResponse,
17
16
  runMirrorLoad,
18
17
  } from "#mirror/orchestration.ts";
18
+ import {
19
+ LivePersonaRouter,
20
+ loadReplayPersonaRouter,
21
+ PERSONA_ROUTER_REPLAY_ENV,
22
+ type PersonaRouter,
23
+ } from "#mirror/personaRouter.ts";
19
24
  import { environmentSessionId, resolveNamedRuntimeSessionId } from "#mirror/runtimeSession.ts";
20
25
  import {
21
26
  activateOperatingMode,
@@ -43,6 +48,7 @@ export type MirrorRouteEnvironment = {
43
48
  MEMORY_RECEPTION?: string;
44
49
  MIRROR_TS_MIRROR_LLM_REPLAY?: string;
45
50
  MIRROR_TS_MIRROR_EMBEDDING_REPLAY?: string;
51
+ MIRROR_TS_PERSONA_ROUTER_REPLAY?: string;
46
52
  };
47
53
 
48
54
  export function isMirrorWrite(argv: readonly string[]): boolean {
@@ -109,7 +115,6 @@ export async function runMirrorDoormanRoute(
109
115
  decideDoorman({
110
116
  prompt,
111
117
  state: readDoormanSessionState(db, sessionId),
112
- personas: doormanPersonas(db),
113
118
  }),
114
119
  );
115
120
  if (typeof decided === "number") return decided;
@@ -131,6 +136,7 @@ export async function runMirrorDoormanRoute(
131
136
  onReceptionOutcome: deps.onReceptionOutcome,
132
137
  llmProvider: receptionEnabled ? family.llm : undefined,
133
138
  embeddingProvider: family.embedding,
139
+ personaRouter: personaRouterFor(family.mode, env),
134
140
  onMemoryScores: deps.onMemoryScores,
135
141
  newId,
136
142
  nowIso,
@@ -141,6 +147,21 @@ export async function runMirrorDoormanRoute(
141
147
  });
142
148
  }
143
149
 
150
+ /**
151
+ * CR203: the persona router for the family's transport. Live asks the
152
+ * Decisions route; a replay run reaches no network, so it answers from its
153
+ * recorded fixture when one is named, and otherwise routes nothing: its turns
154
+ * have the Journey's default or none.
155
+ */
156
+ export function personaRouterFor(
157
+ mode: "replay" | "live",
158
+ env: MirrorRouteEnvironment,
159
+ ): PersonaRouter | undefined {
160
+ if (mode === "live") return new LivePersonaRouter({ env: { ...env } });
161
+ const fixture = env[PERSONA_ROUTER_REPLAY_ENV];
162
+ return fixture ? loadReplayPersonaRouter(fixture) : undefined;
163
+ }
164
+
144
165
  export function isModeWrite(argv: readonly string[]): boolean {
145
166
  if (argv[0] !== "mode") return false;
146
167
  const args = modePositionals(argv.slice(1));
@@ -241,6 +262,7 @@ export async function runMirrorWriteRoute(
241
262
  onReceptionOutcome: options.onReceptionOutcome,
242
263
  llmProvider: receptionEnabled ? family?.llm : undefined,
243
264
  embeddingProvider: family?.embedding,
265
+ personaRouter: family ? personaRouterFor(family.mode, env) : undefined,
244
266
  newId,
245
267
  nowIso,
246
268
  });
@@ -1,11 +1,11 @@
1
1
  import type { Database } from "#db/database.ts";
2
2
  import { descriptorsByLayer } from "#descriptor/descriptorRead.ts";
3
3
  import type { OnProviderCallOutcome } from "#observability/callOutcome.ts";
4
- import { detectPersona, type PersonaRoutingRow } from "#persona/detectPersona.ts";
5
4
  import { type EmbeddingProvider, generateEmbeddingSafely } from "#providers/embedding.ts";
6
5
  import type { LlmProvider, LlmResponse } from "#providers/llm.ts";
7
6
  import { cosineSimilarity } from "#search/ranker.ts";
8
7
  import { stripAccents } from "#util/slug.ts";
8
+ import { type PersonaRouter, type PersonaRouting, routeTurnPersona } from "./personaRouter.ts";
9
9
  import { runReception, sliceCodePoints } from "./reception.ts";
10
10
  import { GLOBAL_STICKY_DEFAULTS_SESSION_ID, getRuntimeSession } from "./runtimeSession.ts";
11
11
 
@@ -28,6 +28,10 @@ export interface ResolveMirrorDefaultsInput {
28
28
  onReceptionLlmCall?: (response: LlmResponse, prompt: string) => void;
29
29
  /** Reception's outcome, category only; see `observability/callOutcome.ts`. */
30
30
  onReceptionOutcome?: OnProviderCallOutcome;
31
+ /** CR203: routes the persona when none was named; absent, no call is made. */
32
+ personaRouter?: PersonaRouter;
33
+ /** CR203: the routing call, for its ledger row; content-free. */
34
+ onPersonaRouting?: (routing: PersonaRouting) => void;
31
35
  }
32
36
 
33
37
  interface IdentityRoutingRow {
@@ -48,7 +52,6 @@ export async function resolveMirrorDefaults(
48
52
 
49
53
  const session = input.sessionId ? getRuntimeSession(db, input.sessionId) : null;
50
54
  const global = getRuntimeSession(db, GLOBAL_STICKY_DEFAULTS_SESSION_ID);
51
- const stickyPersona = session?.persona || global?.persona || null;
52
55
  const stickyJourney = session?.journey || global?.journey || null;
53
56
  const query = input.query ?? null;
54
57
 
@@ -73,23 +76,33 @@ export async function resolveMirrorDefaults(
73
76
  input.onReceptionLlmCall,
74
77
  input.onReceptionOutcome,
75
78
  );
76
- if (reception.personas.length > 0 && persona === null) persona = reception.personas[0] ?? null;
79
+ // Reception still names personas, and its prompt still offers them: the
80
+ // prompt is digest-pinned, and changing it is its own decision. Its pick
81
+ // no longer decides the turn (CR203): the router does, below.
77
82
  if (reception.journey && journey === null) journey = reception.journey;
78
83
  touchesIdentity = reception.touchesIdentity;
79
84
  touchesShadow = reception.touchesShadow;
80
85
  }
81
86
 
82
- persona ??= stickyPersona;
83
87
  journey ??= stickyJourney;
84
88
 
85
- if (persona === null && query) {
86
- const matches = detectPersona(query, personaRoutingRows(db));
87
- persona = matches[0]?.key ?? null;
88
- }
89
89
  if (journey === null && query) {
90
90
  detectedJourney = await detectJourney(db, query, input.embeddingProvider);
91
91
  journey = detectedJourney[0]?.[0] ?? null;
92
92
  }
93
+ // CR203: the persona the person named, else the turn's own by its subject,
94
+ // else the Journey's default, else none. Never the session's or the last
95
+ // load's by inheritance: a wrong persona is worse than none.
96
+ if (persona === null) {
97
+ persona = (
98
+ await routeTurnPersona(db, {
99
+ prompt: query ?? "",
100
+ journey,
101
+ router: query ? input.personaRouter : undefined,
102
+ onRouting: input.onPersonaRouting,
103
+ })
104
+ ).persona;
105
+ }
93
106
 
94
107
  return { persona, journey, detectedJourney, touchesIdentity, touchesShadow };
95
108
  }
@@ -177,14 +190,6 @@ function descriptorMap(db: Database, layer: string): Map<string, string> {
177
190
  return new Map(descriptorsByLayer(db, layer).map((row) => [row.key, row.descriptor]));
178
191
  }
179
192
 
180
- /** Every persona's routing table, as `detectPersona` scores it. Shared with the doorman (CV9.DS8.TS2). */
181
- export function personaRoutingRows(db: Database): PersonaRoutingRow[] {
182
- return identityRoutingRows(db, "persona").map((row) => ({
183
- key: row.key,
184
- routing_keywords: routingKeywords(row.metadata),
185
- }));
186
- }
187
-
188
193
  function routingKeywords(metadata: string | null): string[] {
189
194
  if (!metadata) return [];
190
195
  try {
@@ -36,7 +36,7 @@
36
36
  import { basename, dirname } from "node:path";
37
37
  import { isCommandTurn } from "#conversation/commandTurn.ts";
38
38
  import { DatabaseNotCurrentError } from "#conversation/hostCalls.ts";
39
- import { openDatabaseReadOnly } from "#db/database.ts";
39
+ import { type Database, openDatabaseReadOnly } from "#db/database.ts";
40
40
  import { inspectMigrationState } from "#db/migrationReport.ts";
41
41
  import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
42
42
  import type { ProviderConfig } from "#providers/config.ts";
@@ -48,11 +48,11 @@ import {
48
48
  type DoormanDecision,
49
49
  type DoormanSessionState,
50
50
  decideDoorman,
51
- doormanPersonas,
52
51
  forgetDoormanDelivery,
53
52
  readDoormanSessionState,
54
53
  runDoorman,
55
54
  } from "./doorman.ts";
55
+ import { LivePersonaRouter, type PersonaRouter } from "./personaRouter.ts";
56
56
 
57
57
  export type MirrorContextRejection = "invalid_request" | "no_key";
58
58
 
@@ -81,7 +81,7 @@ export interface DeliverMirrorContextOptions {
81
81
  /** Reception's classifier for the full folder; on unless turned off. */
82
82
  receptionEnabled?: boolean;
83
83
  /** Tests only: providers in place of the live ones the key would build. */
84
- providers?: { llm?: LlmProvider; embedding?: EmbeddingProvider };
84
+ providers?: { llm?: LlmProvider; embedding?: EmbeddingProvider; personaRouter?: PersonaRouter };
85
85
  }
86
86
 
87
87
  export interface DeliveredMirrorContext {
@@ -90,6 +90,11 @@ export interface DeliveredMirrorContext {
90
90
  block: string;
91
91
  /** The session's persona and Journey after the delivery. */
92
92
  persona: string | null;
93
+ /**
94
+ * CR204: the persona's own name, from its metadata, for a host to show; `null` when it has
95
+ * none or the turn has no persona. Display text only: `persona` stays the id.
96
+ */
97
+ personaName: string | null;
93
98
  journey: string | null;
94
99
  }
95
100
 
@@ -97,7 +102,6 @@ export interface DeliveredMirrorContext {
97
102
  export function decideHostTurn(input: {
98
103
  prompt: string;
99
104
  state: DoormanSessionState | null;
100
- personas: Parameters<typeof decideDoorman>[0]["personas"];
101
105
  }): DoormanDecision {
102
106
  const decided = decideDoorman(input);
103
107
  // In Mirror Mode the doorman answers nothing only for a command turn, which
@@ -121,8 +125,11 @@ function assertCurrent(dbPath: string): void {
121
125
  /**
122
126
  * The providers' environment for a host call: the key, one attempt per call,
123
127
  * and the embedding bounded like reception (10 s). A full turn makes one
124
- * reception call and, with attachments, one embedding; a delta, one embedding.
125
- * So a turn waits at most about 20 s on its providers.
128
+ * reception call, one persona-routing call (CR203) and, with attachments, one
129
+ * embedding; a delta, one embedding and one routing call made beside it. So a
130
+ * delta waits at most about 10 s on its providers and a full turn about 30 s,
131
+ * only when every call runs to its bound; the routing settings are the
132
+ * defaults, since a host passes no environment.
126
133
  */
127
134
  export function hostProviderEnv(apiKey: string): Record<string, string> {
128
135
  return {
@@ -137,11 +144,12 @@ export function hostProviderEnv(apiKey: string): Record<string, string> {
137
144
  export function hostProviders(
138
145
  apiKey: string,
139
146
  createClient?: (config: ProviderConfig) => OpenRouterClient,
140
- ): { llm: LlmProvider; embedding: EmbeddingProvider } {
147
+ ): { llm: LlmProvider; embedding: EmbeddingProvider; personaRouter: PersonaRouter } {
141
148
  const env = hostProviderEnv(apiKey);
142
149
  return {
143
150
  llm: new LiveLlmProvider({ env, ...(createClient ? { createClient } : {}) }),
144
151
  embedding: new LiveEmbeddingProvider({ env, ...(createClient ? { createClient } : {}) }),
152
+ personaRouter: new LivePersonaRouter({ env, ...(createClient ? { createClient } : {}) }),
145
153
  };
146
154
  }
147
155
 
@@ -172,18 +180,17 @@ export async function deliverMirrorContext(
172
180
  decision = decideHostTurn({
173
181
  prompt,
174
182
  state: readDoormanSessionState(read, sessionId),
175
- personas: doormanPersonas(read),
176
183
  });
177
184
  } finally {
178
185
  read.close();
179
186
  }
180
187
  if (decision.kind === "nothing")
181
- return { kind: "nothing", block: "", persona: null, journey: null };
188
+ return { kind: "nothing", block: "", persona: null, personaName: null, journey: null };
182
189
 
183
190
  const apiKey = typeof options.apiKey === "string" ? options.apiKey.trim() : "";
184
191
  if (!apiKey && !options.providers) throw new MirrorContextRejectedError("no_key");
185
192
  const receptionEnabled = options.receptionEnabled !== false;
186
- const { llm, embedding } = options.providers ?? hostProviders(apiKey);
193
+ const { llm, embedding, personaRouter } = options.providers ?? hostProviders(apiKey);
187
194
 
188
195
  const db = openLiveWriteDatabase(dbPath);
189
196
  try {
@@ -198,15 +205,18 @@ export async function deliverMirrorContext(
198
205
  receptionEnabled,
199
206
  llmProvider: receptionEnabled ? llm : undefined,
200
207
  embeddingProvider: embedding,
208
+ personaRouter,
201
209
  extensionRuntime: { environment: {} },
202
210
  newId,
203
211
  nowIso,
204
212
  });
205
213
  const state = readDoormanSessionState(db, sessionId);
214
+ const persona = state?.persona ?? null;
206
215
  return {
207
216
  kind: decision.kind,
208
217
  block: rendered.stdout,
209
- persona: state?.persona ?? null,
218
+ persona,
219
+ personaName: persona ? personaName(db, persona) : null,
210
220
  journey: state?.journey ?? null,
211
221
  };
212
222
  } finally {
@@ -214,6 +224,20 @@ export async function deliverMirrorContext(
214
224
  }
215
225
  }
216
226
 
227
+ /** CR204: the `name` the seed kept in a persona's metadata, when it kept one. */
228
+ function personaName(db: Database, key: string): string | null {
229
+ const row = db
230
+ .prepare("SELECT metadata FROM identity WHERE layer = 'persona' AND key = ?")
231
+ .get(key);
232
+ if (typeof row?.metadata !== "string") return null;
233
+ try {
234
+ const name: unknown = (JSON.parse(row.metadata) as Record<string, unknown> | null)?.name;
235
+ return typeof name === "string" && name.trim() ? name.trim() : null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
217
241
  /**
218
242
  * The host compacted the Conversation's transcript: the folder left it, so the
219
243
  * next turn delivers it whole again. An unknown session is nothing to mark.
@@ -13,11 +13,13 @@
13
13
  //
14
14
  // 1. the explicit Mirror form in the prompt -> the full folder, routed from
15
15
  // the query, before the first token; the session marked active by its id;
16
- // 2. Mirror Mode active, an ordinary turn -> the prompt is routed with
17
- // `detectPersona` (a keyword score, no model call); a persona that newly
18
- // scores has its section delivered and becomes the session's; the
19
- // memories and attachments relevant to the prompt follow; the identity
20
- // layers are not repeated -- they already sit in the transcript;
16
+ // 2. Mirror Mode active, an ordinary turn -> the turn's persona is routed
17
+ // by its subject (CR203, `personaRouter.ts`): the router's answer, else
18
+ // the Journey's own default, else none -- never the session's by
19
+ // inheritance; a persona the conversation has not read yet has its
20
+ // section delivered; the memories and attachments relevant to the prompt
21
+ // follow; the identity layers are not repeated -- they already sit in
22
+ // the transcript;
21
23
  // a skill invocation other than the Mirror form is not a question and
22
24
  // gets nothing (CR160's command-turn rule);
23
25
  // 3. Mirror Mode not active -> nothing. The model's own routing may invoke
@@ -37,7 +39,8 @@
37
39
  //
38
40
  // What a turn costs: nothing when Mirror Mode is off; on an ordinary active
39
41
  // turn, ONE embedding, shared by the memory and attachment searches and
40
- // counted once in `llm_calls` (DS8 decision 1); on the explicit turn, what
42
+ // counted once in `llm_calls` (DS8 decision 1), and ONE persona-routing call,
43
+ // made beside the embedding, not after it (CR203); on the explicit turn, what
41
44
  // `mirror load --query` costs today (reception's LLM call when enabled, the
42
45
  // attachment embedding), plus the skill's own load until TS3 teaches it to
43
46
  // trust this line.
@@ -48,14 +51,13 @@ import { memoriesById } from "#frontDoor/searchRoute.ts";
48
51
  import { getIdentityContent } from "#identity/identityRead.ts";
49
52
  import { getActiveOperatingMode } from "#mode/operatingMode.ts";
50
53
  import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
51
- import { detectPersona, type PersonaRoutingRow } from "#persona/detectPersona.ts";
52
54
  import { type EmbeddingProvider, generateEmbeddingSafely } from "#providers/embedding.ts";
53
55
  import { searchMemoriesWithStatus } from "#search/memorySearch.ts";
54
56
  import { pythonJsonDumps } from "#util/pyGenerators.ts";
55
57
  import { relevantAttachments } from "./context.ts";
56
- import { personaRoutingRows } from "./defaultResolution.ts";
57
58
  import { mirrorForm } from "./mirrorForm.ts";
58
59
  import { type MirrorLoadInput, runMirrorLoad } from "./orchestration.ts";
60
+ import { personaRoutingLedger, routeTurnPersona } from "./personaRouter.ts";
59
61
  import {
60
62
  decodeMetadata,
61
63
  getRuntimeSession,
@@ -80,8 +82,6 @@ export type DoormanDecision =
80
82
  | {
81
83
  readonly kind: "delta";
82
84
  readonly query: string;
83
- /** The persona that newly scored, or null when the session's stands. */
84
- readonly persona: string | null;
85
85
  };
86
86
 
87
87
  /** The stable prefix TS3's skill reads; everything after the colon varies. */
@@ -116,6 +116,15 @@ export const DOORMAN_MEMORY_SEMANTIC_THRESHOLD = 0.4;
116
116
  */
117
117
  export const DOORMAN_DELIVERED_METADATA_KEY = "doorman_delivered";
118
118
 
119
+ /**
120
+ * The personas whose section the conversation already holds (CR203). A turn
121
+ * may now have none, so "the session's persona" no longer says which sections
122
+ * the transcript carries: a writer turn, a turn with none, and a writer turn
123
+ * again must deliver the writer's section once. A full load starts the list
124
+ * over with its own persona.
125
+ */
126
+ export const DOORMAN_PERSONAS_METADATA_KEY = "doorman_personas";
127
+
119
128
  /**
120
129
  * Set on the session when the runtime compacted the transcript (the Pi
121
130
  * extension's `session_compact`, CV9.DS8.US5): a delta's premise -- the
@@ -139,38 +148,56 @@ export function forgetDoormanDelivery(
139
148
  const metadata = decodeMetadata(session.metadata);
140
149
  metadata[DOORMAN_REFRESH_METADATA_KEY] = true;
141
150
  metadata[DOORMAN_DELIVERED_METADATA_KEY] = [];
151
+ metadata[DOORMAN_PERSONAS_METADATA_KEY] = [];
142
152
  updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
143
153
  }
144
154
 
145
155
  /** How many ranked memories a delta may read through to fill its five. */
146
156
  const DOORMAN_SEARCH_CEILING = 25;
147
157
 
148
- function deliveredMemoryIds(db: Database, sessionId: string | null): Set<string> {
158
+ function deliveredList(db: Database, sessionId: string | null, key: string): Set<string> {
149
159
  if (!sessionId) return new Set();
150
- const raw = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null)[
151
- DOORMAN_DELIVERED_METADATA_KEY
152
- ];
160
+ const raw = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null)[key];
153
161
  return new Set(
154
162
  Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string") : [],
155
163
  );
156
164
  }
157
165
 
166
+ function deliveredMemoryIds(db: Database, sessionId: string | null): Set<string> {
167
+ return deliveredList(db, sessionId, DOORMAN_DELIVERED_METADATA_KEY);
168
+ }
169
+
158
170
  function rememberDelivered(
159
171
  db: WritableDatabase,
160
172
  sessionId: string | null,
161
173
  ids: readonly string[],
162
174
  nowIso: string,
163
- options: { clearRefresh?: boolean } = {},
175
+ options: { clearRefresh?: boolean; personas?: readonly string[] } = {},
164
176
  ): void {
165
177
  if (!sessionId) return;
166
178
  const session = getRuntimeSession(db, sessionId);
167
179
  if (!session) return;
168
180
  const metadata = decodeMetadata(session.metadata);
169
181
  metadata[DOORMAN_DELIVERED_METADATA_KEY] = [...ids];
182
+ if (options.personas) metadata[DOORMAN_PERSONAS_METADATA_KEY] = [...options.personas];
170
183
  if (options.clearRefresh) delete metadata[DOORMAN_REFRESH_METADATA_KEY];
171
184
  updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
172
185
  }
173
186
 
187
+ function rememberPersonas(
188
+ db: WritableDatabase,
189
+ sessionId: string | null,
190
+ personas: readonly string[],
191
+ nowIso: string,
192
+ ): void {
193
+ if (!sessionId) return;
194
+ const session = getRuntimeSession(db, sessionId);
195
+ if (!session) return;
196
+ const metadata = decodeMetadata(session.metadata);
197
+ metadata[DOORMAN_PERSONAS_METADATA_KEY] = [...personas];
198
+ updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
199
+ }
200
+
174
201
  /** One ranked memory's scores, content-free, for the front-door log. */
175
202
  export interface MemoryScore {
176
203
  readonly score: number;
@@ -206,7 +233,6 @@ export function readDoormanSessionState(
206
233
  export function decideDoorman(input: {
207
234
  prompt: string;
208
235
  state: DoormanSessionState | null;
209
- personas: readonly PersonaRoutingRow[];
210
236
  }): DoormanDecision {
211
237
  const explicit = mirrorForm(input.prompt);
212
238
  if (explicit) return { kind: "full", query: explicit.query };
@@ -220,19 +246,29 @@ export function decideDoorman(input: {
220
246
  // The runtime compacted the transcript: the folder is a summary now, so this
221
247
  // turn delivers it whole, routed from the prompt, and the deltas resume after.
222
248
  if (input.state.refresh) return { kind: "full", query: input.prompt };
223
- const match = detectPersona(input.prompt, input.personas)[0]?.key ?? null;
224
- const persona = match !== null && match !== input.state.persona ? match : null;
225
- return { kind: "delta", query: input.prompt, persona };
249
+ // The persona is not decided here: routing it is a model call (CR203), and
250
+ // this function stays pure. `runDoorman` routes it, beside the embedding.
251
+ return { kind: "delta", query: input.prompt };
226
252
  }
227
253
 
254
+ /**
255
+ * What the line says when a turn has no persona (CR203). Draft wording, for the
256
+ * prompt engineer to settle in review: no signature, and no earlier persona
257
+ * carried over.
258
+ */
259
+ export const NO_PERSONA_CLAUSE =
260
+ "no persona this turn: do not sign, and do not answer as an earlier persona";
261
+
228
262
  export function deliveredLine(input: {
229
263
  kind: "full" | "delta";
230
264
  persona: string | null;
231
265
  journey: string | null;
232
266
  }): string {
267
+ // CR203: a turn may have no persona, and the model must hear that as this
268
+ // turn's answer, not as silence it may fill with the last persona it signed.
233
269
  const clause = input.persona
234
270
  ? `answer as the mirror; sign ◇ ${input.persona}`
235
- : "answer as the mirror";
271
+ : `answer as the mirror; ${NO_PERSONA_CLAUSE}`;
236
272
  const what =
237
273
  input.kind === "full"
238
274
  ? "full"
@@ -289,7 +325,10 @@ async function runFull(
289
325
  // The conversation's context starts over with the folder: so does the
290
326
  // list of memories the deltas have put in it, and a compaction's refresh
291
327
  // is answered.
292
- rememberDelivered(db, input.sessionId, [], input.nowIso(), { clearRefresh: true });
328
+ rememberDelivered(db, input.sessionId, [], input.nowIso(), {
329
+ clearRefresh: true,
330
+ personas: loaded.resolved.persona ? [loaded.resolved.persona] : [],
331
+ });
293
332
  }
294
333
  const line = deliveredLine({
295
334
  kind: "full",
@@ -305,28 +344,37 @@ async function runDelta(
305
344
  input: RunDoormanInput,
306
345
  ): Promise<RenderedDoorman> {
307
346
  const state = readDoormanSessionState(db, input.sessionId);
308
- const persona = decision.persona ?? state?.persona ?? null;
309
347
  const journey = state?.journey ?? null;
348
+ // The turn's persona and its embedding, side by side: neither waits on the
349
+ // other. One embedding for the turn, in the ledger once; both searches share
350
+ // it. Without a provider, or when it fails, the memory search degrades to FTS
351
+ // and the attachments are skipped: the delta still carries the line and the
352
+ // persona.
353
+ const [turn, queryEmbedding] = await Promise.all([
354
+ routeTurnPersona(db, {
355
+ prompt: decision.query,
356
+ journey,
357
+ router: input.personaRouter,
358
+ onRouting: personaRoutingLedger(db, { sessionId: input.sessionId, now: input.nowIso }),
359
+ }),
360
+ embedOnce(db, decision.query, input),
361
+ ]);
362
+ const persona = turn.persona;
310
363
  const parts: string[] = [deliveredLine({ kind: "delta", persona, journey })];
311
364
 
312
- if (decision.persona) {
313
- const content = getIdentityContent(db, "persona", decision.persona);
314
- if (content) parts.push(`=== persona/${decision.persona} ===\n${content}`);
315
- if (input.sessionId) {
316
- updateRuntimeSessionColumns(
317
- db,
318
- input.sessionId,
319
- { persona: decision.persona },
320
- input.nowIso(),
321
- );
365
+ const deliveredPersonas = deliveredList(db, input.sessionId, DOORMAN_PERSONAS_METADATA_KEY);
366
+ if (persona && !deliveredPersonas.has(persona)) {
367
+ const content = getIdentityContent(db, "persona", persona);
368
+ if (content) {
369
+ parts.push(`=== persona/${persona} ===\n${content}`);
370
+ rememberPersonas(db, input.sessionId, [...deliveredPersonas, persona], input.nowIso());
322
371
  }
323
372
  }
324
-
325
- // One embedding for the turn, in the ledger once; both searches share it.
326
- // Without a provider, or when it fails, the memory search degrades to FTS
327
- // and the attachments are skipped: the delta still carries the line and the
328
- // persona.
329
- const queryEmbedding = await embedOnce(db, decision.query, input);
373
+ // The session records the turn's answer, none included: a session's persona
374
+ // is the last turn's, not a default the next turn inherits.
375
+ if (input.sessionId && persona !== (state?.persona ?? null)) {
376
+ updateRuntimeSessionColumns(db, input.sessionId, { persona }, input.nowIso());
377
+ }
330
378
 
331
379
  const delivered = deliveredMemoryIds(db, input.sessionId);
332
380
  const memories = await searchMemoriesWithStatus(db, {
@@ -413,8 +461,3 @@ async function embedOnce(
413
461
  return null;
414
462
  }
415
463
  }
416
-
417
- /** The persona routing table the decision scores against; read once per turn. */
418
- export function doormanPersonas(db: Database): PersonaRoutingRow[] {
419
- return personaRoutingRows(db);
420
- }
@@ -12,6 +12,7 @@ import type { EmbeddingProvider } from "#providers/embedding.ts";
12
12
  import type { LlmProvider } from "#providers/llm.ts";
13
13
  import { loadMirrorContext } from "./context.ts";
14
14
  import { type ResolvedMirrorDefaults, resolveMirrorDefaults } from "./defaultResolution.ts";
15
+ import { type PersonaRouter, personaRoutingLedger } from "./personaRouter.ts";
15
16
  import { pythonTruthy, sliceCodePoints } from "./reception.ts";
16
17
  import {
17
18
  renderJourneyDetected,
@@ -47,6 +48,8 @@ export interface MirrorLoadInput {
47
48
  onReceptionOutcome?: OnProviderCallOutcome;
48
49
  llmProvider?: LlmProvider;
49
50
  embeddingProvider?: EmbeddingProvider;
51
+ /** CR203: routes a turn's persona by its subject; absent, the turn has the Journey's default or none. */
52
+ personaRouter?: PersonaRouter;
50
53
  databasePath?: string;
51
54
  mirrorHome?: string;
52
55
  user?: string;
@@ -72,6 +75,10 @@ export async function runMirrorLoad(
72
75
  const resolved = await resolveMirrorDefaults(db, {
73
76
  ...input,
74
77
  onReceptionOutcome: input.onReceptionOutcome,
78
+ onPersonaRouting: personaRoutingLedger(db, {
79
+ sessionId: input.sessionId ?? null,
80
+ now: input.nowIso,
81
+ }),
75
82
  // Priced, as Python's `build_llm_logger(role="reception")` prices it
76
83
  // (CV22.DS8.US3). The row existed before but carried a null cost, so
77
84
  // every Mirror Mode activation with a query would have logged live spend
@@ -289,7 +296,9 @@ function writeMirrorState(
289
296
  input.sessionId,
290
297
  {
291
298
  mirrorActive: input.active,
292
- ...(input.persona !== undefined && input.persona !== null ? { persona: input.persona } : {}),
299
+ // A load's persona is recorded even when it is none (CR203): the session
300
+ // must not keep an earlier turn's persona as if this one had chosen it.
301
+ ...(input.persona !== undefined ? { persona: input.persona } : {}),
293
302
  ...(input.journey !== undefined && input.journey !== null ? { journey: input.journey } : {}),
294
303
  },
295
304
  nowIso,
@@ -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