mirror-mind 0.33.4 → 0.34.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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "mirror-mind",
3
+ "owner": {
4
+ "name": "Mirror Mind"
5
+ },
6
+ "plugins": [
7
+ {
8
+ "name": "mirror-mind",
9
+ "source": {
10
+ "source": "local",
11
+ "path": "./plugins/mirror-mind"
12
+ },
13
+ "description": "Mirror Mind — local-first memory and identity for agentic runtimes."
14
+ }
15
+ ]
16
+ }
package/AGENTS.md CHANGED
@@ -18,10 +18,10 @@ The mirror operates in four modes, chosen automatically based on context:
18
18
  Mirror, Builder, Explorer, and Soul.
19
19
 
20
20
  Each skill below is named three ways: `/mm-<skill>` in Pi and Gemini CLI,
21
- `$mm-<skill>` in Codex, `/mm:<skill>` in Claude Code. A Claude Code **plugin**
22
- install prefixes the plugin's name — `/mirror-mind:mm:<skill>` in the menu,
23
- `mirror-mind:mm-<skill>` as the skill's own name — and the forms below refer to
24
- the same skill.
21
+ `$mm-<skill>` in Codex, `/mm:<skill>` in Claude Code. A **plugin** install
22
+ prefixes the plugin's name — `/mirror-mind:mm:<skill>` in Claude Code's menu,
23
+ `$mirror-mind:mm:<skill>` in Codex, `mirror-mind:mm-<skill>` as the skill's own
24
+ name — and the forms below refer to the same skill.
25
25
 
26
26
  **Mirror Mode** — activate for: life decisions, feelings, business strategy,
27
27
  writing, mentoring, health, existential questions, sensemaking, psychological
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [v0.34.0 — Codex Through the Canonical Plugin](v0.34.0.md) — minor release closing CV21.DS4: Codex runs Mirror through the canonical plugin, installed in two commands from a marketplace the package ships, measured live at L4 on Codex 0.163.0. The v0.32.1 wrapper wiring no longer ships; `runtime diagnose` and `runtime update` say when Codex's cached plugin copy is behind and name the re-add that brings it level. No migration.
15
16
  - [v0.33.4 — A Name Is a Key](v0.33.4.md) — patch release of three first-run corrections: two from a contributor's PR #43 and CR141, which its review opened. `init --help` created a home named `--help`, `init ../escaped` created one outside `.mirror-minds`, and `init "My User"` put a space into `MIRROR_USER`; a user name is now a kebab-case key like a journey slug or a persona id, refused otherwise with a name that would work and nothing created. `identity edit` on Windows opens Notepad when no editor is named. Existing homes untouched. No migration.
16
17
  - [v0.33.3 — Mirror Finds Itself Under Any Path](v0.33.3.md) — patch release of one Change Request, CR140, carried by a contributor's PR #42: Mirror read its own install path from `URL.pathname`, so a checkout or package under a path with a space or a non-ASCII character read as `Install: unknown`, the welcome card showed no update, and `runtime update` refused. The path is now a filesystem path, a guard keeps the idiom out, and an `unknown` install names the path it looked up from. On Windows, npm's global layout is recognized and npm runs with no shell, verified on Windows in CI for the two files they touch; there is still no Windows product. Affected installs update once by hand. No migration.
17
18
  - [v0.33.2 — Git Runs Where Mirror Lives](v0.33.2.md) — patch release of one Change Request, CR138: on a clone install, `runtime status`, `version`, and `diagnose` describe the install from any directory, and `diagnose` grades Mirror's own tree. Git now runs only in the install's own checkout, so a checkout someone else made can no longer run a `core.fsmonitor` command through these commands; `version --start PATH` is the one explicit exception. Package installs were never exposed. No migration.
