mirror-mind 0.33.4 → 0.35.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 (56) hide show
  1. package/.agents/plugins/marketplace.json +16 -0
  2. package/.pi/skills/mm-build/SKILL.md +1 -1
  3. package/.pi/skills/mm-explore/SKILL.md +1 -1
  4. package/.pi/skills/mm-help/SKILL.md +2 -2
  5. package/.pi/skills/mm-soul/SKILL.md +1 -1
  6. package/AGENTS.md +9 -8
  7. package/README.md +5 -6
  8. package/docs/releases/index.md +2 -0
  9. package/docs/releases/v0.34.0.md +142 -0
  10. package/docs/releases/v0.35.0.md +199 -0
  11. package/package.json +3 -6
  12. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  13. package/plugins/mirror-mind/agy/pre-invocation.sh +92 -0
  14. package/plugins/mirror-mind/agy/session-start.sh +92 -0
  15. package/plugins/mirror-mind/agy/stop.sh +92 -0
  16. package/plugins/mirror-mind/hooks.json +26 -0
  17. package/plugins/mirror-mind/mcp_config.json +11 -0
  18. package/plugins/mirror-mind/plugin.json +4 -0
  19. package/plugins/mirror-mind/rules/AGENTS.md +189 -0
  20. package/plugins/mirror-mind/skills/mm-build/SKILL.md +1 -1
  21. package/ts/src/conversation/agyTranscript.ts +105 -0
  22. package/ts/src/conversation/backfill.ts +13 -129
  23. package/ts/src/conversation/commandTurn.ts +42 -0
  24. package/ts/src/conversation/deferredCloseTail.ts +154 -0
  25. package/ts/src/conversation/logger.ts +104 -20
  26. package/ts/src/conversation/loggerCli.ts +6 -18
  27. package/ts/src/conversation/loggerRuntime.ts +6 -1
  28. package/ts/src/conversation/runtimePolicy.ts +59 -0
  29. package/ts/src/conversation/sessionComposites.ts +54 -8
  30. package/ts/src/docs/docsLint.ts +14 -1
  31. package/ts/src/frontDoor/routing.ts +3 -2
  32. package/ts/src/frontDoor/runtimeRoute.ts +13 -1
  33. package/ts/src/frontDoor/stdinSource.ts +2 -2
  34. package/ts/src/guards/claudePlugin.ts +113 -2
  35. package/ts/src/guards/packContents.ts +7 -6
  36. package/ts/src/guards/piSourcedSkills.ts +1 -1
  37. package/ts/src/guards/retiredSurfaces.ts +67 -0
  38. package/ts/src/hooks/agy.ts +229 -0
  39. package/ts/src/hooks/claude.ts +16 -4
  40. package/ts/src/hooks/main.ts +17 -31
  41. package/ts/src/hooks/payload.ts +8 -15
  42. package/ts/src/hooks/runtime.ts +11 -10
  43. package/ts/src/providers/config.ts +11 -0
  44. package/ts/src/runtime/diagnose.ts +115 -1
  45. package/ts/src/runtime/pluginWiring.ts +135 -0
  46. package/ts/src/runtime/update.ts +39 -6
  47. package/ts/src/runtime/wiring.ts +42 -46
  48. package/.gemini/hooks/log-assistant.sh +0 -85
  49. package/.gemini/hooks/log-user.sh +0 -85
  50. package/.gemini/hooks/session-end.sh +0 -85
  51. package/.gemini/hooks/session-start.sh +0 -85
  52. package/scripts/codex-hooks/session-end.sh +0 -85
  53. package/scripts/codex-hooks/session-start.sh +0 -85
  54. package/scripts/codex-mirror.sh +0 -48
  55. package/ts/src/hooks/codex.ts +0 -45
  56. package/ts/src/hooks/gemini.ts +0 -126
@@ -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, agy's Stop); 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 already closes without extraction at exit and
9
+ // lets 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
+ }
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
14
14
  import { join } from "node:path";
