mirror-mind 0.36.3 → 0.37.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.
Files changed (42) hide show
  1. package/.pi/extensions/mirror-logger.ts +173 -46
  2. package/.pi/skills/mm-mirror/SKILL.md +25 -6
  3. package/README.md +2 -2
  4. package/docs/releases/index.md +1 -0
  5. package/docs/releases/v0.37.0.md +221 -0
  6. package/package.json +1 -1
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  8. package/plugins/mirror-mind/plugin.json +1 -1
  9. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  10. package/ts/src/builder/commands.ts +9 -6
  11. package/ts/src/builder/load.ts +14 -2
  12. package/ts/src/conversation/logger.ts +12 -6
  13. package/ts/src/db/migrateOnOpen.ts +1 -1
  14. package/ts/src/db/migrations.ts +16 -0
  15. package/ts/src/db/schema.ts +0 -1
  16. package/ts/src/db/schemaInventorySnapshot.ts +1 -8
  17. package/ts/src/db/schemaState.ts +2 -0
  18. package/ts/src/db/schemaTsDivergence.ts +25 -4
  19. package/ts/src/frontDoor/cli.ts +42 -2
  20. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  21. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  22. package/ts/src/frontDoor/routing.ts +16 -15
  23. package/ts/src/frontDoor/soulRoute.ts +4 -4
  24. package/ts/src/guards/testTempHomes.ts +92 -0
  25. package/ts/src/hooks/agy.ts +136 -59
  26. package/ts/src/hooks/claude.ts +29 -90
  27. package/ts/src/hooks/mirrorState.ts +10 -22
  28. package/ts/src/mirror/context.ts +19 -4
  29. package/ts/src/mirror/defaultResolution.ts +2 -1
  30. package/ts/src/mirror/doorman.ts +420 -0
  31. package/ts/src/mirror/orchestration.ts +22 -3
  32. package/ts/src/mirror/render.ts +7 -1
  33. package/ts/src/mirror/runtimeSession.ts +66 -8
  34. package/ts/src/mode/operatingMode.ts +22 -4
  35. package/ts/src/providers/transport.ts +14 -0
  36. package/ts/src/runtime/diagnose.ts +32 -7
  37. package/ts/src/runtime/pluginWiring.ts +6 -4
  38. package/ts/src/runtime/status.ts +3 -2
  39. package/ts/src/runtime/update.ts +17 -1
  40. package/ts/src/runtime/wiring.ts +14 -1
  41. package/ts/src/search/memorySearch.ts +38 -18
  42. package/ts/src/search/ranker.ts +16 -2
@@ -1,4 +1,8 @@
1
- // Mirror Mode state for one runtime session, as the inject hook needs it.
1
+ // Mirror Mode state for one runtime session, as Antigravity's PreInvocation
2
+ // hook needs it on agy before 1.4.7, whose payload carries no prompt. The
3
+ // Claude-family inject hook stopped reading it in CV9.DS8.US4, and agy's hook
4
+ // stopped on 1.4.7 in CV9.DS8.TS6: both hand the turn to `mirror doorman`,
5
+ // which reads for itself.
2
6
  //
3
7
  // The TypeScript replacement for the Python hook module `mirror_state` (144
4
8
  // lines, deleted with the core at CV22.DS10.TS5), which
@@ -11,24 +15,21 @@
11
15
  // adding two commands whose sole consumer is a hook would widen the command
12
16
  // surface this story exists to shrink.
13
17
  //
14
- // One Python behavior is deliberately kept, because it is a real safeguard:
15
- // a missing session id FAILS LOUD on stderr rather than silently answering
16
- // "nothing to do". A hook integration that forgets to pass one would otherwise
17
- // look like a user who simply has Mirror Mode off.
18
+ // `needs_inject` and `mark_injected` -- the once-injection the inject hook
19
+ // used to owe a Mirror Mode session -- left with US4, and the `hook_injected`
20
+ // column they read left with US5 (migration 018).
18
21
 
