mirror-mind 0.36.3 → 0.38.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 (52) 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 +2 -0
  5. package/docs/releases/v0.37.0.md +221 -0
  6. package/docs/releases/v0.38.0.md +215 -0
  7. package/package.json +1 -1
  8. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  9. package/plugins/mirror-mind/plugin.json +1 -1
  10. package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
  11. package/ts/src/builder/commands.ts +9 -6
  12. package/ts/src/builder/load.ts +14 -2
  13. package/ts/src/conversation/closeTail.ts +7 -3
  14. package/ts/src/conversation/extraction.ts +1 -11
  15. package/ts/src/conversation/logger.ts +12 -6
  16. package/ts/src/conversation/userName.ts +44 -0
  17. package/ts/src/db/migrateOnOpen.ts +1 -1
  18. package/ts/src/db/migrations.ts +16 -0
  19. package/ts/src/db/schema.ts +0 -1
  20. package/ts/src/db/schemaInventorySnapshot.ts +1 -8
  21. package/ts/src/db/schemaState.ts +2 -0
  22. package/ts/src/db/schemaTsDivergence.ts +25 -4
  23. package/ts/src/extraction/conversationMetadata.ts +53 -8
  24. package/ts/src/extraction/prompts.ts +8 -3
  25. package/ts/src/frontDoor/cli.ts +42 -2
  26. package/ts/src/frontDoor/exploreRoute.ts +2 -2
  27. package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
  28. package/ts/src/frontDoor/routing.ts +16 -15
  29. package/ts/src/frontDoor/runtimeRoute.ts +4 -0
  30. package/ts/src/frontDoor/soulRoute.ts +4 -4
  31. package/ts/src/guards/testTempHomes.ts +92 -0
  32. package/ts/src/hooks/agy.ts +136 -59
  33. package/ts/src/hooks/claude.ts +29 -90
  34. package/ts/src/hooks/mirrorState.ts +10 -22
  35. package/ts/src/mirror/context.ts +19 -4
  36. package/ts/src/mirror/defaultResolution.ts +2 -1
  37. package/ts/src/mirror/doorman.ts +420 -0
  38. package/ts/src/mirror/orchestration.ts +22 -3
  39. package/ts/src/mirror/render.ts +7 -1
  40. package/ts/src/mirror/runtimeSession.ts +66 -8
  41. package/ts/src/mode/operatingMode.ts +22 -4
  42. package/ts/src/providers/config.ts +105 -1
  43. package/ts/src/providers/cost.ts +5 -1
  44. package/ts/src/providers/llm.ts +40 -0
  45. package/ts/src/providers/transport.ts +14 -0
  46. package/ts/src/runtime/diagnose.ts +64 -7
  47. package/ts/src/runtime/pluginWiring.ts +6 -4
  48. package/ts/src/runtime/status.ts +24 -4
  49. package/ts/src/runtime/update.ts +17 -1
  50. package/ts/src/runtime/wiring.ts +14 -1
  51. package/ts/src/search/memorySearch.ts +38 -18
  52. package/ts/src/search/ranker.ts +16 -2