@@ -0,0 +1,142 @@
1
+ ---
2
+ digest: >
3
+ v0.34.0 is a minor release closing CV21.DS4: Codex runs Mirror through the
4
+ canonical plugin, the same one Claude Code loads. The package ships a Codex
5
+ marketplace; two commands install the plugin, Codex asks once to trust its
6
+ hooks, and from then on every session carries the skills, the Operating
7
+ Instructions, per-turn Mirror Mode injection, the MCP server, and logging as
8
+ `codex` with every reply beside its prompt — measured live at L4 on Codex
9
+ 0.163.0. The v0.32.1 wrapper wiring is removed: `scripts/codex-mirror.sh`,
10
+ its hooks, and the JSONL backfill no longer ship. Because Codex copies the
11
+ plugin into a versioned cache, `runtime diagnose` and `runtime update` now
12
+ say when that copy is behind and name the one command that brings it level.
13
+ No migration.
14
+ ---
15
+
16
+ # v0.34.0 — Codex Through the Canonical Plugin
17
+
18
+ **Date:** 2026-10-17
19
+
20
+ ## Highlights
21
+
22
+ - **Codex installs Mirror as a plugin, from a marketplace the package ships**
23
+ (CV21.DS4.US1). Two commands — `codex plugin marketplace add "<pkg>"`, then
24
+ `codex plugin add mirror-mind@mirror-mind` — and Codex asks once, at the
25
+ next session, to trust the plugin's hooks. `mirror init` prints exactly
26
+ these steps when it finds `codex` on the PATH.
27
+ - **Codex is at L4, measured live** (CV21.DS4.US4, on Codex 0.163.0): all 25
28
+ skills, the Operating Instructions as a developer message, Mirror Mode on
29
+ an ordinary turn and on `$mirror-mind:mm:mirror`, the 7 MCP tools behind
30
+ Codex's `tool_search`, and every prompt and reply logged as `codex`. Codex
31
+ detaches the TUI on `/quit` and fires SessionEnd only when its daemon reaps
32
+ the idle thread, so Mirror gained a safety net for sessions that outlive
33
+ their TUI: session maintenance closes them as orphans
34
+ (`MIRROR_STALE_ORPHAN_MINUTES`, default 30).
35
+ - **A bounded SessionEnd no longer cuts extraction** (CV21.DS4.TS2). Codex
36
+ bounds the hook at 1 s; the close now runs no LLM work, and the next
37
+ session start finishes the deferred tail. Grok's 1.5 s bound rides the same
38
+ change.
39
+ - **`$`-skill turns are command turns** (CV21.DS4.TS3), as `/`-commands are
40
+ on every other runtime, with Mirror's explicit form the one exception.
41
+ - **The v0.32.1 Codex wiring is removed** (CV21.DS4.TS5). What no longer
42
+ exists: `scripts/codex-mirror.sh`, `scripts/codex-hooks/`, the
43
+ `codex:session-*` hook entries, and
44
+ `conversation-logger backfill-codex-session` — the plugin logs Codex turns
45
+ as they happen, so nothing re-imports the transcript at exit. A
46
+ retired-surfaces guard keeps the wiring from drifting back.
47
+ - **An updated Mirror now reaches Codex's plugin cache — or says so**
48
+ (CV21.DS4.TS6). Codex copies the plugin into its cache at a version, so an
49
+ npm update changes the install but not the copy Codex runs. `runtime
50
+ diagnose` reports `codex_plugin_cache_behind` naming both versions and the
51
+ measured command — `codex plugin add mirror-mind@mirror-mind`; re-adding
52
+ refreshes the cache in place, no remove needed — and a successful `runtime
53
+ update` prints the same line. A dangling Claude Code skills link is
54
+ reported too (`claude_plugin_link_dangling`). Absence is not drift: an
55
+ unwired machine stays green.
56
+
57
+ ## Where We Started
58
+
59
+ v0.33.0 proved one plugin works as it is on Claude Code, Codex, and Grok.
60
+ But a Codex user still wired Mirror by hand: a skills link, an `AGENTS.md`
61
+ link, and a wrapper script around every session that imported the transcript
62
+ from JSONL at exit — v0.32.1's wiring, built when Codex had no hook system.
63
+ Codex 0.157 added plugins; DS4 asked whether the canonical plugin could be
64
+ the whole answer, and set a gate: the old wiring retires only once parity is
65
+ shown live.
66
+
67
+ ## What Changed
68
+
69
+ **The install is two commands.** The npm package root carries
70
+ `.agents/plugins/marketplace.json`, one local plugin — the same
71
+ `plugins/mirror-mind` Claude Code loads. Codex copies it into its cache at
72
+ the plugin's version and runs its Claude-family hooks as they are; runtime
73
+ detection reads Codex's `turn_id` and labels the session `codex`.
74
+
75
+ **The gate was met, so the old path is gone.** US4 measured L4 twice, live,
76
+ through the shipped plugin alone. TS5 then removed the wrapper, its generated
77
+ hooks, the `codex:*` hook family, the JSONL backfill (its only caller was the
78
+ wrapper's exit hook; the plugin's SessionEnd closes without reading it),
79
+ their pack-contents guards, and their CI step. The docs name the marketplace
80
+ install and nothing else.
81
+
82
+ **The one stale copy is watched.** Claude Code reads the plugin through a
83
+ link, in place; Codex runs a versioned copy. TS6 taught `runtime diagnose`
84
+ and `runtime update`'s post-update hints to compare that copy against the
85
+ installed version — the landed version, read from the just-updated tree —
86
+ and to print the one command that works on Codex 0.163.0, which has no
87
+ plugin-update verb: the re-add.
88
+
89
+ ## Upgrading
90
+
91
+ ```bash
92
+ npm install -g mirror-mind@latest # or: mirror runtime update
93
+ ```
94
+
95
+ **If Codex runs the plugin already:** after this update (and any future one),
96
+ bring the cache level once:
97
+
98
+ ```bash
99
+ codex plugin add mirror-mind@mirror-mind
100
+ ```
101
+
102
+ `mirror runtime diagnose` will tell you whenever this is needed.
103
+
104
+ **If you wired Codex before the plugin** (a skills link, an `AGENTS.md` link,
105
+ the wrapper script): install the plugin with the two commands above, then
106
+ undo the old wiring — the exact steps are in Getting Started's Codex section.
107
+ If you do nothing, everything still works, at the cost of doubled skills and
108
+ double logging where the old wrapper still runs; an older installed package
109
+ keeps its own wrapper copy, but this version ships none.
110
+
111
+ No migration. Existing homes, sessions, and memories are untouched.
112
+
113
+ ## Conscious Exclusions
114
+
115
+ - **Codex logging is no longer end-to-end tested**, by decision: the retired
116
+ wrapper smoke was the only artifact-level Codex check. Confidence now rests
117
+ on the shared Claude-family hook path (smoked), runtime-detection units,
118
+ and US4's live measurement.
119
+ - **Mirror never runs `codex` itself.** The cache readers are read-only
120
+ filesystem checks; the person runs the printed command.
121
+ - **Two named silences in the wiring checks:** a cache *ahead* of the install
122
+ (a deliberate downgrade gets no advice from the older tree), and a Claude
123
+ link that resolves to another install tree (healthy means the target
124
+ reads). Both are recorded as decisions with tests.
125
+ - **CR142 is captured, not fixed:** the Claude plugin smoke's negative probe
126
+ fails on a machine with a globally installed `mirror-hook` — a smoke
127
+ isolation bug found during TS5's validation, pre-existing, filed under
128
+ RS010.
129
+
130
+ ## What We Learned
131
+
132
+ The gate ordering did the work: retiring the old wiring only after L4 was
133
+ measured live meant the removal was a deletion, not a bet. And the one real
134
+ surprise of the arc — Codex sessions outlive their TUI — was caught by the
135
+ live measurement, not by any test, which is why the measurement is part of
136
+ the method.
137
+
138
+ ## Next Horizon
139
+
140
+ DS4 closes with this release. CV21's remaining runtime stories — Antigravity
141
+ through a native layer (DS7), Grok (DS10) — are pulled deliberately from the
142
+ CV21 index.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.33.4",
3
+ "version": "0.34.0",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Gemini CLI, Codex)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,7 @@
38
38
  ".claude/hooks",
