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.
- package/.pi/extensions/mirror-logger.ts +173 -46
- package/.pi/skills/mm-mirror/SKILL.md +25 -6
- package/README.md +2 -2
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.37.0.md +221 -0
- package/docs/releases/v0.38.0.md +215 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +1 -1
- package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +14 -6
- package/ts/src/builder/commands.ts +9 -6
- package/ts/src/builder/load.ts +14 -2
- package/ts/src/conversation/closeTail.ts +7 -3
- package/ts/src/conversation/extraction.ts +1 -11
- package/ts/src/conversation/logger.ts +12 -6
- package/ts/src/conversation/userName.ts +44 -0
- package/ts/src/db/migrateOnOpen.ts +1 -1
- package/ts/src/db/migrations.ts +16 -0
- package/ts/src/db/schema.ts +0 -1
- package/ts/src/db/schemaInventorySnapshot.ts +1 -8
- package/ts/src/db/schemaState.ts +2 -0
- package/ts/src/db/schemaTsDivergence.ts +25 -4
- package/ts/src/extraction/conversationMetadata.ts +53 -8
- package/ts/src/extraction/prompts.ts +8 -3
- package/ts/src/frontDoor/cli.ts +42 -2
- package/ts/src/frontDoor/exploreRoute.ts +2 -2
- package/ts/src/frontDoor/mirrorModeRoute.ts +129 -5
- package/ts/src/frontDoor/routing.ts +16 -15
- package/ts/src/frontDoor/runtimeRoute.ts +4 -0
- package/ts/src/frontDoor/soulRoute.ts +4 -4
- package/ts/src/guards/testTempHomes.ts +92 -0
- package/ts/src/hooks/agy.ts +136 -59
- package/ts/src/hooks/claude.ts +29 -90
- package/ts/src/hooks/mirrorState.ts +10 -22
- package/ts/src/mirror/context.ts +19 -4
- package/ts/src/mirror/defaultResolution.ts +2 -1
- package/ts/src/mirror/doorman.ts +420 -0
- package/ts/src/mirror/orchestration.ts +22 -3
- package/ts/src/mirror/render.ts +7 -1
- package/ts/src/mirror/runtimeSession.ts +66 -8
- package/ts/src/mode/operatingMode.ts +22 -4
- package/ts/src/providers/config.ts +105 -1
- package/ts/src/providers/cost.ts +5 -1
- package/ts/src/providers/llm.ts +40 -0
- package/ts/src/providers/transport.ts +14 -0
- package/ts/src/runtime/diagnose.ts +64 -7
- package/ts/src/runtime/pluginWiring.ts +6 -4
- package/ts/src/runtime/status.ts +24 -4
- package/ts/src/runtime/update.ts +17 -1
- package/ts/src/runtime/wiring.ts +14 -1
- package/ts/src/search/memorySearch.ts +38 -18
- 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
|
@@ -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
|
-
>
|
|
16
|
-
>
|
|
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
|
-
> **
|
|
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.
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* only
|
|
212
|
-
*
|
|
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
|
|
222
|
+
return state?.mode === "Builder Mode" && state.journey && state.namedSession === true
|
|
223
|
+
? state.journey
|
|
224
|
+
: null;
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
/**
|
package/ts/src/builder/load.ts
CHANGED
|
@@ -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
|
-
{
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
177
|
-
//
|
|
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
|
-
|
|
182
|
-
VALUES (?, ?, ?, 0, ?, ?,
|
|
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
|
|
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[] {
|
package/ts/src/db/migrations.ts
CHANGED
|
@@ -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
|
/**
|
package/ts/src/db/schema.ts
CHANGED
|
@@ -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,
|
|
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": [
|
package/ts/src/db/schemaState.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
}
|