mirror-mind 0.33.3 → 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.
- package/.agents/plugins/marketplace.json +16 -0
- package/AGENTS.md +4 -4
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.4.md +104 -0
- package/docs/releases/v0.34.0.md +142 -0
- package/package.json +2 -3
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/ts/src/conversation/backfill.ts +13 -129
- package/ts/src/conversation/commandTurn.ts +42 -0
- package/ts/src/conversation/deferredCloseTail.ts +154 -0
- package/ts/src/conversation/logger.ts +104 -20
- package/ts/src/conversation/loggerCli.ts +6 -18
- package/ts/src/conversation/loggerRuntime.ts +6 -1
- package/ts/src/conversation/runtimePolicy.ts +50 -0
- package/ts/src/conversation/sessionComposites.ts +54 -8
- package/ts/src/frontDoor/cli.ts +12 -2
- package/ts/src/frontDoor/routing.ts +3 -2
- package/ts/src/frontDoor/runtimeRoute.ts +13 -1
- package/ts/src/guards/claudePlugin.ts +39 -2
- package/ts/src/guards/packContents.ts +4 -4
- package/ts/src/guards/retiredSurfaces.ts +36 -0
- package/ts/src/hooks/claude.ts +16 -4
- package/ts/src/hooks/gemini.ts +3 -1
- package/ts/src/hooks/main.ts +6 -16
- package/ts/src/identity/identityEdit.ts +11 -2
- package/ts/src/identity/identityKey.ts +18 -2
- package/ts/src/providers/config.ts +11 -0
- package/ts/src/runtime/diagnose.ts +115 -1
- package/ts/src/runtime/pluginWiring.ts +135 -0
- package/ts/src/runtime/update.ts +39 -6
- package/ts/src/runtime/wiring.ts +24 -10
- package/scripts/codex-hooks/session-end.sh +0 -85
- package/scripts/codex-hooks/session-start.sh +0 -85
- package/scripts/codex-mirror.sh +0 -48
- package/ts/src/hooks/codex.ts +0 -45
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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, Gemini CLI 30 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:
|
|
428
|
+
options: CloseTailOptions,
|
|
391
429
|
deps: LoggerDeps,
|
|
392
430
|
hooks: CloseHooks = {},
|
|
393
|
-
): Promise<
|
|
431
|
+
): Promise<boolean> {
|
|
394
432
|
const runtimeSession = getRuntimeSession(db, sessionId);
|
|
395
433
|
const conversationId = runtimeSession?.conversationId ?? null;
|
|
396
|
-
if (!conversationId) return;
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
| "
|
|
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
|
|
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
|
|
598
|
-
//
|
|
599
|
-
//
|
|
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: "
|
|
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: {
|
|
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
|
-
|
|
806
|
-
|
|
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
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// that cross the LLM close tail -- the replay providers and the maintenance
|
|
8
8
|
// dependencies built on them.
|
|
9
9
|
//
|
|
10
|
-
// The providers are loaded lazily. `diagnose-journeys
|
|
10
|
+
// The providers are loaded lazily. `diagnose-journeys`
|
|
11
11
|
// or `session-start --fast` must never fail because a replay fixture is not
|
|
12
12
|
// configured, and `switch` or `session-end` must fail loudly (and fall back
|
|
13
13
|
// to Python at the routing layer) when it is not: "unconfigured installs keep
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
resolveExtractionMaxAttempts,
|
|
30
30
|
resolveExtractionModel,
|
|
31
31
|
resolveMaintenanceMaxExtractions,
|
|
32
|
+
resolveStaleOrphanMinutes,
|
|
32
33
|
resolveSummarizeEnabled,
|
|
33
34
|
resolveTwoPassEnabled,
|
|
34
35
|
} from "#providers/config.ts";
|
|
@@ -242,6 +243,9 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
242
243
|
source: "startup_maintenance",
|
|
243
244
|
}),
|
|
244
245
|
runExtraction,
|
|
246
|
+
// CV21.DS4.TS2: the finalization a bounded runtime's SessionEnd deferred.
|
|
247
|
+
finalizeCloseTail: (database, conversationId) =>
|
|
248
|
+
hooks.finalizeMetadata(database, conversationId),
|
|
245
249
|
// CR121: only the sessions newer than this home; its birth is read from
|
|
246
250
|
// the ledger once per maintenance run, and an unknown birth is an error.
|
|
247
251
|
backfillPiSessions: (database) =>
|
|
@@ -251,6 +255,7 @@ export function createLoggerRuntime(options: LoggerRuntimeOptions): LoggerRuntim
|
|
|
251
255
|
deps,
|
|
252
256
|
),
|
|
253
257
|
extractionLimit: resolveMaintenanceMaxExtractions({ env }),
|
|
258
|
+
staleOrphanMinutes: resolveStaleOrphanMinutes({ env }),
|
|
254
259
|
monotonic: options.monotonic,
|
|
255
260
|
now: deps.nowIso,
|
|
256
261
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// What a runtime's session lifecycle lets Mirror do at its edges, by the label
|
|
2
|
+
// US8 stamps on the session row (`claude_code`, `codex`, `grok_build`). One home
|
|
3
|
+
// for the two policies the close path reads (CV21.DS4.TS2, US4).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a runtime's SessionEnd hook has the time to run the LLM tail at exit
|
|
7
|
+
* (CV21.DS4.TS2). Measured bounds, with the evidence:
|
|
8
|
+
*
|
|
9
|
+
* claude_code the plugin registers the hook `async`; Claude Code runs it in
|
|
10
|
+
* the background after exit, unbounded by the user's wait.
|
|
11
|
+
* codex synchronous ("running async SessionEnd hook synchronously",
|
|
12
|
+
* DS12.TS2 L2), 1 s for a handler with no `timeout` (DS2's live
|
|
13
|
+
* `/hooks` table, 2026-10-13, Codex 0.162.1).
|
|
14
|
+
* grok_build 1.5 s for a handler with no `timeout` (DS12.TS4, Grok 1.0.52).
|
|
15
|
+
* DS10 re-measures it and may flip this entry.
|
|
16
|
+
*
|
|
17
|
+
* Any other label, and no label, keeps today's behavior: the tail runs.
|
|
18
|
+
*/
|
|
19
|
+
export function sessionEndRunsLlmTail(interfaceName: string | null | undefined): boolean {
|
|
20
|
+
switch (interfaceName) {
|
|
21
|
+
case "codex":
|
|
22
|
+
case "grok_build":
|
|
23
|
+
return false;
|
|
24
|
+
default:
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a runtime's session outlives its TUI, so that `active = 1` on the
|
|
31
|
+
* session row means RESUMABLE rather than attended (CV21.DS4.US4).
|
|
32
|
+
*
|
|
33
|
+
* codex 0.163.0: `/quit` and Ctrl-D print "Disconnected from this task.
|
|
34
|
+
* Any running work continues. To reconnect, run: codex resume
|
|
35
|
+
* <id>". The thread persists in the shared app-server daemon, and
|
|
36
|
+
* SessionEnd does not fire at TUI exit: Codex's own log shows no
|
|
37
|
+
* hook started after the last turn, and a probe hook pair recorded
|
|
38
|
+
* nothing on either quit path (US4's probe, 2026-10-17). On 0.162.1,
|
|
39
|
+
* four days earlier, `/quit` ended the session and SessionEnd fired
|
|
40
|
+
* (DS2). The session is still re-measured by the next story that
|
|
41
|
+
* depends on it.
|
|
42
|
+
*
|
|
43
|
+
* For such a runtime, an open conversation idle past the orphan threshold is an
|
|
44
|
+
* orphan even while its session is active, and the close deactivates the session
|
|
45
|
+
* (`closeStaleOrphans`). Every other runtime's session ends with its TUI, and an
|
|
46
|
+
* active session there means a person who may still be typing.
|
|
47
|
+
*/
|
|
48
|
+
export function sessionOutlivesTui(interfaceName: string | null | undefined): boolean {
|
|
49
|
+
return interfaceName === "codex";
|
|
50
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// report still fails. Freezing real timings would have faked the evidence
|
|
17
17
|
// instead of isolating it.
|
|
18
18
|
|
|
19
|
+
import { finishDeferredCloseTails } from "#conversation/deferredCloseTail.ts";
|
|
19
20
|
import {
|
|
20
21
|
countCarriedOverConversations,
|
|
21
22
|
countConversationsWithExtractionStatus,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
extractPending,
|
|
24
25
|
} from "#conversation/extractionDriver.ts";
|
|
25
26
|
import { setMute } from "#conversation/logger.ts";
|
|
27
|
+
import { sessionOutlivesTui } from "#conversation/runtimePolicy.ts";
|
|
26
28
|
import type { WritableDatabase } from "#db/database.ts";
|
|
27
29
|
import { GLOBAL_STICKY_DEFAULTS_SESSION_ID } from "#mirror/runtimeSession.ts";
|
|
28
30
|
import { deactivateOperatingMode } from "#mode/operatingMode.ts";
|
|
@@ -40,6 +42,12 @@ export interface MaintenanceDeps {
|
|
|
40
42
|
retitleConversation: (db: WritableDatabase, conversationId: string) => Promise<boolean> | boolean;
|
|
41
43
|
/** The DS5 orchestration behind replay with its accounting, as the driver injects it. */
|
|
42
44
|
runExtraction: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
45
|
+
/**
|
|
46
|
+
* The close-time finalization (title, summary, tags) for a conversation a
|
|
47
|
+
* bounded runtime's SessionEnd closed without it (CV21.DS4.TS2). Optional
|
|
48
|
+
* only for callers written before the step existed; the front door wires it.
|
|
49
|
+
*/
|
|
50
|
+
finalizeCloseTail?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
43
51
|
/**
|
|
44
52
|
* `backfillPiSessions` (slice E) bound to its resolved sessions directory.
|
|
45
53
|
* Injected rather than resolved here so the composite never reads the
|
|
@@ -48,6 +56,8 @@ export interface MaintenanceDeps {
|
|
|
48
56
|
backfillPiSessions: (db: WritableDatabase) => Promise<number> | number;
|
|
49
57
|
/** Python's `MEMORY_MAINTENANCE_MAX_EXTRACTIONS` (AI-05); defaults to the released budget. */
|
|
50
58
|
extractionLimit?: number;
|
|
59
|
+
/** `MIRROR_STALE_ORPHAN_MINUTES` (CV21.DS4.US4); defaults to the 30-minute threshold. */
|
|
60
|
+
staleOrphanMinutes?: number;
|
|
51
61
|
/** Monotonic seconds, injected so report timings are pinnable. */
|
|
52
62
|
monotonic?: () => number;
|
|
53
63
|
now?: () => string;
|
|
@@ -64,14 +74,38 @@ export function resetSessionOrientation(db: WritableDatabase, now: () => string
|
|
|
64
74
|
);
|
|
65
75
|
}
|
|
66
76
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Conversation ids bound to an ATTENDED active runtime session: one whose
|
|
79
|
+
* runtime ends the session with its TUI, so `active = 1` means a person may
|
|
80
|
+
* still be typing. A runtime whose session outlives the TUI (Codex 0.163.0,
|
|
81
|
+
* `runtimePolicy.ts`) is left out: there `active` means resumable, and an idle
|
|
82
|
+
* conversation is an orphan like any other (CV21.DS4.US4).
|
|
83
|
+
*/
|
|
84
|
+
function attendedConversationIds(db: WritableDatabase): Set<string> {
|
|
69
85
|
const rows = db
|
|
70
86
|
.prepare(
|
|
71
|
-
"SELECT conversation_id FROM runtime_sessions WHERE active = 1 AND conversation_id IS NOT NULL",
|
|
87
|
+
"SELECT conversation_id, interface FROM runtime_sessions WHERE active = 1 AND conversation_id IS NOT NULL",
|
|
72
88
|
)
|
|
73
89
|
.all() as Record<string, unknown>[];
|
|
74
|
-
return new Set(
|
|
90
|
+
return new Set(
|
|
91
|
+
rows
|
|
92
|
+
.filter(
|
|
93
|
+
(row) => !sessionOutlivesTui(typeof row.interface === "string" ? row.interface : null),
|
|
94
|
+
)
|
|
95
|
+
.map((row) => String(row.conversation_id)),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The runtime sessions still active on a conversation just closed as an orphan:
|
|
101
|
+
* marked inactive with `closed_at`, so `guessRuntimeSessionId` stops reading
|
|
102
|
+
* them as "the session" (DS12.TS2 L9's class), and a later prompt on the same
|
|
103
|
+
* id binds a new conversation.
|
|
104
|
+
*/
|
|
105
|
+
function deactivateSessionsOf(db: WritableDatabase, conversationId: string, now: string): void {
|
|
106
|
+
db.prepare(
|
|
107
|
+
"UPDATE runtime_sessions SET active = 0, closed_at = ?, updated_at = ? WHERE conversation_id = ? AND active = 1",
|
|
108
|
+
).run(now, now, conversationId);
|
|
75
109
|
}
|
|
76
110
|
|
|
77
111
|
/** Python's `get_open_conversations_idle_since`. */
|
|
@@ -91,7 +125,10 @@ function openConversationsIdleSince(db: WritableDatabase, threshold: string): st
|
|
|
91
125
|
|
|
92
126
|
/**
|
|
93
127
|
* Python's `close_stale_orphans`: end open conversations idle longer than the
|
|
94
|
-
* threshold, skipping any still bound to an active runtime session
|
|
128
|
+
* threshold, skipping any still bound to an attended active runtime session
|
|
129
|
+
* (`attendedConversationIds`). Since CV21.DS4.US4 a session on a runtime that
|
|
130
|
+
* outlives its TUI does not protect its conversation, and the close
|
|
131
|
+
* deactivates every session the orphan was bound to.
|
|
95
132
|
*
|
|
96
133
|
* The count increments even when closing raises: `ended_at` is written before
|
|
97
134
|
* the tail, so the orphan IS closed and an extraction failure must not stop the
|
|
@@ -109,15 +146,16 @@ export async function closeStaleOrphans(
|
|
|
109
146
|
const now = deps.now ?? nowIso;
|
|
110
147
|
const threshold = new Date(Date.parse(now()) - thresholdMinutes * 60_000).toISOString();
|
|
111
148
|
|
|
112
|
-
const
|
|
149
|
+
const attended = attendedConversationIds(db);
|
|
113
150
|
let count = 0;
|
|
114
151
|
for (const conversationId of openConversationsIdleSince(db, threshold)) {
|
|
115
|
-
if (
|
|
152
|
+
if (attended.has(conversationId)) continue;
|
|
116
153
|
try {
|
|
117
154
|
await deps.closeConversation(db, conversationId);
|
|
118
155
|
} catch {
|
|
119
156
|
// The orphan is closed regardless; a failed tail must not stop the loop.
|
|
120
157
|
}
|
|
158
|
+
deactivateSessionsOf(db, conversationId, now());
|
|
121
159
|
count += 1;
|
|
122
160
|
}
|
|
123
161
|
return count;
|
|
@@ -142,6 +180,8 @@ export async function retitlePendingConversations(
|
|
|
142
180
|
`SELECT c.id
|
|
143
181
|
FROM conversations c
|
|
144
182
|
WHERE c.ended_at IS NOT NULL
|
|
183
|
+
-- Owed its deferred finalization, which titles it later this run (CV21.DS4.US4).
|
|
184
|
+
AND (c.metadata IS NULL OR json_extract(c.metadata, '$.close_tail_pending') IS NOT 1)
|
|
145
185
|
AND EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.role = 'user')
|
|
146
186
|
AND EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.role = 'assistant')
|
|
147
187
|
ORDER BY c.ended_at DESC, c.started_at DESC
|
|
@@ -203,7 +243,7 @@ export async function sessionMaintenance(
|
|
|
203
243
|
closeStaleOrphans(db, {
|
|
204
244
|
closeConversation: deps.closeConversation,
|
|
205
245
|
now: deps.now,
|
|
206
|
-
thresholdMinutes: STALE_ORPHAN_THRESHOLD_MINUTES,
|
|
246
|
+
thresholdMinutes: deps.staleOrphanMinutes ?? STALE_ORPHAN_THRESHOLD_MINUTES,
|
|
207
247
|
}),
|
|
208
248
|
),
|
|
209
249
|
await timedStep("Backfilled Pi sessions", monotonic, () => deps.backfillPiSessions(db)),
|
|
@@ -213,6 +253,12 @@ export async function sessionMaintenance(
|
|
|
213
253
|
await timedStep("Extracted pending conversations", monotonic, () =>
|
|
214
254
|
extractPending(db, { runExtraction: deps.runExtraction, limit: deps.extractionLimit }),
|
|
215
255
|
),
|
|
256
|
+
// After extraction, so a stored summary is kept rather than generated twice.
|
|
257
|
+
await timedStep("Finished deferred close tails", monotonic, () =>
|
|
258
|
+
deps.finalizeCloseTail
|
|
259
|
+
? finishDeferredCloseTails(db, { finalizeCloseTail: deps.finalizeCloseTail })
|
|
260
|
+
: 0,
|
|
261
|
+
),
|
|
216
262
|
];
|
|
217
263
|
|
|
218
264
|
const parts = ["Conversation maintenance complete."];
|