19
- import { openDatabaseReadOnly, type WritableDatabase } from "#db/database.ts";
20
- import { getRuntimeSession, upsertRuntimeSession } from "#mirror/runtimeSession.ts";
22
+ import { openDatabaseReadOnly } from "#db/database.ts";
23
+ import { getRuntimeSession } from "#mirror/runtimeSession.ts";
21
24
 
22
25
  export interface MirrorHookState {
23
26
  readonly active: boolean;
24
- readonly hookInjected: boolean;
25
27
  readonly persona: string;
26
28
  readonly journey: string;
27
29
  }
28
30
 
29
31
  const NO_STATE: MirrorHookState = {
30
32
  active: false,
31
- hookInjected: false,
32
33
  persona: "",
33
34
  journey: "",
34
35
  };
@@ -47,7 +48,6 @@ export function readMirrorState(dbPath: string, sessionId: string): MirrorHookSt
47
48
  if (!session) return NO_STATE;
48
49
  return {
49
50
  active: session.mirrorActive,
50
- hookInjected: session.hookInjected,
51
51
  persona: session.persona ?? "",
52
52
  journey: session.journey ?? "",
53
53
  };
@@ -57,15 +57,3 @@ export function readMirrorState(dbPath: string, sessionId: string): MirrorHookSt
57
57
  db.close();
58
58
  }
59
59
  }