39
39
  ".gemini/hooks",
40
40
  "plugins/mirror-mind",
41
- "scripts/codex-hooks",
42
- "scripts/codex-mirror.sh",
41
+ ".agents/plugins",
43
42
  "AGENTS.md",
44
43
  "README.md",
45
44
  "LICENSE"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.33.4",
3
+ "version": "0.34.0",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,20 +1,17 @@
1
1
  // Session backfills from runtime-written JSONL (CV22.DS7.US10 slice E).
2
2
  //
3
- // Ports `backfill_pi_sessions` and `backfill_codex_session` from
4
- // `src/memory/cli/conversation_logger.py`. Both import a closed session the
5
- // live hooks never saw, through the atomic seam in `sessionImport.ts`, and
6
- // both are graded against `ts/test/goldens/backfill.golden.json`.
3
+ // Ports `backfill_pi_sessions` from `src/memory/cli/conversation_logger.py`:
4
+ // it imports closed sessions the live hooks never saw, through the atomic
5
+ // seam in `sessionImport.ts`, and is graded against
6
+ // `ts/test/goldens/backfill.golden.json`. (The Codex importer,
7
+ // `backfill_codex_session`, was ported beside it and retired with the
8
+ // v0.32.1 Codex wrapper by CV21.DS4.TS5: its only caller was the wrapper's
9
+ // session-end hook, and the plugin logs Codex turns live.)
7
10
  //
