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.
@@ -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
  });
@@ -111,6 +111,10 @@ export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
111
111
  * reason, in the Pi extension's words, and the search goes on without it. The
112
112
  * value is read once, into `mmnode`, and printed with `printf` and its line
113
113
  * breaks removed: dash's `echo` turns the `\n` of `\nodejs` into a newline.
114
+ * `MIRROR_BIN` follows the same rule (CR197): only an absolute path to a
115
+ * directory holding an executable `mirror`, read once, into `mmbindir`, and
116
+ * refused in the same words. A relative one would resolve against the project
117
+ * the client started the server from, and Node would run that project's file.
114
118
  *
115
119
  * Written for what three runtimes do to the string (the US6 spike):
116
120
  *
@@ -119,14 +123,17 @@ export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
119
123
  * runtime that did so with an unset name would blank it;
120
124
  * * no positional parameter (`$1`, `$@`, ...) and no backtick, for the same
121
125
  * reason, and because the text travels through two JSON encoders;
122
- * * variables named `mmbin`, `mmnode`, `mmdir`: Grok substitutes SET
126
+ * * variables named `mmbin`, `mmbindir`, `mmnode`, `mmdir`: Grok substitutes SET
123
127
  * environment variables by name inside MCP args at load time (`$HOME`
124
128
  * became the path), so a script variable with a name a person exports
125
129
  * would be replaced before the shell ever saw it.
126
130
  */
127
131
  export const MCP_LOCATOR = [
128
132
  'mmbin=""',
129
- '[ -n "$MIRROR_BIN" ] && [ -x "$MIRROR_BIN/mirror" ] && mmbin="$MIRROR_BIN/mirror"',
133
+ 'mmbindir="$MIRROR_BIN"',
134
+ 'mmwhy=""',
135
+ `[ -n "$mmbindir" ] && case "$mmbindir" in ${ABSOLUTE_PATH_CASE}) if [ ! -e "$mmbindir" ]; then mmwhy="missing"; elif [ ! -d "$mmbindir" ]; then mmwhy="not a directory"; elif [ ! -f "$mmbindir/mirror" ] || [ ! -x "$mmbindir/mirror" ]; then mmwhy="a directory without an executable mirror"; else mmbin="$mmbindir/mirror"; fi ;; *) mmwhy="relative" ;; esac`,
136
+ `[ -n "$mmwhy" ] && printf 'mirror-mind: MIRROR_BIN=%s is %s; searching the PATH and the usual places instead\\n' "$(printf '%s' "$mmbindir" | tr -d '\\r\\n')" "$mmwhy" >&2`,
130
137
  '[ -n "$mmbin" ] || mmbin="$(command -v mirror 2>/dev/null)"',
131
138
  `for mmdir in ${GLOBAL_BIN_DIRS.join(" ")}; do [ -n "$mmbin" ] && break; [ -x "$mmdir/mirror" ] && mmbin="$mmdir/mirror"; done`,
132
139
  '[ -n "$mmbin" ] || { echo "mirror-mind: mirror not found on PATH; the MCP server cannot start. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory." >&2; exit 1; }',
@@ -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,