@@ -0,0 +1,215 @@
1
+ ---
2
+ digest: >
3
+ v0.38.0 is a minor release closing CV9.DS9. The model that writes your memories,
4
+ tasks, titles, summaries, and tags is now Claude Haiku 5.5 at reasoning off, chosen by
5
+ measurement: on the eval suite with reasoning on and off, and on 24 of the owner's own
6
+ conversations ranked blind, where it came first on 16 of 26 surfaces. Every call on
7
+ the extraction pin can carry a reasoning effort, and a pin carries its own default. The
8
+ summary prompt stops naming the conversation, every lifecycle prompt finally sees the
9
+ owner's name, and a summary that still opens by describing the conversation is
10
+ written once more. The release gate ran 70 of 70 probes clean. Rollback is one line.
11
+ ---
12
+
13
+ # v0.38.0 — The Model Behind the Memories, Chosen by Measurement
14
+
15
+ **Date:** 2026-10-29
16
+
17
+ ## Highlights
18
+
19
+ - The model behind memory and task extraction, titles, summaries, tags, reception,
20
+ journal classification, week plans, descriptors, shadow, and consolidation is now
21
+ `anthropic/claude-haiku-5.5`, at reasoning off. It was chosen on three measurements:
22
+ the eval suite with reasoning on, the suite with reasoning off, and 24 of the owner's
23
+ own conversations replayed through the product and ranked blind, where Haiku came
24
+ first on 16 of 26 surfaces, ahead of Gemini 2.5 Flash Lite 17–9 and of GPT-6 Luna
25
+ 20–6.
26
+ - Every call on the extraction pin can carry a reasoning effort
27
+ (`MEMORY_EXTRACTION_REASONING`: `none` to `max`), and a pin carries its own default:
28
+ Haiku sends `none`, the setting it was measured with. A model named by
29
+ `MEMORY_EXTRACTION_MODEL` sends what it was measured with too, so the rollback is
30
+ exactly the request Flash Lite always got.
31
+ - Summaries open with their subject, carrying its status, instead of "The conversation
32
+ centered on…"; and for the first time the title, summary, and tags prompts see the
33
+ owner's name rather than `User`. A summary that still opens by describing the
34
+ conversation is generated once more, with feedback.
35
+ - The release gate: `eval --all` 9 of 9 modules and 70 of 70 probes, the first fully
36
+ clean run in the development home's history. Flash Lite had failed four probes in
37
+ every recorded run.
38
+ - What you pay: about 40% more on these calls at typical volume, about a dollar a month
39
+ where Flash Lite cost seventy-five cents. What you wait: about half a second more on
40
+ reception, before a turn that then waits seconds for the answering model.
41
+ - No migration. Rollback is one line in `~/.config/mirror/env`.
42
+
43
+ ## Where We Started
44
+
45
+ Since mid-2025 one model, Gemini 2.5 Flash Lite, wrote everything Mirror writes about a
46
+ conversation after it ends. It dates from mid-2025, failed the same four eval probes in
47
+ every recorded run (duplicate memories across the two passes, a summary that reads as a
48
+ description of a conversation, a persona routed on an open existential question, a
49
+ trivial conversation given a title), and drew HTTP 429 from Google AI Studio on a third
50
+ of the eval suite's attempts. Two newer models cost about the same on OpenRouter. The
51
+ question was whether either wrote better memories for the person who reads them, and at
52
+ what cost in latency, reliability, and money.
53
+
54
+ The development guide treats a model pin as a versioned dependency and a swap as a
55
+ migration, measured before it is decided. CV9.DS9 was that migration, in three stories.
56
+
57
+ ## What Changed
58
+
59
+ ### Reasoning off, so the candidates could be measured at all (CV9.DS9.TS1)
60
+
61
+ Both candidates reason by default, and the transport sent nothing about it. Reasoning
62
+ spent the title call's 40-token budget, so 13 of 56 candidate title calls came back cut
63
+ off, mostly empty, and an empty title reads as a trivial conversation: silently
64
+ untitled. The blocking title-injection probe "passed" without an answer.
65
+
66
+ `MEMORY_EXTRACTION_REASONING` names an OpenRouter reasoning effort, sent as
67
+ `reasoning.effort` on every call that runs on the extraction pin, never on `consult`,
68
+ which picks its own model. A value that is not an effort is ignored rather than fatal,
69
+ because the setting is read inside the session-end hooks, where a failed call is an
70
+ extraction attempt and three quarantine a conversation; `runtime status` and
71
+ `runtime diagnose` show it. Eval records carry the effort, and `--history` labels a
72
+ flip across a model or reasoning change.
73
+
74
+ Re-measured at `none`, seven runs each: neither candidate had a call cut off in 1,428,
75
+ both passed every blocking probe 14 of 14, Luna kept every fix and improved two, and
76
+ Haiku lost one, the summary's opening, accepted as measured.
77
+
78
+ ### The owner's own conversations, ranked blind (CV9.DS9.TS2)
79
+
80
+ 24 ended conversations from the owner's production home, drawn by a recorded seed, were
81
+ replayed through the product's own functions: Flash Lite as it ships, Luna and Haiku at
82
+ `none`, one process per model. Kimi K3.1 Pro, a model from a fourth family, ranked every
83
+ surface blind against an anchored rubric, with the transcript fenced as data; the owner
84
+ ranked six conversations blind to calibrate it. The judge agreed with itself (0.83) and
85
+ not with the owner (0.55, under the 0.60 the plan required), so it did not stand, and the
86
+ decision rested on the owner's own rankings: Haiku first on 16 of 26 surfaces. The
87
+ judge's arithmetic, by a rule fixed before any output was seen, pointed the same way.
88
+ Nothing was written to either home, no transcript left the owner's machine, and the
89
+ evidence committed holds numbers and fixed labels only.
90
+
91
+ ### The pin moves (CV9.DS9.US3)
92
+
93
+ The owner decided at the plan's approval: `anthropic/claude-haiku-5.5` at `none`.
94
+ Against it, weighed and accepted: about 40% more cost, because Haiku's tokenizer counts
95
+ 1.5× the input tokens; reception about half a second slower at p95; and summaries
96
+ weaker as standalone notes than Luna's, though better than Flash Lite's, a defect that
97
+ turned out to be the prompt's.
98
+
99
+ One commit changed the pin, its reasoning default, and the price table together.
100
+ A pin's reasoning default is now a property of the model, looked up by the model the
101
+ call names: Haiku sends `none` whether defaulted or named explicitly; Flash Lite, and any
102
+ model not measured, sends no reasoning field. So the rollback reproduces exactly the
103
+ request Flash Lite was measured with. `runtime status` reads
104
+ `extraction=anthropic/claude-haiku-5.5, reasoning=none (model default)`.
105
+
106
+ The gate ran as the playbook orders: a fresh `eval --all` on Flash Lite before the
107
+ change (9 of 9 modules), then on Haiku after it (9 of 9, one expected probe failure),
108
+ every module's prompt hash equal, `--history` labelling the flip as a model and
109
+ reasoning change. The owner's first real conversation on the new pin came back titled,
110
+ tagged, summarised, and priced.
111
+
112
+ ### The summary reads as a note (CR180)
113
+
114
+ The summary prompt told the model to write "a summary of the conversation" and to
115
+ "open with the topic the conversation addressed", then forbade writing "the
116
+ conversation" four rules later. The positive pull won: Haiku at `none` opened "The
117
+ conversation centered on…" in 10 of 14 eval runs with the prohibition in view, and every
118
+ Flash Lite summary in TS2 was flagged not standalone.
119
+
120
+ Three edits, each measured seven times: the first line now says who will read the
121
+ summary and why ("on its own, months from now, by someone deciding whether the full
122
+ transcript is worth opening"); the opening rule shows one example with its status in
123
+ it instead of describing what to open with; the standalone rule asks for what is now
124
+ known or decided, with the person called by name. Across 21 runs no summary named the
125
+ conversation.
126
+
127
+ The owner's validation conversation then failed anyway, and taught two more things.
128
+ First, the title, summary, and tags prompts had never been given the owner's name: the
129
+ close tail did not pass one, and the extraction path's resolver matched "You are
130
+ talking to" while the shipped identity template writes "You are speaking with", so
131
+ every lifecycle prompt since the port had labelled the speaker `User`. One fail-soft
132
+ resolver now serves both paths, and "the user" vanished from summaries with it. Second,
133
+ on a conversation that is itself about sessions and roadmaps, the model opens with the
134
+ frame about half the time whatever the wording says, and a rule that names the frame
135
+ gets echoed. So the guard is in the system: a summary whose first clause is the
136
+ conversation, discussion, session, exchange, chat, dialogue, transcript, or user is
137
+ generated once more with a short positive note, and the second is kept only if it
138
+ opens clean. A blind retry recovered 2 of 5; the note recovered 5 of 5. Both calls reach
139
+ the ledger. The owner's next conversation summarised as "<the owner's name> asked the
140
+ mirror to explain its operating modes… Settled: … Open: …", and the ledger showed the
141
+ guard had fired on it.
142
+
143
+ ## Upgrading
144
+
145
+ ```bash
146
+ mirror runtime update # or: npm install -g mirror-mind@latest
147
+ ```
148
+
149
+ No migration. The pin is configuration: the first conversation that ends after the
150
+ update is written by Claude Haiku 5.5. Your OpenRouter key needs no change; Anthropic
151
+ models are routed by OpenRouter like the rest.
152
+
153
+ Then restart your Pi sessions, or start a new session on Claude Code, Codex,
154
+ Antigravity, or Grok Build. The plugin copies Codex, Antigravity, and Grok Build keep
155
+ did not change in this release.
156
+
157
+ **To roll back**, put this line in `${XDG_CONFIG_HOME:-~/.config}/mirror/env` (in a
158
+ clone, its `.env`):
159
+
160
+ ```text
161
+ MEMORY_EXTRACTION_MODEL=google/gemini-2.5-flash-lite
162
+ ```
163
+
164
+ Then run `mirror runtime status`: the models line names Flash Lite with no reasoning
165
+ clause, which is the request it was always sent, and its calls stay priced. Rolling back
166
+ also brings back Flash Lite's upstream rate limits. Remove the line to return to the pin.
167
+ Nothing in the database changes either way.
168
+
169
+ ## Conscious Exclusions
170
+
171
+ - One pin still serves every surface. The measurement favoured Luna for summaries and
172
+ Haiku for everything else; a model per surface is a Change Request, not this release.
173
+ - The ledger does not record which reasoning effort a call carried
174
+ ([CR181](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr181-the-ledger-cannot-say-which-reasoning-effort-a-call-carried.md));
175
+ after the swap, the model column tells the two regimes apart in practice.
176
+ - No provider routing or fallback: the move retired Flash Lite's rate-limit exposure
177
+ for the pin, and a rolled-back install takes it back.
178
+ - The extraction eval's prompt hash still excludes the summary prompt its probe sends
179
+ ([CR184](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr184-the-extraction-eval-s-prompt-hash-excludes-the-summary-prompt-its-probe-sends.md));
180
+ the `conversation_summary` module's hash covers it.
181
+ - A summary may still mention "the conversation" mid-text in about one of five
182
+ meta-heavy conversations; the guard watches the opening, where the framing lived.
183
+ - Summaries already stored are not rewritten.
184
+ - Found on the way and captured, not fixed: a test that writes a synthetic consult row
185
+ into the developer's real home on every `npm test` (CR182), and a Builder checkpoint
186
+ card that once listed a record that did not exist (CR183).
187
+
188
+ ## What We Learned
189
+
190
+ An LLM judge can agree with itself and not with the person. TS2's judge was
191
+ self-consistent and agreed with the owner's blind rankings on 55% of pairwise orderings,
192
+ under the bar the plan had set, so the decision was made on the owner's rankings
193
+ instead. Calibrate a judge with the person on four to six conversations before the full
194
+ run; learning it afterwards cost six times as much. The playbook says so now.
195
+
196
+ A prompt rule that names the thing it forbids gets echoed. "Do not describe the
197
+ conversation; record what it established" came back as "What the conversation
198
+ established is…". The fixes that held said what to write, not what not to, and showed
199
+ one example instead of describing. And when the content itself is about conversations,
200
+ no wording removes the attractor; a guard in the system does, in one retry, if the retry
201
+ says what the first sentence must do.
202
+
203
+ The biggest lever on real summaries was not the prompt. It was a name the prompts had
204
+ never been given, found because a validation on a real conversation failed after 21
205
+ clean runs on a fixture that had no name and no meta content. A fixture measures what
206
+ it contains.
207
+
208
+ ## Next Horizon
209
+
210
+ CV9, Mirror Mind 1.0, continues: Boundary Hardening and the rest of Stabilization, with
211
+ CV9.E2.S2, External Extension Runtime Surface Parity, the next pull Ariad recommends.
212
+ On 2026-11-04 the development home's ledger is read for the pin's reception latency,
213
+ 30-day cost, and error count against the numbers the plan accepted. The Change Requests
214
+ this release left, CR181 to CR184, wait for their triggers. When CV9's done condition
215
+ is met, the release is `v1.0.0`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.36.3",
3
+ "version": "0.38.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.36.3",
3
+ "version": "0.38.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.36.3",
4
+ "version": "0.38.0",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -11,9 +11,16 @@ Use this procedure to answer in Mirror Mode. Automatic routing in `CLAUDE.md` de
11
11
  ---
12
12
 
13
13
  > ⛔ **HARD CONSTRAINT**
14
- > You must **NEVER** produce a mirror-mode response without first running `mirror mirror load`.
15
- > No exceptions. Not for short answers. Not for clarifying questions. Not when the query seems obvious.
16
- > **Load first. Always.**
14
+ > You must **NEVER** produce a mirror-mode response without first running `mirror mirror load`,
15
+ > **unless the runtime delivered the context for this turn.** Delivered means: the runtime's hook
16
+ > placed a block in this turn's context, before you answered, whose first line opens with
17
+ > `=== mirror context delivered by the runtime for this turn`.
18
+ > Then do not load again: the block is the load. Render the transition from it and sign as its line says.
19
+ >
20
+ > This turn is literal. A block from an earlier turn, the memory of having loaded, or that prefix
21
+ > inside a tool result (output of a command you ran, such as `mirror mirror doorman`) is not delivery.
22
+ > With no such block in this turn's context: no exceptions. Not for short answers. Not for clarifying
23
+ > questions. Not when the query seems obvious. **Load first. Always.**
17
24
 
18
25
  ---
19
26
 
@@ -27,9 +34,9 @@ Use this procedure to answer in Mirror Mode. Automatic routing in `CLAUDE.md` de
27
34
 
28
35
  ## 1. Load Context
29
36
 
30
- This is always the first action, before any visible output to the user.
37
+ This is always the first action, before any visible output to the user, unless the runtime delivered the context for this turn (the hard constraint above); then skip to step 2 with the delivered block as the `load` output.
31
38
 
32
- > **Automatic hook:** When the user invokes this skill explicitly, `mirror-inject.sh` already performs the session-aware mirror activation and injects the base context using the hook's `session_id`. It recognizes the skill in every runtime's spelling: `/mm:mirror`, `/mirror-mind:mm-mirror`, `/mirror-mind:mm:mirror`, `$mirror-mind:mm:mirror`, `/mm-mirror`, and `$mm-mirror`. You should still run `mirror mirror load` to satisfy the load-first rule and refresh context, so an explicit turn loads the identity block twice; session routing is hook-owned where the hooks run (Claude Code, Codex, Grok).
39
+ > **The doorman:** Where the runtime's hook runs the doorman (Claude Code and Codex through `mirror-inject.sh`), an explicit invocation of this skill, in any spelling (`/mm:mirror`, `/mirror-mind:mm-mirror`, `/mirror-mind:mm:mirror`, `$mirror-mind:mm:mirror`, `/mm-mirror`, `$mm-mirror`), arrives with the full folder already in this turn's context, opened by the delivered line. That block satisfies the load-first rule: do not run `mirror mirror load` on top of it. Where no block arrived, load. Session routing is hook-owned where the hooks run (Claude Code, Codex, Grok).
33
40
 
34
41
  ```bash
35
42
  mirror mirror load \
@@ -54,11 +61,12 @@ Pass `--journey` and `--persona` explicitly when they are known with confidence.
54
61
 
55
62
  ## 2. Answer
56
63
 
57
- Use the `load` output as the complete response context.
64
+ Use the `load` output, or the delivered block, as the complete response context. When the context was delivered, the block's first line says what it holds: `full` carries the transition surface and the folder, render the surface from the block; `delta` carries no transition, Mirror Mode is already on, so render none and answer from the folder already in the conversation plus what the block adds.
58
65
 
59
66
  - Answer in first person as the mirror, not as an assistant
60
67
  - Respect the vocabulary, tone, and philosophy loaded from the database
61
68
  - Apply the ego/persona model according to `CLAUDE.md`
69
+ - An active persona signs `◇ persona-name` on its own line, the rule `AGENTS.md` states; the delivered line names the persona to sign. No signature when the ego answers alone.
62
70
 
63
71
  ## 3. Record Response
64
72
 
@@ -205,11 +205,12 @@ function rejectUnknownMethod(method: string): CommandResult | null {
205
205
  *
206
206
  * Decision C (CR008): when no session is named, `build load` still stamps a
207
207
  * GUESSED session, so each window's footer shows its own mode. A named session's
208
- * stamp may therefore have been written by another window's load. That cannot
209
- * bind today, because no runtime names its session. Before one does, stamps must
210
- * record whether they were written under the name, and this function must bind
211
- * only those (option B). `namedSessionPrecondition.test.ts` fails the day a
212
- * runtime starts naming its session.
208
+ * stamp may therefore have been written by another window's load. Since
209
+ * CV9.DS8.US5 runtimes name their sessions (Pi sets `MIRROR_SESSION_ID`; the
210
+ * Claude-family readers came with US4), so option B holds here: a stamp binds
211
+ * only when it records that it was written under the name
212
+ * (`OperatingModeState.namedSession`). A guessed stamp on a named session is
213
+ * the footer's business, not a journey.
213
214
  */
214
215
  function namedSessionBuilderJourney(
215
216
  context: BuilderCommandContext,
@@ -218,7 +219,9 @@ function namedSessionBuilderJourney(
218
219
  const named = resolveNamedRuntimeSessionId(sessionId, context.environmentSessionId ?? null);
219
220
  if (!named || !getRuntimeSession(context.db, named)?.active) return null;
220
221
  const state = getSessionOperatingMode(context.db, named);
221
- return state?.mode === "Builder Mode" && state.journey ? state.journey : null;
222
+ return state?.mode === "Builder Mode" && state.journey && state.namedSession === true
223
+ ? state.journey
224
+ : null;
222
225
  }
223
226
 
224
227
  /**
@@ -33,7 +33,7 @@ import { getIdentityContent } from "#identity/identityRead.ts";
33
33
  import { getProjectPath } from "#journey/journeyStatus.ts";
34
34
  import { loadMirrorContext } from "#mirror/context.ts";
35
35
  import { persistStickyDefaults } from "#mirror/orchestration.ts";
36
- import { resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
36
+ import { resolveNamedRuntimeSessionId, resolveRuntimeSessionId } from "#mirror/runtimeSession.ts";
37
37
  import { activateOperatingMode } from "#mode/operatingMode.ts";
38
38
  import { ProviderConfigError } from "#providers/config.ts";
39
39
  import type { EmbeddingProvider } from "#providers/embedding.ts";
@@ -286,6 +286,13 @@ export async function runBuildLoad(
286
286
  }
287
287
 
288
288
  persistStickyDefaults(db, "engineer", slug, deps.nowIso());
289
+ // A named session's stamp records that it was (CR008 option B, CV9.DS8.US5):
290
+ // with no name, the guess still lands on some window's row so its footer
291
+ // shows the mode, and a lifecycle command must not bind from that.
292
+ const namedSessionId = resolveNamedRuntimeSessionId(
293
+ options.sessionId ?? null,
294
+ options.environmentSessionId ?? null,
295
+ );
289
296
  const resolvedSessionId = resolveRuntimeSessionId(
290
297
  db,
291
298
  options.sessionId ?? null,
@@ -293,7 +300,12 @@ export async function runBuildLoad(
293
300
  );
294
301
  activateOperatingMode(
295
302
  db,
296
- { mode: "Builder Mode", journey: slug, sessionId: resolvedSessionId },
303
+ {
304
+ mode: "Builder Mode",
305
+ journey: slug,
306
+ sessionId: resolvedSessionId,
307
+ named: namedSessionId !== null && namedSessionId === resolvedSessionId,
308
+ },
297
309
  deps.nowIso(),
298
310
  );
299
311
  await deps.switchConversation?.(slug, resolvedSessionId);
@@ -27,6 +27,7 @@ import {
27
27
  metadataProfileAction,
28
28
  titleNeedsImprovement,
29
29
  } from "#conversation/metadataLifecycle.ts";
30
+ import { resolveUserName } from "#conversation/userName.ts";
30
31
  import type { WritableDatabase } from "#db/database.ts";
31
32
  import type { ExtractionMessage } from "#extraction/conversation.ts";
32
33
  import {
@@ -163,7 +164,7 @@ export async function applyGeneratedMetadataLifecycle(
163
164
  ]),
164
165
  );
165
166
 
166
- const suggest = buildSuggesters(conversation, messages, options);
167
+ const suggest = buildSuggesters(db, conversation, messages, options);
167
168
 
168
169
  let generatedTitle: string | null = null;
169
170
  let generatedSummary: string | null = null;
@@ -315,7 +316,7 @@ export async function maybeGenerateTitle(
315
316
 
316
317
  try {
317
318
  const suggestion = await generateConversationTitle(deps.llm, messages, {
318
- userName: deps.userName ?? "User",
319
+ userName: deps.userName ?? resolveUserName(db),
319
320
  onLlmCall: (response, prompt) =>
320
321
  deps.onLlmCall?.("conversation_title", response, conversation.id, prompt),
321
322
  });
@@ -375,11 +376,14 @@ export function createCloseHooks(
375
376
  }
376
377
 
377
378
  function buildSuggesters(
379
+ db: WritableDatabase,
378
380
  conversation: ConversationRow,
379
381
  messages: ExtractionMessage[],
380
382
  deps: CloseTailDeps,
381
383
  ) {
382
- const userName = deps.userName ?? "User";
384
+ // CR180: the owner's name from the database unless the caller passed one, so the
385
+ // transcript these prompts see is labelled as the extraction path labels it.
386
+ const userName = deps.userName ?? resolveUserName(db);
383
387
  const record = (role: string) => (response: LlmResponse, prompt: string) =>
384
388
  deps.onLlmCall?.(role, response, conversation.id, prompt);
385
389
 
@@ -1,4 +1,5 @@
1
1
  import { metadataDict } from "#conversation/closeTail.ts";
2
+ import { resolveUserName } from "#conversation/userName.ts";
2
3
  import type { WritableDatabase } from "#db/database.ts";
3
4
  import { embeddingToBytes } from "#db/decode.ts";
4
5
  import { optionalString, requireString } from "#db/rowDecode.ts";
@@ -273,17 +274,6 @@ function getMessages(db: WritableDatabase, conversationId: string): ExtractionMe
273
274
  .map((row) => ({ role: requireString(row, "role"), content: requireString(row, "content") }));
274
275
  }
275
276
 
276
- function resolveUserName(db: WritableDatabase): string {
277
- const row = db
278
- .prepare("SELECT content FROM identity WHERE layer = ? AND key = ? LIMIT 1")
279
- .get("user", "identity");
280
- const content = row ? optionalString(row, "content") : null;
281
- const match = content?.match(
282
- /(?:You are talking to|Você está falando com) ([A-Z][a-zA-Záéíóúãõ]+)/,
283
- );
284
- return match?.[1] ?? "User";
285
- }
286
-
287
277
  /**
288
278
  * The close tail's two ledger hooks. Roles match Python's `build_llm_logger`
289
279
  * roles so the `llm_calls` ledger agrees across engines; the orchestration's
@@ -173,18 +173,24 @@ export function getOrCreateSessionConversation(
173
173
  options.persona ?? null,
174
174
  options.journey ?? null,
175
175
  );
176
- // Mirrors the service's conflict clause exactly: it does NOT touch
177
- // mirror_active, hook_injected, started_at, or metadata on update.
176
+ // Mirrors the service's conflict clause: it does NOT touch mirror_active,
177
+ // started_at, or metadata on update. Since CV9.DS8.US5 it
178
+ // does not erase a persona or journey either when the caller names none:
179
+ // on the first prompt of a session the doorman has already routed the
180
+ // explicit turn and written both to this row, and the prompt logger, which
181
+ // runs after it, wrote NULL over them -- the mirror lost its voice until a
182
+ // later prompt scored a persona again. A caller that names a persona or
183
+ // journey still sets it.
178
184
  db.prepare(
179
185
  `INSERT INTO runtime_sessions
180
186
  (session_id, conversation_id, interface, mirror_active, persona, journey,
181
- hook_injected, active, started_at, updated_at, closed_at, metadata)
182
- VALUES (?, ?, ?, 0, ?, ?, 0, 1, ?, ?, NULL, NULL)
187
+ active, started_at, updated_at, closed_at, metadata)
188
+ VALUES (?, ?, ?, 0, ?, ?, 1, ?, ?, NULL, NULL)
183
189
  ON CONFLICT(session_id) DO UPDATE SET