8
- // The two are deliberately NOT unified beyond that seam, because Python's
9
- // asymmetries are the contract:
10
- // * Pi titles through `set_provisional_title` (whitespace collapse plus
11
- // `title_source`/`title_status` provenance in metadata); Codex titles
12
- // through `_generate_title` and a bare column write with no provenance.
13
- // * Pi tolerates a corrupt or structurally invalid file by skipping THAT
14
- // file and continuing the walk; Codex gives up on its single file.
15
- // * Pi parses every timestamp form (millisecond epoch, string, absent);
16
- // Codex stores the raw `timestamp` value and lets the model reject a
17
- // missing one.
11
+ // Pi titles through `set_provisional_title` (whitespace collapse plus
12
+ // `title_source`/`title_status` provenance in metadata), tolerates a corrupt
13
+ // or structurally invalid file by skipping THAT file and continuing the
14
+ // walk, and parses every timestamp form (millisecond epoch, string, absent).
18
15
  //
19
16
  // Nothing here touches a provider or the clock directly: `now` is injected
20
17
  // through `LoggerDeps`, which is what makes "no timestamp -> now" pinnable.
@@ -24,12 +21,11 @@
24
21
  // home meant the person's whole Pi history, copied and spent on before the
25
22
  // first prompt. The floor is explicit (`notBefore`), never defaulted, and
26
23
  // the rule fails closed: there is no value that means "everything" except
27
- // the epoch, which says so. The Codex importer is untouched: it imports the
28
- // one session the wrapper names, not a history.
24
+ // the epoch, which says so.
29
25
 
30
26
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
31
27
  import { join, relative, sep } from "node:path";
32
- import { generateTitle, type LoggerDeps, setProvisionalTitle } from "#conversation/logger.ts";
28
+ import { type LoggerDeps, setProvisionalTitle } from "#conversation/logger.ts";
33
29
  import { type ImportedMessage, importClosedConversation } from "#conversation/sessionImport.ts";
34
30
  import type { WritableDatabase } from "#db/database.ts";
35
31
  import { expandHome } from "#util/paths.ts";
@@ -76,13 +72,6 @@ function getFrom(container: unknown, key: string): unknown {
76
72
  return getOr(container, key, undefined);
77
73
  }