15
+ import { isCommandTurn } from "#conversation/commandTurn.ts";
16
+ import { markCloseTailPending } from "#conversation/deferredCloseTail.ts";
15
17
  import {
16
18
  lastAssistantMessageOf,
17
19
  parseHookPayloadObject,
@@ -20,6 +22,7 @@ import {
20
22
  subagentTypeOf,
21
23
  turnIdOf,
22
24
  } from "#conversation/hookPayload.ts";
25
+ import { sessionEndRunsLlmTail } from "#conversation/runtimePolicy.ts";
23
26
  import { backfillAssistantMessages } from "#conversation/transcriptBackfill.ts";
24
27
  import { type WritableDatabase, withTransaction } from "#db/database.ts";
25
28
  import { mirrorForm } from "#mirror/mirrorForm.ts";
@@ -146,7 +149,12 @@ export function getOrCreateSessionConversation(
146
149
  const boundId =
147
150
  bound && typeof bound.conversation_id === "string" ? bound.conversation_id : null;
148
151
  if (boundId) {
149
- const existing = db.prepare("SELECT id FROM conversations WHERE id = ?").get(boundId);
152
+ // Live: exists and not ended. A closed conversation is not reopened by a
153
+ // prompt (CV21.DS4.US4): a `codex resume` after the idle close starts a new
154
+ // one on the same thread id rather than appending to an extracted one.
155
+ const existing = db
156
+ .prepare("SELECT id FROM conversations WHERE id = ? AND ended_at IS NULL")
157
+ .get(boundId);
150
158
  if (existing) return boundId;
151
159
  }
152
160
 
@@ -318,17 +326,39 @@ function startConversation(
318
326
  * finalization in a `finally`. Duplicating that contract in a second async
319
327
  * close path would let the two drift on exactly the property that must not.
320
328
  */
329
+ export interface CloseTailOptions {
330
+ extract: boolean;
331
+ /**
332
+ * Run the close-time metadata finalization (title, summary, tags). Off, there
333
+ * is no LLM work at all: the conversation is flagged as owing it, immediately
334
+ * before `ended_at` so the window against a still-running async Stop logger's
335
+ * metadata write is microseconds, and the next session start's maintenance
336
+ * finishes it (CV21.DS4.TS2, `deferredCloseTail.ts`). Default on.
337
+ */
338
+ finalize?: boolean;
339
+ /**
340
+ * Runs after the state writes and before any LLM work: where `endSession`
341
+ * deactivates the session and `handleSessionEndHook` backfills the
342
+ * transcript, which the close bounds and extraction should see.
343
+ */
344
+ beforeTail?: () => void;
345
+ }
346
+
321
347
  export async function endConversation(
322
348
  db: WritableDatabase,
323
349
  conversationId: string,
324
- options: { extract: boolean },
350
+ options: CloseTailOptions,
325
351
  deps: LoggerDeps,
326
352
  hooks: CloseHooks = {},
327
353
  ): Promise<void> {
354
+ const finalize = options.finalize ?? true;
355
+ if (!finalize) markCloseTailPending(db, conversationId);
328
356
  db.prepare("UPDATE conversations SET ended_at = ? WHERE id = ?").run(
329
357
  deps.nowIso(),
330
358
  conversationId,
331
359
  );
360
+ options.beforeTail?.();
361
+ if (!finalize) return;
332
362
  try {
333
363
  if (options.extract) await hooks.runExtraction?.(db, conversationId);
334
364
  } finally {
@@ -383,19 +413,43 @@ export async function switchConversation(
383
413
  return newConversationId;
384
414
  }
385
415
 
386
- /** Python `end_session`: close the bound conversation and deactivate. */
416
+ /**
417
+ * Python `end_session`: close the bound conversation and deactivate.
418
+ *
419
+ * The session is deactivated BEFORE the tail, not after (CV21.DS4.TS2). A tail
420
+ * cut by the runtime's bound (Codex 1 s, Grok 1.5 s) or a
421
+ * background hook that dies used to leave the session `active = 1`: the row
422
+ * `guessRuntimeSessionId` reads as "the session", which is how another
423
+ * runtime's agent wrote into a Codex conversation (DS12.TS2 L9).
424
+ */
387
425
  export async function endSession(
388
426
  db: WritableDatabase,
389
427
  sessionId: string,
390
- options: { extract: boolean },
428
+ options: CloseTailOptions,
391
429
  deps: LoggerDeps,
392
430
  hooks: CloseHooks = {},
393
- ): Promise<void> {
431
+ ): Promise<boolean> {
394
432
  const runtimeSession = getRuntimeSession(db, sessionId);
395
433
  const conversationId = runtimeSession?.conversationId ?? null;
396
- if (!conversationId) return;
397
- await endConversation(db, conversationId, { extract: options.extract }, deps, hooks);
398
- upsertRuntimeSession(db, sessionId, { active: false, closedAt: deps.nowIso() }, deps.nowIso());
434
+ if (!conversationId) return false;
435
+ // A closed conversation is not closed twice (CV21.DS4.US4): Codex 0.163.0 fires
436
+ // SessionEnd when its daemon reaps the detached thread, and maintenance may have
437
+ // closed the idle conversation first. The session is deactivated; ended_at and
438
+ // the tail are left as they are.
439
+ const alreadyEnded = db
440
+ .prepare("SELECT 1 FROM conversations WHERE id = ? AND ended_at IS NOT NULL")
441
+ .get(conversationId);
442
+ if (alreadyEnded) {
443
+ upsertRuntimeSession(db, sessionId, { active: false, closedAt: deps.nowIso() }, deps.nowIso());
444
+ options.beforeTail?.();
445
+ return true;
446
+ }
447
+ const deactivate = () => {
448
+ upsertRuntimeSession(db, sessionId, { active: false, closedAt: deps.nowIso() }, deps.nowIso());
449
+ options.beforeTail?.();
450
+ };
451
+ await endConversation(db, conversationId, { ...options, beforeTail: deactivate }, deps, hooks);
452
+ return true;
399
453
  }
400
454
 
401
455
  /**
@@ -479,7 +533,7 @@ export type UserPromptHookOutcome =
479
533
  | "malformed_payload"
480
534
  | "missing_session"
481
535
  | "missing_prompt"
482
- | "slash_command"
536
+ | "command_turn"
483
537
  | "bare_mirror_form";
484
538
  }
485
539
  | { action: "logged"; sessionId: string }
@@ -564,7 +618,8 @@ function recordSkippedTurn(
564
618
  * Grok session whose first turn was a Mirror turn used to be labelled
565
619
  * `claude_code` and bound to someone else's session (DS12.TS4 L7, TS2 L9).
566
620
  * And a Mirror turn is a conversation turn: its question is logged without
567
- * the form, where every other slash command stays a command.
621
+ * the form, where every other skill invocation, in `/` or in Codex's `$`
622
+ * (CV21.DS4.TS3), stays a command.
568
623
  */
569
624
  export function handleUserPromptHook(
570
625
  db: WritableDatabase,
@@ -594,11 +649,12 @@ export function handleUserPromptHook(
594
649
  return { action: "skipped", reason: "bare_mirror_form" };
595
650
  }
596
651
  content = mirror.query;
597
- } else if (prompt.startsWith("/")) {
598
- // Slash commands never enter conversation history, and neither does
599
- // their reply: the turn is recorded so the Stop hook knows why.
652
+ } else if (isCommandTurn(prompt)) {
653
+ // A skill invocation, in `/` or in Codex's `$` (CV21.DS4.TS3), never
654
+ // enters conversation history, and neither does its reply: the turn is
655
+ // recorded so the Stop hook knows why.
600
656
  recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
601
- return { action: "skipped", reason: "slash_command" };
657
+ return { action: "skipped", reason: "command_turn" };
602
658
  }
603
659
 
604
660
  logUserMessage(
@@ -785,7 +841,13 @@ export function handleStopHook(
785
841
  export async function handleSessionEndHook(
786
842
  db: WritableDatabase,
787
843
  rawPayload: string,
788
- options: { mirrorHome: string; claudeProjectDir: string | null; homeDir: string },
844
+ options: {
845
+ mirrorHome: string;
846
+ claudeProjectDir: string | null;
847
+ homeDir: string;
848
+ /** The runtime the hook detected: the fallback when the session row has no label. */
849
+ interface?: string;
850
+ },
789
851
  deps: LoggerDeps,
790
852
  hooks: CloseHooks = {},
791
853
  ): Promise<SessionEndHookOutcome> {
@@ -794,19 +856,41 @@ export async function handleSessionEndHook(
794
856
  if (!payload) return { action: "skipped", reason: "malformed_payload" };
795
857
 
796
858
  const sessionId = payloadString(payload, "session_id");
797
- if (sessionId) await endSession(db, sessionId, { extract: true }, deps, hooks);
798
-
799
859
  const transcriptPath = resolveTranscriptPath(
800
860
  payloadString(payload, "transcript_path"),
801
861
  sessionId,
802
862
  options.claudeProjectDir,
803
863
  options.homeDir,
804
864
  );
805
- const backfilled = transcriptPath !== null && existsSync(transcriptPath);
806
- if (backfilled) backfillAssistantMessages(db, transcriptPath, deps);
865
+ let backfilled = false;
866
+ // After the close, which bounds the backfill window, and before the tail,
867
+ // which should see what the backfill adds (CV21.DS4.TS2).
868
+ const backfill = () => {
869
+ backfilled = transcriptPath !== null && existsSync(transcriptPath);
870
+ if (backfilled && transcriptPath !== null)
871
+ backfillAssistantMessages(db, transcriptPath, deps);
872
+ };
873
+
874
+ if (sessionId) {
875
+ // The label US8 stamps on every prompt; the hook's own detection only when
876
+ // the row carries none; today's behavior when neither names a runtime.
877
+ const label = getRuntimeSession(db, sessionId)?.interface || options.interface || null;
878
+ const finalize = sessionEndRunsLlmTail(label);
879
+ const closed = await endSession(
880
+ db,
881
+ sessionId,
882
+ { extract: finalize, finalize, beforeTail: backfill },
883
+ deps,
884
+ hooks,
885
+ );
886
+ // A session with no bound conversation closes nothing; the backfill still runs.
887
+ if (!closed) backfill();
888
+ } else {
889
+ backfill();
890
+ }
807
891
 
808
892
  if (sessionId) return { action: "ended", sessionId, transcriptPath, backfilled };
809
- if (backfilled) return { action: "backfilled", transcriptPath };
893
+ if (backfilled && transcriptPath !== null) return { action: "backfilled", transcriptPath };
810
894
  return { action: "skipped", reason: "nothing_to_do" };
811
895
  } catch (error) {
812
896
  return { action: "failed", error: error instanceof Error ? error.message : String(error) };
@@ -15,7 +15,6 @@
15
15
  // needs the dated zip backup from the runtime (CV22.DS7.TS1) and refuses, like
16
16
  // Python, when it is not available.
17
17
 
18
- import { backfillCodexSession } from "#conversation/backfill.ts";
19
18
  import { diagnoseJourneyAssociations, renderJourneyFindings } from "#conversation/journeyRepair.ts";
20
19
  import {
21
20
  discardCurrentConversation,
@@ -64,7 +63,6 @@ export const CONVERSATION_LOGGER_SUBCOMMANDS = [
64
63
  "session-maintenance",
65
64
  "diagnose-journeys",
66
65
  "repair-journeys",
67
- "backfill-codex-session",
68
66
  ] as const;
69
67
 
70
68
  /** The subcommands whose Python path runs `end_conversation`'s LLM close tail. */
@@ -189,7 +187,10 @@ export async function runConversationLoggerCommand(
189
187
  }
190
188
  }
191
189
 
192
- case "session-end":
190
+ case "session-end": {
191
+ // `--interface`, as `stop` takes it: the hook's own detection, used only
192
+ // when the session row carries no label (CV21.DS4.TS2).
193
+ const interfaceName = extractOption(args.slice(1), "--interface").value ?? undefined;
193
194
  await handleSessionEndHook(
194
195
  db,
195
196
  context.stdin ?? "",
@@ -197,11 +198,13 @@ export async function runConversationLoggerCommand(
197
198
  mirrorHome: home,
198
199
  claudeProjectDir: runtime.claudeProjectDir,
199
200
  homeDir: runtime.homeDir,
201
+ interface: interfaceName,
200
202
  },
201
203
  deps,
202
204
  await runtime.closeHooks(),
203
205
  );
204
206
  return ok([]);
207
+ }
205
208
 
206
209
  case "log-user":
207
210
  case "log-assistant": {
@@ -300,21 +303,6 @@ export async function runConversationLoggerCommand(
300
303
  return ok(lines);
301
304
  }
302
305
 
303
- case "backfill-codex-session": {
304
- if (args.length < 2) return ok([]);
305
- const path = args[1] as string;
306
- const count = backfillCodexSession(
307
- db,
308
- { jsonlPath: path, interface: interfaceOption(args, "codex") },
309
- deps,
310
- );
311
- return ok([
312
- count
313
- ? `Backfilled 1 Codex session from ${path}`
314
- : `No new Codex session backfilled from ${path}`,
315
- ]);
316
- }
317
-
318
306
  default:
319
307
  return ok([]);
320
308
  }