60
-
61
- /** Python `needs_inject`: Mirror Mode is on and context has not been injected. */
62
- export function needsInject(state: MirrorHookState): boolean {
63
- return state.active && !state.hookInjected;
64
- }
65
-
66
- /** Python `mark_injected`, over an already-open writable connection. */
67
- export function markInjected(db: WritableDatabase, sessionId: string, nowIso: string): void {
68
- if (!sessionId) return;
69
- if (!getRuntimeSession(db, sessionId)) return;
70
- upsertRuntimeSession(db, sessionId, { hookInjected: true }, nowIso);
71
- }
@@ -116,15 +116,19 @@ export async function relevantAttachments(
116
116
  journey: string | null,
117
117
  provider?: EmbeddingProvider,
118
118
  embeddingLedger?: (info: EmbeddingAttemptInfo) => void,
119
+ /**
120
+ * The query's embedding, already generated (CV9.DS8.TS2): the doorman embeds
121
+ * a turn once and shares the vector with the memory search. When given, the
122
+ * provider is not called and no ledger row is written here.
123
+ */
124
+ precomputedEmbedding?: readonly number[],
119
125
  ): Promise<{ row: AttachmentRow; score: number }[]> {
120
126
  const rows = attachmentRows(db, journey);
121
127
  // No attachments, no embedding: the early return is why a query against a database
122
128
  // without attachments records no spend -- there is none.
123
129
  if (rows.length === 0) return [];
124
- if (!provider) throw new Error("attachment embedding replay provider is required");
125
- const queryEmbedding = await generateEmbeddingSafely(provider, query, {
126
- ...(embeddingLedger ? { onAttempt: embeddingLedger } : {}),
127
- });
130
+ const queryEmbedding =
131
+ precomputedEmbedding ?? (await embedQuery(query, provider, embeddingLedger));
128
132
  const queryTokens = (query.match(/[\p{L}\p{N}_]+/gu) ?? [])
129
133
  .filter((token) => [...token].length >= 2 || /^\p{N}+$/u.test(token))
130
134
  .map((token) => stripAccents(token.toLowerCase()));
@@ -146,6 +150,17 @@ export async function relevantAttachments(
146
150
  return scored.slice(0, journey ? 5 : 8).filter((item) => item.score > 0.4);
147
151
  }
148
152
 
153
+ async function embedQuery(
154
+ query: string,
155
+ provider: EmbeddingProvider | undefined,
156
+ embeddingLedger: ((info: EmbeddingAttemptInfo) => void) | undefined,
157
+ ): Promise<readonly number[]> {
158
+ if (!provider) throw new Error("attachment embedding replay provider is required");
159
+ return generateEmbeddingSafely(provider, query, {
160
+ ...(embeddingLedger ? { onAttempt: embeddingLedger } : {}),
161
+ });
162
+ }
163
+
149
164
  function attachmentRows(db: Database, journey: string | null): AttachmentRow[] {
150
165
  const sql = journey
151
166
  ? "SELECT journey_id, name, description, content, embedding FROM attachments WHERE journey_id = ? AND embedding IS NOT NULL ORDER BY created_at"
@@ -177,7 +177,8 @@ function descriptorMap(db: Database, layer: string): Map<string, string> {
177
177
  return new Map(descriptorsByLayer(db, layer).map((row) => [row.key, row.descriptor]));
178
178
  }
179
179
 
180
- function personaRoutingRows(db: Database): PersonaRoutingRow[] {
180
+ /** Every persona's routing table, as `detectPersona` scores it. Shared with the doorman (CV9.DS8.TS2). */
181
+ export function personaRoutingRows(db: Database): PersonaRoutingRow[] {
181
182
  return identityRoutingRows(db, "persona").map((row) => ({
182
183
  key: row.key,
183
184
  routing_keywords: routingKeywords(row.metadata),
@@ -0,0 +1,420 @@
1
+ // The one doorman (CV9.DS8.TS2).
2
+ //
3
+ // Mirror Mode is the product: the folder `mirror mirror load` builds from the
4
+ // database, open before the model answers. What is at stake is always whether
5
+ // the folder reaches the model, and when. Until DS8 there were three doormen
6
+ // with three policies (the Claude-family inject hook, Antigravity's
7
+ // PreInvocation, and none on Pi), and wherever one existed the skill's own
8
+ // hard constraint made an explicit turn load twice (CR165, measured on Pi
9
+ // 1.4.0, 2026-10-24).
10
+ //
11
+ // This module is the one decision. Given a turn's prompt and the session's
12
+ // state it answers NOTHING, THE FULL FOLDER, or A DELTA:
13
+ //
14
+ // 1. the explicit Mirror form in the prompt -> the full folder, routed from
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;
21
+ // a skill invocation other than the Mirror form is not a question and
22
+ // gets nothing (CR160's command-turn rule);
23
+ // 3. Mirror Mode not active -> nothing. The model's own routing may invoke
24
+ // the skill; the session learns that load from the harness (TS1), and the
25
+ // doorman takes over from the next turn.
26
+ //
27
+ // `decideDoorman` is pure: no database, no clock, no provider. `runDoorman`
28
+ // renders and writes, at the edge. The hooks (US4, TS6) call both in-process;
29
+ // the Pi extension (US5) reaches them through `mirror doorman`. Neither is
30
+ // rewired here.
31
+ //
32
+ // Every block the doorman delivers opens with one line the model reads
33
+ // (`deliveredLine`), on every active turn, even with nothing under it: it is
34
+ // what carries the persona across the conversation. On an ordinary turn the
35
+ // skill is never invoked, so this line is the only Mirror text that reaches
36
+ // the model -- the signature rule (`AGENTS.md`: `◇ persona-name`) rides in it.
37
+ //
38
+ // What a turn costs: nothing when Mirror Mode is off; on an ordinary active
39
+ // 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
41
+ // `mirror load --query` costs today (reception's LLM call when enabled, the
42
+ // attachment embedding), plus the skill's own load until TS3 teaches it to
43
+ // trust this line.
44
+
45
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
46
+ import type { Database, WritableDatabase } from "#db/database.ts";
47
+ import { memoriesById } from "#frontDoor/searchRoute.ts";
48
+ import { getIdentityContent } from "#identity/identityRead.ts";
49
+ import { getActiveOperatingMode } from "#mode/operatingMode.ts";
50
+ import { embeddingLedgerHook } from "#observability/ledgerHooks.ts";
51
+ import { detectPersona, type PersonaRoutingRow } from "#persona/detectPersona.ts";
52
+ import { type EmbeddingProvider, generateEmbeddingSafely } from "#providers/embedding.ts";
53
+ import { searchMemoriesWithStatus } from "#search/memorySearch.ts";
54
+ import { pythonJsonDumps } from "#util/pyGenerators.ts";
55
+ import { relevantAttachments } from "./context.ts";
56
+ import { personaRoutingRows } from "./defaultResolution.ts";
57
+ import { mirrorForm } from "./mirrorForm.ts";
58
+ import { type MirrorLoadInput, runMirrorLoad } from "./orchestration.ts";
59
+ import {
60
+ decodeMetadata,
61
+ getRuntimeSession,
62
+ updateRuntimeSessionColumns,
63
+ } from "./runtimeSession.ts";
64
+
65
+ /** The session's Mirror fields as the decision reads them; null when unnamed or unknown. */
66
+ export interface DoormanSessionState {
67
+ readonly active: boolean;
68
+ readonly persona: string | null;
69
+ readonly journey: string | null;
70
+ /**
71
+ * The folder left the transcript (the runtime compacted it, and said so
72
+ * through `forgetDoormanDelivery`): the next ordinary turn is full again.
73
+ */
74
+ readonly refresh?: boolean;
75
+ }
76
+
77
+ export type DoormanDecision =
78
+ | { readonly kind: "nothing" }
79
+ | { readonly kind: "full"; readonly query: string }
80
+ | {
81
+ readonly kind: "delta";
82
+ readonly query: string;
83
+ /** The persona that newly scored, or null when the session's stands. */
84
+ readonly persona: string | null;
85
+ };
86
+
87
+ /** The stable prefix TS3's skill reads; everything after the colon varies. */
88
+ export const DELIVERED_LINE_PREFIX = "=== mirror context delivered by the runtime for this turn";
89
+
90
+ /** How many memories a delta carries at most. */
91
+ export const DOORMAN_MEMORY_LIMIT = 5;
92
+
93
+ /**
94
+ * The threshold DS8's policy names ("injected when any score above the
95
+ * threshold"), on the SEMANTIC score the ranker reports -- the cosine of the
96
+ * prompt and the memory -- not the hybrid one (CV9.DS8.US5). The hybrid is
97
+ * half recency, reinforcement, relevance, and lexical: a fresh, reinforced,
98
+ * relevance-1 memory floors at 0.35 with no semantic match and would pass a
99
+ * composite 0.40 at cosine 0.10, which unrelated texts reach routinely; the 27
100
+ * composite rows TS2 and US4 logged (off-topic deltas topping at 0.331-0.377,
101
+ * 3 of 5 memories repeating; on-topic at 0.444-0.448 and 0.587-0.591) show the
102
+ * gap in one home, not a property. 0.4 is the rule `relevantAttachments`
103
+ * already applies to attachments: one rule for both. Inclusive. A degraded
104
+ * search (no embedding) reports no semantic score and keeps TS2's FTS
105
+ * behavior: a lexical hit is relevance by another signal, not a miss.
106
+ */
107
+ export const DOORMAN_MEMORY_SEMANTIC_THRESHOLD = 0.4;
108
+
109
+ /**
110
+ * Where the session remembers the memories the doorman has delivered
111
+ * (`runtime_sessions.metadata`, beside `operating_mode`). Every delta is a
112
+ * message that stays in the transcript, and US4 measured 3 of 5 memories
113
+ * repeating on a follow-up: a delta skips what the conversation already holds
114
+ * and fills from the next ones above the threshold. A full load starts the
115
+ * list over -- the folder is fresh, and so is what the model has read.
116
+ */
117
+ export const DOORMAN_DELIVERED_METADATA_KEY = "doorman_delivered";
118
+
119
+ /**
120
+ * Set on the session when the runtime compacted the transcript (the Pi
121
+ * extension's `session_compact`, CV9.DS8.US5): a delta's premise -- the
122
+ * identity layers already sit in the transcript -- no longer holds, so the
123
+ * next ordinary turn in Mirror Mode delivers the full folder, routed from that
124
+ * prompt, and the full load clears the flag.
125
+ */
126
+ export const DOORMAN_REFRESH_METADATA_KEY = "doorman_refresh";
127
+
128
+ /**
129
+ * The runtime says the folder left the transcript: the next ordinary turn is
130
+ * full, and the delivered list is forgotten with it. Unknown session: no-op.
131
+ */
132
+ export function forgetDoormanDelivery(
133
+ db: WritableDatabase,
134
+ sessionId: string,
135
+ nowIso: string,
136
+ ): void {
137
+ const session = getRuntimeSession(db, sessionId);
138
+ if (!session) return;
139
+ const metadata = decodeMetadata(session.metadata);
140
+ metadata[DOORMAN_REFRESH_METADATA_KEY] = true;
141
+ metadata[DOORMAN_DELIVERED_METADATA_KEY] = [];
142
+ updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
143
+ }
144
+
145
+ /** How many ranked memories a delta may read through to fill its five. */
146
+ const DOORMAN_SEARCH_CEILING = 25;
147
+
148
+ function deliveredMemoryIds(db: Database, sessionId: string | null): Set<string> {
149
+ if (!sessionId) return new Set();
150
+ const raw = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null)[
151
+ DOORMAN_DELIVERED_METADATA_KEY
152
+ ];
153
+ return new Set(
154
+ Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string") : [],
155
+ );
156
+ }
157
+
158
+ function rememberDelivered(
159
+ db: WritableDatabase,
160
+ sessionId: string | null,
161
+ ids: readonly string[],
162
+ nowIso: string,
163
+ options: { clearRefresh?: boolean } = {},
164
+ ): void {
165
+ if (!sessionId) return;
166
+ const session = getRuntimeSession(db, sessionId);
167
+ if (!session) return;
168
+ const metadata = decodeMetadata(session.metadata);
169
+ metadata[DOORMAN_DELIVERED_METADATA_KEY] = [...ids];
170
+ if (options.clearRefresh) delete metadata[DOORMAN_REFRESH_METADATA_KEY];
171
+ updateRuntimeSessionColumns(db, sessionId, { metadata: pythonJsonDumps(metadata) }, nowIso);
172
+ }
173
+
174
+ /** One ranked memory's scores, content-free, for the front-door log. */
175
+ export interface MemoryScore {
176
+ readonly score: number;
177
+ readonly semantic: number;
178
+ }
179
+
180
+ /**
181
+ * The session's state as the decision reads it. "Mirror Mode on" is
182
+ * `mirror_active` -- which only `mirror deactivate` clears -- AND the active
183
+ * operating mode (the session's own row, else the global one) still being
184
+ * Mirror Mode (CV9.DS8.US4). `build load`, `explore`, and `soul load` switch the
185
+ * mode and leave the column set; read alone, it would put "answer as the
186
+ * mirror" on every Builder turn of a session that was once in Mirror Mode. A
187
+ * home with no mode record at all leaves the column to decide.
188
+ */
189
+ export function readDoormanSessionState(
190
+ db: Database,
191
+ sessionId: string | null,
192
+ ): DoormanSessionState | null {
193
+ if (!sessionId) return null;
194
+ const session = getRuntimeSession(db, sessionId);
195
+ if (!session) return null;
196
+ const mode = getActiveOperatingMode(db, sessionId);
197
+ const inMirrorMode = mode === null || mode.mode === "Mirror Mode";
198
+ return {
199
+ active: session.mirrorActive && inMirrorMode,
200
+ persona: session.persona,
201
+ journey: session.journey,
202
+ refresh: decodeMetadata(session.metadata)[DOORMAN_REFRESH_METADATA_KEY] === true,
203
+ };
204
+ }
205
+
206
+ export function decideDoorman(input: {
207
+ prompt: string;
208
+ state: DoormanSessionState | null;
209
+ personas: readonly PersonaRoutingRow[];
210
+ }): DoormanDecision {
211
+ const explicit = mirrorForm(input.prompt);
212
+ if (explicit) return { kind: "full", query: explicit.query };
213
+ if (!input.state?.active) return { kind: "nothing" };
214
+ // A skill invocation is not a question to the mirror (CR160's rule for the
215
+ // logger, held here since CV9.DS8.US5's walk): `/mm-build`, `/mm-journeys`,
216
+ // `/mm-explore` paid an embedding and got a delta, and one Builder card was
217
+ // signed. The Mirror form, the one skill whose argument IS the question,
218
+ // was answered above.
219
+ if (isCommandTurn(input.prompt)) return { kind: "nothing" };
220
+ // The runtime compacted the transcript: the folder is a summary now, so this
221
+ // turn delivers it whole, routed from the prompt, and the deltas resume after.
222
+ 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 };
226
+ }
227
+
228
+ export function deliveredLine(input: {
229
+ kind: "full" | "delta";
230
+ persona: string | null;
231
+ journey: string | null;
232
+ }): string {
233
+ const clause = input.persona
234
+ ? `answer as the mirror; sign ◇ ${input.persona}`
235
+ : "answer as the mirror";
236
+ const what =
237
+ input.kind === "full"
238
+ ? "full"
239
+ : `delta (persona: ${input.persona ?? "none"} · journey: ${input.journey ?? "none"})`;
240
+ return `${DELIVERED_LINE_PREFIX}: ${what} — ${clause} ===`;
241
+ }
242
+
243
+ export interface RunDoormanInput
244
+ extends Omit<MirrorLoadInput, "query" | "contextOnly" | "sessionId" | "environmentSessionId"> {
245
+ /** The named session, or null: an unnamed explicit turn loads without marking one. */
246
+ sessionId: string | null;
247
+ embeddingProvider?: EmbeddingProvider;
248
+ /**
249
+ * Where a delta turn's memory scores go -- every ranked memory's, the ones
250
+ * under the threshold included -- so the threshold is read against
251
+ * measurement (US5's live walk), not invented. Content-free: numbers only.
252
+ */
253
+ onMemoryScores?: (scores: readonly MemoryScore[]) => void;
254
+ }
255
+
256
+ export interface RenderedDoorman {
257
+ stdout: string;
258
+ stderr: string;
259
+ }
260
+
261
+ /**
262
+ * Render and write for a decision already taken. The caller opened `db` for
263
+ * writing only because the decision is `full` or `delta`: a `nothing` turn must
264
+ * never reach a writable handle (the live-write path snapshots the database
265
+ * first, 95 ms measured), which is why decide and run are two steps.
266
+ */
267
+ export async function runDoorman(
268
+ db: WritableDatabase,
269
+ decision: DoormanDecision,
270
+ input: RunDoormanInput,
271
+ ): Promise<RenderedDoorman> {
272
+ if (decision.kind === "nothing") return { stdout: "", stderr: "" };
273
+ if (decision.kind === "full") return runFull(db, decision.query, input);
274
+ return runDelta(db, decision, input);
275
+ }
276
+
277
+ async function runFull(
278
+ db: WritableDatabase,
279
+ query: string,
280
+ input: RunDoormanInput,
281
+ ): Promise<RenderedDoorman> {
282
+ const loaded = await runMirrorLoad(db, {
283
+ ...input,
284
+ query: query || null,
285
+ contextOnly: true,
286
+ sessionId: input.sessionId,
287
+ });
288
+ if (input.sessionId) {
289
+ // The conversation's context starts over with the folder: so does the
290
+ // list of memories the deltas have put in it, and a compaction's refresh
291
+ // is answered.
292
+ rememberDelivered(db, input.sessionId, [], input.nowIso(), { clearRefresh: true });
293
+ }
294
+ const line = deliveredLine({
295
+ kind: "full",
296
+ persona: loaded.resolved.persona,
297
+ journey: loaded.resolved.journey,
298
+ });
299
+ return { stdout: `${line}\n${loaded.stdout}`, stderr: loaded.stderr };
300
+ }
301
+
302
+ async function runDelta(
303
+ db: WritableDatabase,
304
+ decision: Extract<DoormanDecision, { kind: "delta" }>,
305
+ input: RunDoormanInput,
306
+ ): Promise<RenderedDoorman> {
307
+ const state = readDoormanSessionState(db, input.sessionId);
308
+ const persona = decision.persona ?? state?.persona ?? null;
309
+ const journey = state?.journey ?? null;
310
+ const parts: string[] = [deliveredLine({ kind: "delta", persona, journey })];
311
+
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
+ );
322
+ }
323
+ }
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);
330
+
331
+ const delivered = deliveredMemoryIds(db, input.sessionId);
332
+ const memories = await searchMemoriesWithStatus(db, {
333
+ query: decision.query,
334
+ // Read past what the transcript already holds, so a delta still fills.
335
+ limit: Math.min(DOORMAN_MEMORY_LIMIT + delivered.size, DOORMAN_SEARCH_CEILING),
336
+ provider: input.embeddingProvider,
337
+ ...(queryEmbedding ? { queryEmbedding } : {}),
338
+ // The doorman searches on the system's behalf N times a session: a turn's
339
+ // injection must not raise a memory's reinforcement for the next turn
340
+ // (AI-12). Reinforcement stays with the explicit load.
341
+ logAccess: false,
342
+ recordEmbeddingLedger: false,
343
+ now: input.nowIso(),
344
+ });
345
+ input.onMemoryScores?.(
346
+ memories.results.map((result) => ({ score: result.score, semantic: result.semantic })),
347
+ );
348
+ const relevant = (
349
+ memories.degraded
350
+ ? memories.results
351
+ : memories.results.filter((result) => result.semantic >= DOORMAN_MEMORY_SEMANTIC_THRESHOLD)
352
+ )
353
+ .filter((result) => !delivered.has(result.id))
354
+ .slice(0, DOORMAN_MEMORY_LIMIT);
355
+ if (relevant.length > 0) {
356
+ rememberDelivered(
357
+ db,
358
+ input.sessionId,
359
+ [...delivered, ...relevant.map((result) => result.id)],
360
+ input.nowIso(),
361
+ );
362
+ const rows = memoriesById(
363
+ db,
364
+ relevant.map((result) => result.id),
365
+ );
366
+ const block = ["=== relevant memories ==="];
367
+ for (const result of relevant) {
368
+ const memory = rows.get(result.id);
369
+ if (!memory) continue;
370
+ block.push(`\n[${memory.layer}] ${memory.title}\n${memory.content}`);
371
+ }
372
+ parts.push(block.join("\n"));
373
+ }
374
+
375
+ if (queryEmbedding) {
376
+ const attachments = await relevantAttachments(
377
+ db,
378
+ decision.query,
379
+ journey,
380
+ undefined,
381
+ undefined,
382
+ queryEmbedding,
383
+ );
384
+ if (attachments.length > 0) {
385
+ const block = ["=== relevant attachments ==="];
386
+ for (const { row, score } of attachments) {
387
+ const source = journey ? "" : ` [${row.journeyId}]`;
388
+ block.push(`--- ${row.name}${source} (score: ${score.toFixed(3)}) ---`);
389
+ if (row.description) block.push(`Description: ${row.description}`);
390
+ block.push(row.content);
391
+ }
392
+ parts.push(block.join("\n"));
393
+ }
394
+ }
395
+
396
+ return { stdout: `${parts.join("\n\n")}\n`, stderr: "" };
397
+ }
398
+
399
+ async function embedOnce(
400
+ db: WritableDatabase,
401
+ query: string,
402
+ input: RunDoormanInput,
403
+ ): Promise<readonly number[] | null> {
404
+ if (!input.embeddingProvider) return null;
405
+ try {
406
+ return await generateEmbeddingSafely(input.embeddingProvider, query, {
407
+ onAttempt: embeddingLedgerHook(db, {
408
+ sessionId: input.sessionId,
409
+ now: input.nowIso,
410
+ }),
411
+ });
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
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
+ }
@@ -23,6 +23,7 @@ import {
23
23
  decodeMetadata,
24
24
  GLOBAL_STICKY_DEFAULTS_SESSION_ID,
25
25
  getRuntimeSession,
26
+ resolveNamedRuntimeSessionId,
26
27
  resolveRuntimeSessionId,
27
28
  upsertRuntimeSession,
28
29
  } from "./runtimeSession.ts";
@@ -120,13 +121,33 @@ export async function runMirrorLoad(
120
121
  });
121
122
  persistStickyDefaults(db, resolved.persona, resolved.journey, input.nowIso());
122
123
  activateOperatingMode(db, { mode: "Mirror Mode", journey: resolved.journey }, input.nowIso());
124
+ // The NAMED session -- explicit, else the one the harness put in the
125
+ // environment -- never a guess (CV9.DS8.TS2, closing TS1's measured gap:
126
+ // the skill's own load passed the environment id into the route and this
127
+ // write ignored it, so a self-started Mirror Mode left `mirror_active`
128
+ // unwritten and the doorman had nothing to learn from).
129
+ const namedSessionId = resolveNamedRuntimeSessionId(
130
+ input.sessionId ?? null,
131
+ input.environmentSessionId ?? null,
132
+ );
133
+ if (namedSessionId) {
134
+ // The session's own mode row as well as the global one (CV9.DS8.US4): the
135
+ // doorman reads the session's row first, and `build load` stamps that same
136
+ // row Builder -- without this write a Mirror re-entry in the same session
137
+ // would read as Builder and the deltas would never resume.
138
+ activateOperatingMode(
139
+ db,
140
+ { mode: "Mirror Mode", journey: resolved.journey, sessionId: namedSessionId },
141
+ input.nowIso(),
142
+ );
143
+ }
123
144
  writeMirrorState(
124
145
  db,
125
146
  {
126
147
  active: true,
127
148
  persona: resolved.persona,
128
149
  journey: resolved.journey,
129
- sessionId: input.sessionId ?? null,
150
+ sessionId: namedSessionId,
130
151
  },
131
152
  input.nowIso(),
132
153
  );
@@ -246,7 +267,6 @@ export function persistStickyDefaults(
246
267
  mirrorActive: false,
247
268
  ...(persona !== null ? { persona } : {}),
248
269
  ...(journey !== null ? { journey } : {}),
249
- hookInjected: true,
250
270
  active: false,
251
271
  },
252
272
  nowIso,
@@ -271,7 +291,6 @@ function writeMirrorState(
271
291
  mirrorActive: input.active,
272
292
  ...(input.persona !== undefined && input.persona !== null ? { persona: input.persona } : {}),
273
293
  ...(input.journey !== undefined && input.journey !== null ? { journey: input.journey } : {}),
274
- hookInjected: !input.active,
275
294
  },
276
295
  nowIso,
277
296
  );
@@ -16,10 +16,16 @@ export function renderMirrorModeTransition(input: {
16
16
  return box("◌ MIRROR MODE ACTIVE", rows);
17
17
  }
18
18
 
19
+ /**
20
+ * The stderr banner `mirror mirror load` prints. An active persona is signed
21
+ * `◇ <persona>`, the one rule `AGENTS.md` states (DS8 decision 2) -- on Pi the
22
+ * model reads stderr inside the tool result, so the banner is also an example
23
+ * of the signature it is asked to write.
24
+ */
19
25
  export function renderMirrorBanner(persona: string | null): string {
20
26
  const first = "\u001b[38;5;183m⏺ Mirror Mode active\u001b[0m\n";
21
27
  return persona
22
- ? `${first}\u001b[38;5;183m ✦ Persona: ${persona}\u001b[0m\n`
28
+ ? `${first}\u001b[38;5;183m ◇ ${persona}\u001b[0m\n`
23
29
  : `${first}\u001b[38;5;183m Ego responding without persona\u001b[0m\n`;
24
30
  }
25
31