78
74
 
79
- /** Python truthiness for JSON values: empty containers are falsy too. */
80
- function pythonTruthy(value: unknown): boolean {
81
- if (Array.isArray(value)) return value.length > 0;
82
- if (isRecord(value)) return Object.keys(value).length > 0;
83
- return Boolean(value);
84
- }
85
-
86
75
  /** Python `_parse_pi_content`: a string as-is, text blocks joined by `\n`, else `""`. */
87
76
  export function parsePiContent(content: unknown): string {
88
77
  if (typeof content === "string") return content;
@@ -280,108 +269,3 @@ export function backfillPiSessions(
280
269
  }
281
270
  return count;
282
271
  }
283
-
284
- interface CodexParse {
285
- sessionId: string | null;
286
- messages: { role: string; content: unknown; createdAt: unknown }[];
287
- }
288
-
289
- /**
290
- * Parse a Codex session file. Throws on the first line that is not valid
291
- * JSON or not the expected shape; Python's single `try` turns that into
292
- * "return 0". Values are collected raw: Python validates them only when the
293
- * import constructs its models.
294
- */
295
- function parseCodexSessionFile(path: string): CodexParse {
296
- const parsed: CodexParse = { sessionId: null, messages: [] };
297
- for (const line of readFileSync(path, "utf8").split(/\r?\n|\r/)) {
298
- if (!line.trim()) continue;
299
- const entry: unknown = JSON.parse(line);
300
- const entryType = getFrom(entry, "type");
301
- const payload = getOr(entry, "payload", {});
302
- if (entryType === "session_meta") {
303
- // Codex writes a UUID string; a non-string id is treated as absent
304
- // (the same simplification the hook payloads make).
305
- const id = getFrom(payload, "id");
306
- parsed.sessionId = typeof id === "string" ? id : null;
307
- } else if (entryType === "event_msg") {
308
- const payloadType = getFrom(payload, "type");
309
- if (payloadType === "user_message" || payloadType === "agent_message") {
310
- const content = getOr(payload, "message", "");
311
- // Python: `if content:` -- any truthy value is kept and validated later.
312
- if (pythonTruthy(content)) {
313
- parsed.messages.push({
314
- role: payloadType === "user_message" ? "user" : "assistant",
315
- content,
316
- createdAt: getFrom(entry, "timestamp"),
317
- });
318
- }
319
- }
320
- }
321
- }
322
- return parsed;
323
- }
324
-
325
- export interface CodexBackfillOptions {
326
- jsonlPath: string;
327
- interface?: string;
328
- }
329
-
330
- /**
331
- * Python `backfill_codex_session`: import one Codex session file as a closed
332
- * conversation titled through `_generate_title`. Returns 1 on import, else 0.
333
- *
334
- * Throws when a kept event has a non-string `message` or `timestamp`: Python's
335
- * `Message(...)` model rejects those inside the import transaction, so the
336
- * database is left untouched and the caller sees the failure.
337
- */
338
- export function backfillCodexSession(
339
- db: WritableDatabase,
340
- options: CodexBackfillOptions,
341
- deps: LoggerDeps,
342
- ): number {
343
- const jsonlPath = expandHome(options.jsonlPath);
344
- if (!existsSync(jsonlPath) || !statSync(jsonlPath).isFile()) return 0;
345
-
346
- let parsed: CodexParse;
347
- try {
348
- parsed = parseCodexSessionFile(jsonlPath);
349
- } catch {
350
- return 0;
351
- }
352
- if (!parsed.sessionId || parsed.messages.length === 0) return 0;
353
- if (db.prepare("SELECT 1 FROM runtime_sessions WHERE session_id = ?").get(parsed.sessionId)) {
354
- return 0;
355
- }
356
-
357
- const messages: ImportedMessage[] = parsed.messages.map((message) => {
358
- if (typeof message.content !== "string") {
359
- throw new TypeError("Codex event message must be a string");
360
- }
361
- if (typeof message.createdAt !== "string") {
362
- throw new TypeError("Codex event timestamp must be a string");
363
- }
364
- return { role: message.role, content: message.content, createdAt: message.createdAt };
365
- });
366
-
367
- const conversationId = importClosedConversation(
368
- db,
369
- parsed.sessionId,
370
- {
371
- interface: options.interface ?? "codex",
372
- messages,
373
- endedAt: (messages.at(-1) as ImportedMessage).createdAt,
374
- },
375
- deps,
376
- );
377
- if (conversationId === null) return 0;
378
-
379
- const firstUser = messages.find((message) => message.role === "user");
380
- if (firstUser) {
381
- db.prepare("UPDATE conversations SET title = ? WHERE id = ?").run(
382
- generateTitle(firstUser.content),
383
- conversationId,
384
- );
385
- }
386
- return 1;
387
- }
@@ -0,0 +1,42 @@
1
+ // A command turn: a prompt that opens with a skill invocation, in any runtime's
2
+ // sigil (CV21.DS4.TS3).
3
+ //
4
+ // The prompt hook skips a command turn and records it, so the Stop hook skips
5
+ // its reply too (`command_turn`, CV21.DS2.US8): a skill's rendering -- a
6
+ // journey table, help text -- is not conversation, and extraction reading it
7
+ // as if the person said it is noise. What you say to the mirror is remembered;
8
+ // a skill you invoke is not. The one exception is the Mirror skill's explicit
9
+ // form, whose question IS the conversation turn (`mirrorForm.ts`); the prompt
10
+ // hook checks that form first, and a test holds every Mirror form to be a
11
+ // command turn, so the exception always sits inside this rule.
12
+ //
13
+ // The two sides are deliberately asymmetric:
14
+ //
15
+ // `/` skips ANYTHING. Every runtime uses `/` for its own built-ins as well as
16
+ // for skills (`/hooks`, `/mcp`, `/compact`), so a prompt opening with it
17
+ // is a command whatever follows. This is the rule US8 inherited.
18
+ // `$` skips only a SKILL NAME. `$` is Codex's skill sigil and nothing else
19
+ // (`$mirror-mind:mm:journeys` through the plugin, DS12.TS2 L10;
20
+ // `$mm-journeys` through a skills link), and `$5 a month` is English.
21
+ // The name is lowercase segments -- letters, digits, hyphens, and
22
+ // underscores after the first letter, since Codex discovers
23
+ // `~/.codex/skills/<dir>` by directory name -- with an optional
24
+ // plugin-name prefix joined by `:`, ending at whitespace or the end of
25
+ // the prompt, the boundary `mirrorForm` draws. A comma is not a boundary
26
+ // and a leading space is not a prompt start, as ` /help` is not a
27
+ // command today.
28
+ //
29
+ // One rule for every runtime: a Claude Code user who types `$mm-journeys`,
30
+ // which Claude Code does not understand, is still invoking a skill, and that
31
+ // turn is not conversation either. The rule has one home for every hook that
32
+ // runs in our process (the Claude-family logger, the Gemini hook); the Pi
33
+ // extension, a separate process in Pi's file, keeps its own `/` check.
34
+
35
+ const SKILL_SEGMENT = "[a-z][a-z0-9_-]*";
36
+ const DOLLAR_SKILL_FORM = new RegExp(`^\\$${SKILL_SEGMENT}(?::${SKILL_SEGMENT})*(?=\\s|$)`);
37
+
38
+ /** Whether the prompt opens with a skill invocation in any runtime's sigil. */
39
+ export function isCommandTurn(prompt: string): boolean {
40
+ if (prompt.startsWith("/")) return true;
41
+ return DOLLAR_SKILL_FORM.test(prompt);
42
+ }
@@ -0,0 +1,154 @@
1
+ // The deferred close tail (CV21.DS4.TS2).
2
+ //
3
+ // A session's close tail is two things: the state writes (`ended_at`, the
4
+ // runtime session inactive, the transcript backfill) and the LLM work
5
+ // (extraction, then title/summary/tags). Some runtimes give a SessionEnd hook
6
+ // less time than one model call: Codex runs it synchronously under a 1 s
7
+ // default (DS12.TS2 L2; DS2's live `/hooks` table), Grok under 1.5 s
8
+ // (DS12.TS4). Pi and Gemini CLI already close without extraction at exit and
9
+ // let the next session start extract (`session-end-pi`). On a runtime that
10
+ // bounds the hook, the shared Claude-family `session-end` does the same, and a
11
+ // little more: it also skips the metadata finalization, and flags the
12
+ // conversation so the next start's maintenance finishes it.
13
+ //
14
+ // The flag lives in `conversations.metadata` beside `extracted`,
15
+ // `extraction_status`, and `extraction_quarantined`: same table, same
16
+ // `json_extract` predicate, no migration. It is written and cleared through
17
+ // the two helpers here and nowhere else, read-modify-write on the blob, and
18
+ // cleared by deleting the key: the sibling predicates read `IS 1`/`IS NOT 1`,
19
+ // and a `false` left behind is noise every later reader has to think about.
20
+ //
21
+ // The maintenance step runs AFTER `extractPending`, so a conversation's stored
22
+ // summary is kept by `finalizeMetadataOnClose` ("summary already exists")
23
+ // rather than generated twice, and takes only conversations already extracted
24
+ // or ineligible for extraction: one still waiting on the extraction budget is
25
+ // left, flag intact, so finalization never precedes its extraction. It is
26
+ // bounded like its siblings (`RETITLE_LIMIT`, the extraction budget), FIFO by
27
+ // `ended_at`, and carries the rest over. Two runtimes started at once both run
28
+ // maintenance with no lock and can both pick the same conversation; that race
29
+ // is `extractPending`'s and `retitlePending`'s today, and is not solved here.
30
+
31
+ import { metadataDict } from "#conversation/closeTail.ts";
32
+ import type { WritableDatabase } from "#db/database.ts";
33
+ import { pythonJsonDumps } from "#util/pyGenerators.ts";
34
+
35
+ // The policy that decides which runtimes defer the tail lives in
36
+ // `runtimePolicy.ts` (`sessionEndRunsLlmTail`), beside its sibling for sessions
37
+ // that outlive the TUI (US4).
38
+
39
+ export const CLOSE_TAIL_PENDING_KEY = "close_tail_pending";
40
+
41
+ /** The sibling budgets: extraction 10 per run, retitle 5. Finalization is up to three calls. */
42
+ export const DEFERRED_CLOSE_TAIL_LIMIT = 5;
43
+
44
+ function readMetadata(
45
+ db: WritableDatabase,
46
+ conversationId: string,
47
+ ): Record<string, unknown> | null {
48
+ const row = db.prepare("SELECT metadata FROM conversations WHERE id = ?").get(conversationId);
49
+ if (!row) return null;
50
+ return metadataDict(typeof row.metadata === "string" ? row.metadata : null);
51
+ }
52
+
53
+ function writeMetadata(
54
+ db: WritableDatabase,
55
+ conversationId: string,
56
+ metadata: Record<string, unknown>,
57
+ ): void {
58
+ db.prepare("UPDATE conversations SET metadata = ? WHERE id = ?").run(
59
+ pythonJsonDumps(metadata),
60
+ conversationId,
61
+ );
62
+ }
63
+
64
+ /** Flag the conversation as owing its finalization. A missing conversation is left alone. */
65
+ export function markCloseTailPending(db: WritableDatabase, conversationId: string): void {
66
+ const metadata = readMetadata(db, conversationId);
67
+ if (metadata === null) return;
68
+ metadata[CLOSE_TAIL_PENDING_KEY] = true;
69
+ writeMetadata(db, conversationId, metadata);
70
+ }
71
+
72
+ /** Clear the flag by deleting the key, never by writing `false`. */
73
+ export function clearCloseTailPending(db: WritableDatabase, conversationId: string): void {
74
+ const metadata = readMetadata(db, conversationId);
75
+ if (metadata === null || !(CLOSE_TAIL_PENDING_KEY in metadata)) return;
76
+ delete metadata[CLOSE_TAIL_PENDING_KEY];
77
+ writeMetadata(db, conversationId, metadata);
78
+ }
79
+
80
+ export function isCloseTailPending(db: WritableDatabase, conversationId: string): boolean {
81
+ return readMetadata(db, conversationId)?.[CLOSE_TAIL_PENDING_KEY] === true;
82
+ }
83
+
84
+ const PENDING_WHERE = `
85
+ WHERE c.ended_at IS NOT NULL
86
+ AND json_extract(c.metadata, '$.${CLOSE_TAIL_PENDING_KEY}') IS 1
87
+ `;
88
+
89
+ /**
90
+ * Already extracted, or not eligible for extraction (the orchestration's own
91
+ * gate: a journey and four messages), so finalization follows extraction and
92
+ * never precedes it.
93
+ */
94
+ const READY_WHERE = `
95
+ AND (json_extract(c.metadata, '$.extracted') IS 1
96
+ OR c.journey IS NULL
97
+ OR (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) < 4)
98
+ `;
99
+
100
+ export interface DeferredCloseTail {
101
+ id: string;
102
+ endedAt: string;
103
+ }
104
+
105
+ /** Flagged conversations ready to finalize, oldest-ended first, bounded. */
106
+ export function selectDeferredCloseTails(
107
+ db: WritableDatabase,
108
+ limit: number | null,
109
+ ): DeferredCloseTail[] {
110
+ const base = `SELECT c.id, c.ended_at FROM conversations c${PENDING_WHERE}${READY_WHERE}ORDER BY c.ended_at ASC`;
111
+ const rows = limit === null ? db.prepare(base).all() : db.prepare(`${base} LIMIT ?`).all(limit);
112
+ return rows.map((row) => ({ id: String(row.id), endedAt: String(row.ended_at) }));
113
+ }
114
+
115
+ /** Every flagged conversation, ready or not, and the oldest: what `runtime diagnose` reads. */
116
+ export function countDeferredCloseTails(db: WritableDatabase): {
117
+ count: number;
118
+ oldestEndedAt: string | null;
119
+ } {
120
+ const row = db
121
+ .prepare(`SELECT COUNT(*) AS n, MIN(c.ended_at) AS oldest FROM conversations c${PENDING_WHERE}`)
122
+ .get();
123
+ const count = Number(row?.n ?? 0);
124
+ return { count, oldestEndedAt: count > 0 && row?.oldest ? String(row.oldest) : null };
125
+ }
126
+
127
+ export interface FinishDeferredCloseTailsDeps {
128
+ /** The close-time finalization (title, summary, tags). Throwing is isolated. */
129
+ finalizeCloseTail: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
130
+ limit?: number;
131
+ }
132
+
133
+ /**
134
+ * The maintenance step: finalize each ready, flagged conversation and clear
135
+ * its flag. A finalization that throws leaves its flag for the next run and
136
+ * does not stop the loop. Returns how many were finished.
137
+ */
138
+ export async function finishDeferredCloseTails(
139
+ db: WritableDatabase,
140
+ deps: FinishDeferredCloseTailsDeps,
141
+ ): Promise<number> {
142
+ const limit = deps.limit ?? DEFERRED_CLOSE_TAIL_LIMIT;
143
+ let count = 0;
144
+ for (const tail of selectDeferredCloseTails(db, limit)) {
145
+ try {
146
+ await deps.finalizeCloseTail(db, tail.id);
147
+ } catch {
148
+ continue;
149
+ }
150
+ clearCloseTailPending(db, tail.id);
151
+ count += 1;
152
+ }
153
+ return count;
154
+ }