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.
- package/.agents/plugins/marketplace.json +16 -0
- package/.pi/skills/mm-build/SKILL.md +1 -1
- package/.pi/skills/mm-explore/SKILL.md +1 -1
- package/.pi/skills/mm-help/SKILL.md +2 -2
- package/.pi/skills/mm-soul/SKILL.md +1 -1
- package/AGENTS.md +9 -8
- package/README.md +5 -6
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.34.0.md +142 -0
- package/docs/releases/v0.35.0.md +199 -0
- package/package.json +3 -6
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/agy/pre-invocation.sh +92 -0
- package/plugins/mirror-mind/agy/session-start.sh +92 -0
- package/plugins/mirror-mind/agy/stop.sh +92 -0
- package/plugins/mirror-mind/hooks.json +26 -0
- package/plugins/mirror-mind/mcp_config.json +11 -0
- package/plugins/mirror-mind/plugin.json +4 -0
- package/plugins/mirror-mind/rules/AGENTS.md +189 -0
- package/plugins/mirror-mind/skills/mm-build/SKILL.md +1 -1
- package/ts/src/conversation/agyTranscript.ts +105 -0
- 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 +59 -0
- package/ts/src/conversation/sessionComposites.ts +54 -8
- package/ts/src/docs/docsLint.ts +14 -1
- package/ts/src/frontDoor/routing.ts +3 -2
- package/ts/src/frontDoor/runtimeRoute.ts +13 -1
- package/ts/src/frontDoor/stdinSource.ts +2 -2
- package/ts/src/guards/claudePlugin.ts +113 -2
- package/ts/src/guards/packContents.ts +7 -6
- package/ts/src/guards/piSourcedSkills.ts +1 -1
- package/ts/src/guards/retiredSurfaces.ts +67 -0
- package/ts/src/hooks/agy.ts +229 -0
- package/ts/src/hooks/claude.ts +16 -4
- package/ts/src/hooks/main.ts +17 -31
- package/ts/src/hooks/payload.ts +8 -15
- package/ts/src/hooks/runtime.ts +11 -10
- 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 +42 -46
- package/.gemini/hooks/log-assistant.sh +0 -85
- package/.gemini/hooks/log-user.sh +0 -85
- package/.gemini/hooks/session-end.sh +0 -85
- package/.gemini/hooks/session-start.sh +0 -85
- 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
- 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`
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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) 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
|
}
|