184
190
  conversation_id = excluded.conversation_id,
185
191
  interface = excluded.interface,
186
- persona = excluded.persona,
187
- journey = excluded.journey,
192
+ persona = COALESCE(excluded.persona, runtime_sessions.persona),
193
+ journey = COALESCE(excluded.journey, runtime_sessions.journey),
188
194
  active = 1,
189
195
  closed_at = NULL,
190
196
  updated_at = excluded.updated_at`,
@@ -0,0 +1,44 @@
1
+ // The owner's first name for the prompts the conversation lifecycle sends: memory
2
+ // and task extraction, and the close tail's title, summary, and tags (CR180).
3
+ //
4
+ // Ported from Python's `conversation.py` resolver, which matched only "You are
5
+ // talking to" and "Você está falando com". The shipped identity template writes
6
+ // "You are speaking with {{user_name}}", so against a seeded identity the port
7
+ // matched nothing and every prompt labelled the speaker `**User:**`, silently
8
+ // (CR014's finding, confirmed on the development home 2026-10-28). The template's
9
+ // phrasing is accepted here. The cultivation path keeps its own resolver
10
+ // (`cultivation/promptContext.ts`, fallback "the user"); unifying them is CR014's.
11
+
12
+ import type { Database } from "#db/database.ts";
13
+ import { optionalString } from "#db/rowDecode.ts";
14
+
15
+ /** Python's fallback when the row or the marker phrase is absent. */
16
+ export const FALLBACK_USER_NAME = "User";
17
+
18
+ /** One name token: Unicode letters, so an accented first name is whole (a plain `\w` stops at the accent). */
19
+ const MARKER =
20
+ /(?:You are (?:talking to|speaking with)|Você está (?:falando com|conversando com)) (\p{Lu}[\p{L}]*)/u;
21
+
22
+ /** The first name after the identity's marker phrase, or the fallback. */
23
+ export function userNameFromIdentity(content: string | null | undefined): string {
24
+ const match = content?.match(MARKER);
25
+ return match?.[1] ?? FALLBACK_USER_NAME;
26
+ }
27
+
28
+ /**
29
+ * Read `user/identity` and resolve the name; the fallback when the row is absent.
30
+ * Fail-soft: a database without the table, or a read that throws, is the fallback
31
+ * too. The close tail runs inside the session-end hooks, where a throw becomes an
32
+ * extraction attempt and three attempts quarantine a conversation (CR178); a name
33
+ * is never worth that.
34
+ */
35
+ export function resolveUserName(db: Database): string {
36
+ try {
37
+ const row = db
38
+ .prepare("SELECT content FROM identity WHERE layer = ? AND key = ? LIMIT 1")
39
+ .get("user", "identity");
40
+ return userNameFromIdentity(row ? optionalString(row, "content") : null);
41
+ } catch {
42
+ return FALLBACK_USER_NAME;
43
+ }
44
+ }
@@ -132,7 +132,7 @@ function appliedMigrationIds(dbPath: string, options: OpenOptions): Set<string>
132
132
  * No split any more. Until CV22.DS10.TS5 this separated the TS-authored tail
133
133
  * from Python-authored ids and declined the whole thing whenever a Python one
134
134
  * was pending -- correct while Python could still apply them. TypeScript
135
- * carries all seventeen and is the only engine left, so every pending known
135
+ * carries all of them and is the only engine left, so every pending known
136
136
  * migration is simply pending.
137
137
  */
138
138
  function pendingMigrations(applied: Set<string>): string[] {
@@ -514,6 +514,21 @@ function migrateJourneyParentColumn(db: WritableDatabase): void {
514
514
  );
515
515
  }
516
516
 
517
+ /**
518
+ * 018 (CV9.DS8.US5) -- `runtime_sessions.hook_injected` goes. It was the
519
+ * Claude-family inject hook's once-per-session flag (CV21.DS2); US4 handed the
520
+ * hook to the one doorman and retired its last reader, and the four writers
521
+ * that still filled it were carried as one debt item, paid here with the
522
+ * column. Guarded on the column's presence: a fresh database's `createSchema`
523
+ * no longer has it, and a re-run finds nothing to drop. `DROP COLUMN` is
524
+ * SQLite 3.35; the engine ships 3.54.
525
+ */
526
+ function migrateDropHookInjected(db: WritableDatabase): void {
527
+ if (!tableExists(db, "runtime_sessions")) return;
528
+ if (!tableColumns(db, "runtime_sessions").has("hook_injected")) return;
529
+ db.exec("ALTER TABLE runtime_sessions DROP COLUMN hook_injected");
530
+ }
531
+
517
532
  export type MigrationApply = (db: WritableDatabase) => void;
518
533
 
519
534
  export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
@@ -534,6 +549,7 @@ export const MIGRATIONS: readonly (readonly [string, MigrationApply])[] = [
534
549
  ["015_create_builder_workbench", migrateCreateBuilderWorkbench],
535
550
  ["016_builder_workbench_display_codes", migrateBuilderWorkbenchDisplayCodes],
536
551
  ["017_journey_parent_column", migrateJourneyParentColumn],
552
+ ["018_drop_hook_injected", migrateDropHookInjected],
537
553
  ];
538
554
 
539
555
  /**
@@ -84,7 +84,6 @@ CREATE TABLE IF NOT EXISTS runtime_sessions (
84
84
  mirror_active INTEGER NOT NULL DEFAULT 0,
85
85
  persona TEXT,
86
86
  journey TEXT,
87
- hook_injected INTEGER NOT NULL DEFAULT 0,
88
87
  active INTEGER NOT NULL DEFAULT 1,
89
88
  started_at TEXT NOT NULL,
90
89
  updated_at TEXT NOT NULL,
@@ -1925,13 +1925,6 @@ export const SCHEMA_INVENTORY_SNAPSHOT: SchemaInventory = {
1925
1925
  "pk": 0,
1926
1926
  "type": "TEXT"
1927
1927
  },
1928
- {
1929
- "dflt_value": "0",
1930
- "name": "hook_injected",
1931
- "notnull": 1,
1932
- "pk": 0,
1933
- "type": "INTEGER"
1934
- },
1935
1928
  {
1936
1929
  "dflt_value": "1",
1937
1930
  "name": "active",
@@ -1979,7 +1972,7 @@ export const SCHEMA_INVENTORY_SNAPSHOT: SchemaInventory = {
1979
1972
  }
1980
1973
  ],
1981
1974
  "sql":
1982
- "CREATE TABLE runtime_sessions ( session_id TEXT PRIMARY KEY, conversation_id TEXT REFERENCES conversations(id), interface TEXT, mirror_active INTEGER NOT NULL DEFAULT 0, persona TEXT, journey TEXT, hook_injected INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, metadata TEXT)"
1975
+ "CREATE TABLE runtime_sessions ( session_id TEXT PRIMARY KEY, conversation_id TEXT REFERENCES conversations(id), interface TEXT, mirror_active INTEGER NOT NULL DEFAULT 0, persona TEXT, journey TEXT, active INTEGER NOT NULL DEFAULT 1, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, metadata TEXT)"
1983
1976
  },
1984
1977
  "tasks": {
1985
1978
  "columns": [
@@ -47,6 +47,8 @@ export const KNOWN_MIGRATION_IDS: readonly string[] = [
47
47
  "016_builder_workbench_display_codes",
48
48
  // TS-authored, no Python counterpart (CV22.DS6.US2 — first TS ⊋ Python migration).
49
49
  "017_journey_parent_column",
50
+ // CV9.DS8.US5: the inject hook's once-per-session flag, without a reader since US4.
51
+ "018_drop_hook_injected",
50
52
  ];
51
53
 
52
54
  /**
@@ -17,6 +17,15 @@ const TS_ONLY_COLUMNS: Record<string, ColumnInventory[]> = {
17
17
  identity: [{ name: "parent_journey", type: "TEXT", notnull: 0, dflt_value: null, pk: 0 }],
18
18
  };
19
19
 
20
+ /**
21
+ * Columns the Python snapshot has that TS has dropped (CV9.DS8.US5, migration
22
+ * 018): `runtime_sessions.hook_injected`, the inject hook's once-per-session
23
+ * flag, without a reader since CV9.DS8.US4.
24
+ */
25
+ const TS_REMOVED_COLUMNS: Record<string, string[]> = {
26
+ runtime_sessions: ["hook_injected"],
27
+ };
28
+
20
29
  /** Indexes TS creates that the Python snapshot does not have. */
21
30
  const TS_ONLY_INDEXES: Record<string, { table: string; unique: number; columns: string[] }> = {
22
31
  idx_identity_parent_journey: { table: "identity", unique: 0, columns: ["parent_journey"] },
@@ -46,21 +55,33 @@ export function diffTsInventoryAgainstSnapshot(
46
55
  const actualTable = actual.tables[name];
47
56
  if (!actualTable) continue;
48
57
  const additions = TS_ONLY_COLUMNS[name] ?? [];
49
- const expectedColumns = [...snapshot.tables[name].columns, ...additions];
58
+ const removals = TS_REMOVED_COLUMNS[name] ?? [];
59
+ const expectedColumns = [
60
+ ...snapshot.tables[name].columns.filter((column) => !removals.includes(column.name)),
61
+ ...additions,
62
+ ];
50
63
  if (!isDeepStrictEqual(actualTable.columns, expectedColumns)) {
51
- problems.push(`tables[${name}].columns differ from snapshot + enumerated TS-only additions`);
64
+ problems.push(
65
+ `tables[${name}].columns differ from snapshot + enumerated TS-only additions and removals`,
66
+ );
52
67
  }
53
68
  if (!isDeepStrictEqual(actualTable.foreign_keys, snapshot.tables[name].foreign_keys)) {
54
69
  problems.push(`tables[${name}].foreign_keys differ from snapshot`);
55
70
  }
56
- if (additions.length > 0) {
71
+ if (additions.length > 0 || removals.length > 0) {
57
72
  // The raw CREATE TABLE text diverges by exactly these columns; assert each
58
- // added column name is present rather than reproduce SQLite's stored text.
73
+ // added column name is present and each removed one absent, rather than
74
+ // reproduce SQLite's stored text.
59
75
  for (const column of additions) {
60
76
  if (!(actualTable.sql ?? "").includes(column.name)) {
61
77
  problems.push(`tables[${name}].sql is missing TS-only column ${column.name}`);
62
78
  }
63
79
  }
80
+ for (const column of removals) {
81
+ if ((actualTable.sql ?? "").includes(column)) {
82
+ problems.push(`tables[${name}].sql still carries the dropped column ${column}`);
83
+ }
84
+ }
64
85
  } else if (!isDeepStrictEqual(actualTable.sql, snapshot.tables[name].sql)) {
65
86
  problems.push(`tables[${name}].sql differs from snapshot`);
66
87
  }