@tekmidian/pai 0.71.0 → 0.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4188 @@
1
+ import { B as readWorkersSection, E as isLocalProvider, H as resolveModelCapability, Kt as loadStatusConfig, Ot as loadStatuses, Tt as isLive, Xt as writeContinueToForge, fn as workersLogDir, in as ensureNoMcpConfig, w as defaultWorkersConfig } from "./config-CH2Xj7BG.mjs";
2
+ import { c as migratePaiFile, d as resolvePaiFile, r as daemonPidPath, u as paiHomePath } from "./runtime-paths-BKtuLoJm.mjs";
3
+ import { b as writeJsonAtomic, c as loadConfig, f as readMainConfigRaw, n as CONFIG_FILE, p as writeMainConfigRaw } from "./config-D7w6FDaM.mjs";
4
+ import { t as appendLedger } from "./ledger-D6xicYlV.mjs";
5
+ import { x as worktreesDir } from "./worktree-BO_SncO-.mjs";
6
+ import { a as itermUuid, u as sessionMapPath } from "./scope-B_kKDJhY.mjs";
7
+ import { i as loopbackRouterChoice, t as buildRunEnv } from "./run-env-DpRktidn.mjs";
8
+ import { h as resolveTarget } from "./routing-D6nQ__Ar.mjs";
9
+ import { c as DEFAULT_PROXY_PORT, l as ensureProxyRunning } from "./launch-BkE8NqUd.mjs";
10
+ import { i as getRegistryBackend, r as createStorageBackend } from "./factory-DXisI5XK.mjs";
11
+ import { n as fetchLiveSessions, r as sendToSession } from "./aibroker-client-eRILToKG.mjs";
12
+ import { Ut as probeRouter } from "./run-DwHfR1WP.mjs";
13
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { homedir, loadavg, setPriority, tmpdir, userInfo } from "node:os";
15
+ import { basename, delimiter, dirname, join } from "node:path";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { randomUUID } from "node:crypto";
18
+ import { createInterface } from "node:readline";
19
+ import { copyFileSync as copyFileSync$1, existsSync as existsSync$1, linkSync as linkSync$1, mkdirSync as mkdirSync$1, readFileSync as readFileSync$1, readdirSync as readdirSync$1, renameSync as renameSync$1, writeFileSync as writeFileSync$1 } from "fs";
20
+ import { basename as basename$1, join as join$1, resolve as resolve$1 } from "path";
21
+ import { homedir as homedir$1 } from "os";
22
+
23
+ //#region src/session/checkpoint-block.ts
24
+ /**
25
+ * Shared "## Continue" checkpoint logic for a project's TODO.md.
26
+ *
27
+ * WHY THIS MODULE EXISTS
28
+ * ----------------------
29
+ * Before this, `pause.ts` and `handover.ts` each carried their own copy of
30
+ * findProjectTodo / stripContinueSection / block-builder. Both wrote the same
31
+ * fixed four-line block, and both stripped any existing ## Continue section
32
+ * unconditionally. The consequence was that a rich, model-authored checkpoint
33
+ * could never survive:
34
+ *
35
+ * 1. `pai pause` had no way to accept a body, so the model printed its
36
+ * checkpoint to the terminal and it was lost.
37
+ * 2. Even if a body had been written by hand, the session-stop hook runs
38
+ * `pai session handover` on every clean exit, which regenerated the
39
+ * generic block and erased it.
40
+ *
41
+ * So there are two jobs here:
42
+ *
43
+ * - AUTHORED writes (`pai pause --body-file`) carry the model's markdown
44
+ * verbatim, wrapped in explicit start/end markers.
45
+ * - AUTO writes (hooks) must never destroy an authored checkpoint belonging
46
+ * to the *same* session. They may replace a stale one left by an earlier
47
+ * session, otherwise TODO.md would show a checkpoint that no longer
48
+ * describes where the work stands.
49
+ *
50
+ * PARSING
51
+ * -------
52
+ * A rich body can legitimately contain `---` rules and `##` headings, which the
53
+ * old heuristic scanner treated as section terminators. Authored blocks are
54
+ * therefore delimited by an explicit HTML-comment pair; the legacy heuristic is
55
+ * kept only as a fallback for blocks written before this change.
56
+ */
57
+ /** Locations searched for a project TODO.md, in priority order. */
58
+ const TODO_LOCATIONS = [
59
+ "Notes/TODO.md",
60
+ ".claude/Notes/TODO.md",
61
+ "tasks/todo.md",
62
+ "TODO.md"
63
+ ];
64
+ const MARKER_OPEN = "<!-- pai:checkpoint";
65
+ const MARKER_CLOSE = "<!-- /pai:checkpoint -->";
66
+ /**
67
+ * Markers for a handover that has been superseded but not thrown away.
68
+ *
69
+ * Deliberately NOT `pai:checkpoint`: `locateContinue` scans for that marker and
70
+ * for the `## Continue` heading, so an archived block written verbatim would be
71
+ * found as the live section and rewritten in place of it. Archived entries are
72
+ * inert by construction — different marker, no heading.
73
+ */
74
+ const ARCHIVE_OPEN = "<!-- pai:archived-handover";
75
+ const ARCHIVE_CLOSE = "<!-- /pai:archived-handover -->";
76
+ const ARCHIVE_HEADING = "## Previous handovers";
77
+ /**
78
+ * How many superseded handovers TODO.md keeps.
79
+ *
80
+ * Enough that a handover survives a run of sessions that each end without
81
+ * pausing, which is the sequence that destroyed one on 2026-08-04. Not
82
+ * unbounded: TODO.md is read by a human at the top of every session, and a file
83
+ * that grows without limit stops being read, which is its own kind of loss.
84
+ */
85
+ const ARCHIVE_LIMIT = 5;
86
+ const CONTINUE_HEADING = "## Continue";
87
+ function findProjectTodo(rootPath) {
88
+ for (const rel of TODO_LOCATIONS) {
89
+ const full = join(rootPath, rel);
90
+ if (existsSync(full)) try {
91
+ return {
92
+ path: full,
93
+ content: readFileSync(full, "utf8")
94
+ };
95
+ } catch {}
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Resolve the TODO.md to write to, creating Notes/ if nothing exists yet.
101
+ * Returns null only when the directory could not be created.
102
+ */
103
+ function resolveTodoTarget(rootPath, opts = {}) {
104
+ const found = findProjectTodo(rootPath);
105
+ if (found) return found;
106
+ const notesDir = join(rootPath, "Notes");
107
+ if (opts.create !== false) try {
108
+ if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
109
+ } catch {
110
+ return null;
111
+ }
112
+ return {
113
+ path: join(notesDir, "TODO.md"),
114
+ content: ""
115
+ };
116
+ }
117
+ /**
118
+ * Parse a `<!-- pai:checkpoint key="value" ... -->` marker line.
119
+ * Returns null when the line is not a marker.
120
+ */
121
+ function parseMarker(line) {
122
+ const trimmed = line.trim();
123
+ if (!trimmed.startsWith(MARKER_OPEN)) return null;
124
+ const attrs = {};
125
+ for (const m of trimmed.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) attrs[m[1]] = m[2];
126
+ return {
127
+ authored: attrs.authored === "model" ? "model" : "auto",
128
+ session: attrs.session || void 0,
129
+ sessionId: attrs["session-id"] || void 0,
130
+ ts: attrs.ts || void 0
131
+ };
132
+ }
133
+ /**
134
+ * Lines an auto-generated block is made of. Anything else in a section is
135
+ * content somebody put there deliberately.
136
+ *
137
+ * The blockquote marker is optional on every pattern. These lines appear
138
+ * quoted when the block builder emits them as its header, and unquoted when a
139
+ * caller passes the same text as a BODY — which the session-stop hook does: it
140
+ * builds `Working directory: ${cwd}` and hands it over as state. Matching only
141
+ * the quoted form made such a body read as content, which is how a session
142
+ * that did no work at all overwrote a real handover on 2026-08-04. A line that
143
+ * merely restates the generated header is boilerplate wherever it sits.
144
+ */
145
+ const BOILERPLATE_PATTERNS = [
146
+ /^##\s+Continue$/,
147
+ /^<!--\s*\/?pai:checkpoint/,
148
+ /^>?\s*\*\*Last session:\*\*/,
149
+ /^>?\s*\*\*Paused at:\*\*/,
150
+ /^>?\s*Working directory:/,
151
+ /^>?\s*Resume with:/,
152
+ /^>?\s*_No checkpoint body was recorded/,
153
+ /^-{3,}$/
154
+ ];
155
+ /** A blank line, with or without a blockquote marker. */
156
+ const BLANK_LINE = /^>?\s*$/;
157
+ /**
158
+ * True when a section contains nothing but generated header lines.
159
+ *
160
+ * This is the guard that stops an auto write from destroying content it did
161
+ * not author. A session that hit the old clobbering bug may have worked around
162
+ * it by hand — writing its state into a subsection underneath the generated
163
+ * header lines, in an unmarked block. Those blocks predate the marker, so
164
+ * authorship cannot be read off them; the only safe signal is whether anything
165
+ * beyond boilerplate is present.
166
+ */
167
+ function isBoilerplateOnly(lines) {
168
+ return lines.every((line) => {
169
+ const t = line.trim();
170
+ return BLANK_LINE.test(t) || BOILERPLATE_PATTERNS.some((re) => re.test(t));
171
+ });
172
+ }
173
+ /**
174
+ * True when a checkpoint body says something the generated header does not.
175
+ *
176
+ * "Has a body" and "has something to say" are not the same question, and the
177
+ * preservation rules care only about the second. An unattended writer that
178
+ * emits a single line restating the working directory has produced a body by
179
+ * any string test and a handover by none.
180
+ */
181
+ function hasSubstance(body) {
182
+ const text = (body ?? "").trim();
183
+ if (!text) return false;
184
+ return !isBoilerplateOnly(text.split("\n"));
185
+ }
186
+ /**
187
+ * Extract the non-boilerplate content of a section, or "" if there is none.
188
+ *
189
+ * Interior blank lines are kept. They are not decoration — in Markdown they
190
+ * are what separates a paragraph from the table or list that follows, so
191
+ * dropping them (as this did while it treated every blank line as boilerplate)
192
+ * silently welds a checkpoint body into one unreadable run.
193
+ */
194
+ function extractSectionContent(lines) {
195
+ const kept = [];
196
+ for (const line of lines) {
197
+ const t = line.trim();
198
+ if (BOILERPLATE_PATTERNS.some((re) => re.test(t))) continue;
199
+ kept.push(line);
200
+ }
201
+ return trimBlankEdges(collapseBlankRuns(kept)).join("\n");
202
+ }
203
+ /** Drop leading and trailing blank lines, leaving interior spacing alone. */
204
+ function trimBlankEdges(lines) {
205
+ let start = 0;
206
+ let end = lines.length;
207
+ while (start < end && BLANK_LINE.test(lines[start].trim())) start += 1;
208
+ while (end > start && BLANK_LINE.test(lines[end - 1].trim())) end -= 1;
209
+ return lines.slice(start, end);
210
+ }
211
+ /**
212
+ * Collapse runs of blank lines to a single blank.
213
+ *
214
+ * Removing a boilerplate line leaves the blank that surrounded it behind, so
215
+ * stripping the header can open a three-line gap in the middle of the body.
216
+ * One blank line is all Markdown needs.
217
+ */
218
+ function collapseBlankRuns(lines) {
219
+ const out = [];
220
+ let lastWasBlank = false;
221
+ for (const line of lines) {
222
+ const isBlank = BLANK_LINE.test(line.trim());
223
+ if (isBlank && lastWasBlank) continue;
224
+ out.push(line);
225
+ lastWasBlank = isBlank;
226
+ }
227
+ return out;
228
+ }
229
+ /**
230
+ * Locate the existing ## Continue section.
231
+ *
232
+ * When the section carries an explicit marker pair, the close marker defines
233
+ * the end — this is what lets a rich body contain `---` and `##` safely.
234
+ * Otherwise the legacy heuristic applies: stop at the first `---` or the next
235
+ * `##` heading.
236
+ */
237
+ function locateContinue(content) {
238
+ const lines = content.split("\n");
239
+ const startIdx = lines.findIndex((l) => l.trim() === CONTINUE_HEADING);
240
+ if (startIdx === -1) return null;
241
+ let meta = null;
242
+ let markerIdx = -1;
243
+ for (let i = startIdx + 1; i < Math.min(startIdx + 6, lines.length); i++) {
244
+ const parsed = parseMarker(lines[i]);
245
+ if (parsed) {
246
+ meta = parsed;
247
+ markerIdx = i;
248
+ break;
249
+ }
250
+ if (lines[i].trim() !== "") break;
251
+ }
252
+ let endIdx = lines.length;
253
+ if (markerIdx !== -1) {
254
+ const closeIdx = lines.findIndex((l, i) => i > markerIdx && l.trim() === MARKER_CLOSE);
255
+ if (closeIdx !== -1) endIdx = closeIdx + 1;
256
+ else endIdx = heuristicEnd(lines, startIdx);
257
+ } else endIdx = heuristicEnd(lines, startIdx);
258
+ let trailingEnd = endIdx;
259
+ while (trailingEnd < lines.length && lines[trailingEnd].trim() === "") trailingEnd += 1;
260
+ if (trailingEnd < lines.length && lines[trailingEnd].trim() === "---") trailingEnd += 1;
261
+ else trailingEnd = endIdx;
262
+ return {
263
+ startIdx,
264
+ endIdx: trailingEnd,
265
+ meta,
266
+ lines: lines.slice(startIdx, trailingEnd)
267
+ };
268
+ }
269
+ /**
270
+ * Legacy scanner for blocks written before checkpoint markers existed.
271
+ *
272
+ * Terminates on a horizontal rule or the next level-1 or level-2 heading. Note
273
+ * the `(?!#)` — `###` is a *subsection* of `## Continue`, not a terminator. The
274
+ * original scanner stopped at any run of `#`, which meant a `### Restored
275
+ * state` subsection fell outside the section entirely and could not be seen,
276
+ * let alone carried forward.
277
+ *
278
+ * `#{1,2}` rather than `##`, because matching only `##` did not stop at an H1
279
+ * and therefore ATE IT. A TODO.md whose `## Continue` block precedes its
280
+ * `# TODO` title had the title absorbed into the section and destroyed on the
281
+ * next regenerate — reported independently by three sessions on 2026-08-03,
282
+ * one of which lost it three times and recovered from git each time.
283
+ *
284
+ * An H1 is a document-level heading. It can never be part of a `## Continue`
285
+ * section, so treating it as a terminator is not a heuristic improvement but a
286
+ * structural fact.
287
+ */
288
+ function heuristicEnd(lines, startIdx) {
289
+ for (let i = startIdx + 1; i < lines.length; i++) {
290
+ const trimmed = lines[i].trim();
291
+ if (trimmed === "---" || /^#{1,2}(?!#)/.test(trimmed) && trimmed !== CONTINUE_HEADING) return i;
292
+ }
293
+ return lines.length;
294
+ }
295
+ /** Remove the ## Continue section, returning the remainder of the document. */
296
+ function stripContinue(content) {
297
+ const found = locateContinue(content);
298
+ if (!found) return content;
299
+ const lines = content.split("\n");
300
+ const before = lines.slice(0, found.startIdx);
301
+ const after = lines.slice(found.endIdx);
302
+ while (after.length > 0 && after[0].trim() === "") after.shift();
303
+ return [...before, ...after].join("\n");
304
+ }
305
+ function escapeAttr(value) {
306
+ return value.replace(/"/g, "'");
307
+ }
308
+ function buildContinueBlock(opts) {
309
+ const ts = opts.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
310
+ const attrs = [
311
+ `authored="${opts.authored}"`,
312
+ `session="${escapeAttr(opts.sessionLine)}"`,
313
+ opts.sessionId ? `session-id="${escapeAttr(opts.sessionId)}"` : null,
314
+ `ts="${ts}"`
315
+ ].filter(Boolean).join(" ");
316
+ const header = [
317
+ `> **Last session:** ${opts.sessionLine}`,
318
+ `> **Paused at:** ${ts}`,
319
+ ">",
320
+ `> Working directory: ${opts.cwd}`
321
+ ];
322
+ if (opts.sessionId) header.push(">", `> Resume with: \`claude --resume ${opts.sessionId}\``);
323
+ const body = (opts.body ?? "").trim();
324
+ const parts = [
325
+ CONTINUE_HEADING,
326
+ "",
327
+ `${MARKER_OPEN} ${attrs} -->`,
328
+ "",
329
+ ...header
330
+ ];
331
+ if (body) parts.push("", body);
332
+ else parts.push(">", "> _No checkpoint body was recorded — see the latest session note._");
333
+ parts.push("", MARKER_CLOSE, "", "---", "");
334
+ return parts.join("\n");
335
+ }
336
+ /**
337
+ * Write the ## Continue block, honouring the preservation rules.
338
+ *
339
+ * An AUTO write is unattended — it fires from the session-stop and pre-compact
340
+ * hooks — so it operates under one governing rule: **never destroy content it
341
+ * did not author.** Three cases follow from that:
342
+ *
343
+ * 1. An authored checkpoint for the SAME session is left untouched. The hooks
344
+ * fire after the model has already recorded the real state; overwriting it
345
+ * with metadata is the bug this module exists to fix.
346
+ *
347
+ * "Same session" is decided by the Claude session UUID whenever both sides
348
+ * know it, and only falls back to the human-readable session line when one
349
+ * of them does not. The line is derived from the session note filename,
350
+ * and `session-stop.sh` *renames and renumbers that file* — via `session
351
+ * slug --apply` and `session cleanup --execute` — before it reaches the
352
+ * handover step. So the key the hook computes at exit is not the key the
353
+ * model wrote seconds earlier, and a filename-keyed comparison mismatches
354
+ * by construction. Observed live on 2026-08-01: notes renumbered twice
355
+ * within a single session. The UUID is the only identifier that holds
356
+ * still.
357
+ * 2. An authored checkpoint from an EARLIER session is stale — TODO.md would
358
+ * otherwise keep pointing at the wrong session — so it is replaced. Its
359
+ * content is not lost: `pai pause` mirrors every authored body into the
360
+ * session note.
361
+ * 3. An UNMARKED block predates the marker, so authorship cannot be read off
362
+ * it. If it is nothing but generated header lines it is replaced. If it
363
+ * carries anything else, that content was put there deliberately — quite
364
+ * possibly as a hand-rolled workaround for the very clobbering this fixes —
365
+ * and is carried forward into the new block rather than dropped.
366
+ *
367
+ * A MODEL write always replaces: the model is authoring the checkpoint, and a
368
+ * newer one supersedes an older one.
369
+ */
370
+ /**
371
+ * Do an existing checkpoint and an incoming write describe the same session?
372
+ *
373
+ * The UUID is authoritative when both sides carry one: it is assigned by Claude
374
+ * Code and never changes for the life of the session. The session line is a
375
+ * derived, mutable label and is only consulted when there is no UUID to compare
376
+ * — a checkpoint written before `--session-id` was threaded through, or an auto
377
+ * write from a caller that was not given one.
378
+ */
379
+ function isSameSession(meta, opts) {
380
+ if (meta.sessionId && opts.sessionId) return meta.sessionId === opts.sessionId;
381
+ return meta.session === opts.sessionLine;
382
+ }
383
+ /**
384
+ * How long a model-authored checkpoint is protected from automated overwriting.
385
+ *
386
+ * Sized for the race, not for the session: the gap between a model writing its
387
+ * checkpoint and a hook firing is seconds to minutes, and an hour covers even a
388
+ * slow checkpoint on a loaded machine. Anything genuinely from an earlier
389
+ * session is hours or days old and still falls through to case 2, which is what
390
+ * keeps TODO.md from freezing on a checkpoint nobody will ever replace.
391
+ */
392
+ const AUTHORED_GRACE_MS = 3600 * 1e3;
393
+ /** Was this checkpoint written recently enough to still belong to the run in progress? */
394
+ function isRecent(meta, now) {
395
+ if (!meta.ts) return false;
396
+ const then = Date.parse(meta.ts);
397
+ if (Number.isNaN(then)) return false;
398
+ const nowMs = now ? Date.parse(now) : Date.now();
399
+ if (Number.isNaN(nowMs)) return false;
400
+ return nowMs - then < AUTHORED_GRACE_MS;
401
+ }
402
+ /**
403
+ * Turn a live checkpoint block into an inert archive entry.
404
+ *
405
+ * Two things have to go: the `## Continue` heading and the `pai:checkpoint`
406
+ * marker. Both are what `locateContinue` looks for, so leaving either in place
407
+ * would let a later write treat the archive as the live section — and the next
408
+ * one after that would then archive the archive. Everything else is kept
409
+ * verbatim, because the body is the whole reason this exists.
410
+ */
411
+ function toArchiveEntry(lines, meta) {
412
+ const out = [];
413
+ const label = meta?.session ?? "Unknown session";
414
+ const stamp = meta?.ts ? ` — checkpointed ${meta.ts}` : "";
415
+ out.push(`${ARCHIVE_OPEN} session="${label}"${meta?.ts ? ` ts="${meta.ts}"` : ""} -->`);
416
+ out.push("");
417
+ out.push(`### ${label}${stamp}`);
418
+ out.push("");
419
+ for (const line of lines) {
420
+ if (line.trim() === CONTINUE_HEADING) continue;
421
+ if (line.trim().startsWith(MARKER_OPEN)) continue;
422
+ if (line.trim() === MARKER_CLOSE) continue;
423
+ if (line.trim() === "---") continue;
424
+ out.push(line);
425
+ }
426
+ while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
427
+ out.push("");
428
+ out.push(ARCHIVE_CLOSE);
429
+ return out;
430
+ }
431
+ /**
432
+ * Put a superseded handover into the archive section, newest first.
433
+ *
434
+ * `rest` is the document with the Continue section already stripped. The
435
+ * archive lives immediately below it so a reader meets the current handover
436
+ * first and the previous ones directly after, rather than hunting for them at
437
+ * the bottom of a file that also holds the project's open work.
438
+ */
439
+ function archiveInto(rest, entry) {
440
+ const lines = rest.split("\n");
441
+ const headingIdx = lines.findIndex((l) => l.trim() === ARCHIVE_HEADING);
442
+ if (headingIdx === -1) return [
443
+ ARCHIVE_HEADING,
444
+ "",
445
+ ...entry,
446
+ "",
447
+ "---",
448
+ "",
449
+ rest.trimStart()
450
+ ].join("\n");
451
+ const before = lines.slice(0, headingIdx + 1);
452
+ const after = lines.slice(headingIdx + 1);
453
+ while (after.length > 0 && after[0].trim() === "") after.shift();
454
+ const merged = [
455
+ ...entry,
456
+ "",
457
+ ...after
458
+ ];
459
+ const kept = [];
460
+ let seen = 0;
461
+ for (let i = 0; i < merged.length; i++) {
462
+ if (merged[i].trim().startsWith(ARCHIVE_OPEN)) {
463
+ seen += 1;
464
+ if (seen > ARCHIVE_LIMIT) {
465
+ while (i < merged.length && merged[i].trim() !== ARCHIVE_CLOSE) i++;
466
+ continue;
467
+ }
468
+ }
469
+ kept.push(merged[i]);
470
+ }
471
+ return [
472
+ ...before,
473
+ "",
474
+ ...kept
475
+ ].join("\n");
476
+ }
477
+ function applyContinue(opts) {
478
+ if (!opts.dryRun) try {
479
+ const cfg = loadStatusConfig(opts.rootPath);
480
+ if (cfg.sink === "forge") {
481
+ const block = buildContinueBlock(opts);
482
+ if (opts.authored === "auto" && !hasSubstance(opts.body)) return {
483
+ action: "preserved",
484
+ path: null,
485
+ block
486
+ };
487
+ writeContinueToForge(opts.rootPath, cfg, block, {});
488
+ return {
489
+ action: "written",
490
+ path: null,
491
+ block
492
+ };
493
+ }
494
+ } catch (e) {
495
+ const why = e instanceof Error ? e.message : String(e);
496
+ console.error(`handover: forge write failed (${why}) - wrote Notes/TODO.md instead`);
497
+ }
498
+ const target = resolveTodoTarget(opts.rootPath, { create: !opts.dryRun });
499
+ if (!target) return {
500
+ action: "failed",
501
+ path: null,
502
+ block: buildContinueBlock(opts),
503
+ error: "Could not resolve or create a TODO.md target"
504
+ };
505
+ const existing = locateContinue(target.content);
506
+ let carriedForward = false;
507
+ let effectiveBody = opts.body;
508
+ let archiveEntry = null;
509
+ if (opts.authored === "auto" && existing) {
510
+ if (existing.meta?.authored === "model" && isSameSession(existing.meta, opts)) return {
511
+ action: "preserved",
512
+ path: target.path,
513
+ block: buildContinueBlock(opts),
514
+ preservedMeta: existing.meta
515
+ };
516
+ if (existing.meta && !hasSubstance(opts.body) && !isBoilerplateOnly(existing.lines)) return {
517
+ action: "preserved",
518
+ path: target.path,
519
+ block: buildContinueBlock(opts),
520
+ preservedMeta: existing.meta ?? void 0
521
+ };
522
+ if (existing.meta?.authored === "model" && isRecent(existing.meta, opts.timestamp) && !isBoilerplateOnly(existing.lines)) return {
523
+ action: "preserved",
524
+ path: target.path,
525
+ block: buildContinueBlock(opts),
526
+ preservedMeta: existing.meta
527
+ };
528
+ if (existing.meta?.authored === "model" && !isBoilerplateOnly(existing.lines) && !isSameSession(existing.meta, opts)) archiveEntry = toArchiveEntry(existing.lines, existing.meta);
529
+ if (!existing.meta && !isBoilerplateOnly(existing.lines)) {
530
+ const salvaged = extractSectionContent(existing.lines);
531
+ if (salvaged) {
532
+ effectiveBody = [
533
+ "_Carried forward from the previous checkpoint (author unknown — this",
534
+ "block predates checkpoint authorship markers):_",
535
+ "",
536
+ salvaged
537
+ ].join("\n");
538
+ carriedForward = true;
539
+ }
540
+ }
541
+ }
542
+ const block = buildContinueBlock({
543
+ ...opts,
544
+ body: effectiveBody
545
+ });
546
+ if (opts.dryRun) return {
547
+ action: "written",
548
+ path: target.path,
549
+ block,
550
+ carriedForward,
551
+ archived: !!archiveEntry
552
+ };
553
+ const rest = stripContinue(target.content).trimStart();
554
+ const newContent = block + (archiveEntry ? archiveInto(rest, archiveEntry) : rest);
555
+ const tmpPath = `${target.path}.continue.tmp`;
556
+ try {
557
+ writeFileSync(tmpPath, newContent, "utf8");
558
+ renameSync(tmpPath, target.path);
559
+ } catch (err) {
560
+ try {
561
+ if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
562
+ } catch {}
563
+ return {
564
+ action: "failed",
565
+ path: target.path,
566
+ block,
567
+ error: String(err)
568
+ };
569
+ }
570
+ return {
571
+ action: "written",
572
+ path: target.path,
573
+ block,
574
+ carriedForward,
575
+ archived: !!archiveEntry
576
+ };
577
+ }
578
+ /** ADAPTER_DIR (harness adapter root) — mirrors pai-paths.ts's resolveAdapterDir(),
579
+ * kept self-contained here to avoid importing pai-paths.ts's process.exit(1)
580
+ * validation side effect. PAI_DIR is the deprecated alias, same as there. */
581
+ function getPaiDir() {
582
+ const envDir = process.env.ADAPTER_DIR || process.env.PAI_DIR;
583
+ if (envDir) try {
584
+ return realpathSync(envDir);
585
+ } catch {
586
+ return envDir;
587
+ }
588
+ return join(homedir(), ".claude");
589
+ }
590
+ /**
591
+ * Find the notes directory for a project — local first, then the central
592
+ * ~/.claude/projects/<encoded>/Notes fallback. Never creates.
593
+ */
594
+ function findNotesDir$1(rootPath, encodedDir) {
595
+ for (const rel of [
596
+ "Notes",
597
+ "notes",
598
+ ".claude/Notes"
599
+ ]) {
600
+ const p = join(rootPath, rel);
601
+ if (existsSync(p)) return p;
602
+ }
603
+ const central = join(getPaiDir(), "projects", encodedDir, "Notes");
604
+ if (existsSync(central)) return central;
605
+ return null;
606
+ }
607
+ /**
608
+ * Find the current (highest-numbered) session note: current month, then the
609
+ * previous month, then a flat notesDir as legacy fallback.
610
+ */
611
+ function findLatestNote(notesDir) {
612
+ const findIn = (dir) => {
613
+ if (!existsSync(dir)) return null;
614
+ let files;
615
+ try {
616
+ files = readdirSync(dir);
617
+ } catch {
618
+ return null;
619
+ }
620
+ const notes = files.filter((f) => /^\d{3,4}[\s_-].*\.md$/.test(f)).sort((a, b) => {
621
+ return parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
622
+ });
623
+ return notes.length > 0 ? join(dir, notes[notes.length - 1]) : null;
624
+ };
625
+ const now = /* @__PURE__ */ new Date();
626
+ const current = findIn(join(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
627
+ if (current) return current;
628
+ const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
629
+ const prevFound = findIn(join(notesDir, String(prev.getFullYear()), String(prev.getMonth() + 1).padStart(2, "0")));
630
+ if (prevFound) return prevFound;
631
+ return findIn(notesDir);
632
+ }
633
+ /**
634
+ * Append the checkpoint body to a session note.
635
+ *
636
+ * TODO.md's ## Continue is a single slot that every later checkpoint
637
+ * overwrites. The session note is the durable record, so the body goes to both.
638
+ * Idempotent per timestamp: re-running with the same stamp will not duplicate.
639
+ */
640
+ function appendCheckpointToNote(notePath, body, timestamp) {
641
+ const heading = `## Pause Checkpoint — ${timestamp ?? (/* @__PURE__ */ new Date()).toISOString()}`;
642
+ let existing;
643
+ try {
644
+ existing = readFileSync(notePath, "utf8");
645
+ } catch (err) {
646
+ return {
647
+ appended: false,
648
+ error: String(err)
649
+ };
650
+ }
651
+ if (existing.includes(heading)) return { appended: false };
652
+ const block = `\n\n---\n\n${heading}\n\n${body.trim()}\n`;
653
+ const tmpPath = `${notePath}.checkpoint.tmp`;
654
+ try {
655
+ writeFileSync(tmpPath, existing.trimEnd() + block, "utf8");
656
+ renameSync(tmpPath, notePath);
657
+ } catch (err) {
658
+ try {
659
+ if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
660
+ } catch {}
661
+ return {
662
+ appended: false,
663
+ error: String(err)
664
+ };
665
+ }
666
+ return { appended: true };
667
+ }
668
+ /** Read a checkpoint body from a file, or from stdin when path is "-". */
669
+ function readBodyFile(path) {
670
+ if (path === "-") return readFileSync(0, "utf8");
671
+ return readFileSync(path, "utf8");
672
+ }
673
+
674
+ //#endregion
675
+ //#region src/hooks/ts/lib/context-handover-cache.ts
676
+ /**
677
+ * context-handover-cache.ts — the well-known file where the threshold-
678
+ * triggered pre-compaction handover (see ../../../daemon/context-handover-
679
+ * worker.ts) is written, and where context-compression-hook.ts looks for it
680
+ * at compaction time.
681
+ *
682
+ * Deliberately dependency-light (fs/os/path only). The daemon worker that
683
+ * WRITES this file needs the heavier machinery in session-summary-worker.ts
684
+ * (spawning Claude, git log, etc.), but the PreCompact hook that READS it
685
+ * runs as a short-lived process on every compaction and must not drag in
686
+ * daemon state, database pools, or anything else that module graph carries —
687
+ * this file is the shared seam so neither side has to.
688
+ */
689
+ function contextHandoverCachePath(sessionId) {
690
+ return join(tmpdir(), `pai-context-handover-${sessionId}.json`);
691
+ }
692
+ function readContextHandoverCache(sessionId) {
693
+ const path = contextHandoverCachePath(sessionId);
694
+ if (!existsSync(path)) return null;
695
+ try {
696
+ return JSON.parse(readFileSync(path, "utf-8"));
697
+ } catch {
698
+ return null;
699
+ }
700
+ }
701
+ function writeContextHandoverCache(cache) {
702
+ writeFileSync(contextHandoverCachePath(cache.sessionId), JSON.stringify(cache, null, 2), "utf-8");
703
+ }
704
+ /** A handover older than this is not "current" even when generated after the
705
+ * previous compaction: a session fills its window over hours, and a cache
706
+ * written at the start of that fill misses everything since. */
707
+ const HANDOVER_MAX_AGE_MS = 7200 * 1e3;
708
+
709
+ //#endregion
710
+ //#region src/daemon/daemon-pid.ts
711
+ /**
712
+ * daemon-pid.ts — the one way PAI finds the running daemon's PID.
713
+ *
714
+ * Never by command-line pattern: `pgrep -f` matches any process whose argv
715
+ * mentions the text (a worker whose spec quotes it, an editor), and the caller
716
+ * then signals the wrong process. The service manager knows the PID; when none
717
+ * is in use (`pai daemon serve` in a container) the daemon's own pidfile does.
718
+ */
719
+ const LAUNCHD_LABEL = "com.pai.pai-daemon";
720
+ const SYSTEMD_UNIT = "pai-daemon.service";
721
+ /** `pid = N` from `launchctl print gui/<uid>/<label>`; undefined when not running. */
722
+ function parseLaunchctlPid(out) {
723
+ const m = /^\s*pid = (\d+)\s*$/m.exec(out);
724
+ return m ? Number(m[1]) : void 0;
725
+ }
726
+ /** `MainPID=N` from `systemctl --user show -p MainPID`; 0 means not running. */
727
+ function parseSystemdMainPid(out) {
728
+ const n = Number(/^MainPID=(\d+)\s*$/m.exec(out)?.[1]);
729
+ return n > 0 ? n : void 0;
730
+ }
731
+ const run = (cmd, args) => {
732
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
733
+ return {
734
+ status: r.status,
735
+ stdout: r.stdout ?? ""
736
+ };
737
+ };
738
+ /** Start time of a process (epoch ms) or undefined when it does not exist. */
739
+ function processStart(pid, sh) {
740
+ const r = sh("ps", [
741
+ "-o",
742
+ "lstart=",
743
+ "-p",
744
+ String(pid)
745
+ ]);
746
+ if (r.status !== 0 || !r.stdout.trim()) return void 0;
747
+ const t = Date.parse(r.stdout.trim());
748
+ return Number.isNaN(t) ? void 0 : t;
749
+ }
750
+ /**
751
+ * PID from the pidfile, only if that process is alive and started no later
752
+ * than the pidfile was written (ps start times have 1 s resolution). A process
753
+ * that started after the file is a reused PID and is not the daemon.
754
+ */
755
+ function pidfilePid(path = daemonPidPath(), sh = run) {
756
+ try {
757
+ const pid = parseInt(readFileSync(path, "utf8").trim(), 10);
758
+ if (!(pid > 0)) return void 0;
759
+ const started = processStart(pid, sh);
760
+ if (started === void 0 || started > statSync(path).mtimeMs + 2e3) return void 0;
761
+ return pid;
762
+ } catch {
763
+ return;
764
+ }
765
+ }
766
+ /** The running daemon's PID: service manager first, pidfile second, else undefined. */
767
+ function resolveDaemonPid(plat = process.platform, sh = run, path = daemonPidPath()) {
768
+ if (plat === "darwin") {
769
+ const r = sh("launchctl", ["print", `gui/${process.getuid?.() ?? userInfo().uid}/${LAUNCHD_LABEL}`]);
770
+ const pid = r.status === 0 ? parseLaunchctlPid(r.stdout) : void 0;
771
+ if (pid) return pid;
772
+ } else if (plat === "linux") {
773
+ const r = sh("systemctl", [
774
+ "--user",
775
+ "show",
776
+ "-p",
777
+ "MainPID",
778
+ SYSTEMD_UNIT
779
+ ]);
780
+ const pid = r.status === 0 ? parseSystemdMainPid(r.stdout) : void 0;
781
+ if (pid) return pid;
782
+ }
783
+ return pidfilePid(path, sh);
784
+ }
785
+ function writeDaemonPidfile(path = daemonPidPath()) {
786
+ try {
787
+ mkdirSync(dirname(path), { recursive: true });
788
+ writeFileSync(path, `${process.pid}\n`, "utf8");
789
+ } catch {}
790
+ }
791
+ /** Remove the pidfile only if it is ours (a newer daemon may own it). */
792
+ function removeDaemonPidfile(path = daemonPidPath()) {
793
+ try {
794
+ if (parseInt(readFileSync(path, "utf8").trim(), 10) === process.pid) unlinkSync(path);
795
+ } catch {}
796
+ }
797
+
798
+ //#endregion
799
+ //#region src/memory/embed-pass.ts
800
+ /**
801
+ * Embed-pass policy: time budget, yield rules and the record of the last pass.
802
+ *
803
+ * An embed pass over a multi-million chunk backlog is hours of CPU. It runs
804
+ * under a wall-clock budget (embedMaxMinutes), at low priority, and steps aside
805
+ * while a local-model worker holds the machine or the load average is high.
806
+ */
807
+ const DEFAULT_EMBED_MAX_MINUTES = 60;
808
+ const DEFAULT_EMBED_MAX_LOAD = 8;
809
+ /** Modest batch: small enough that a budget or yield check lands within seconds. */
810
+ const EMBED_PASS_BATCH_SIZE = 25;
811
+ /** True when a running or queued worker uses a provider on this machine. */
812
+ function localWorkerBusy(logDir, providers) {
813
+ try {
814
+ return loadStatuses(logDir).some((s) => {
815
+ const p = providers[s.provider];
816
+ return s.state === "running" && !!p && isLocalProvider(p.baseUrl) && isLive(s);
817
+ });
818
+ } catch {
819
+ return false;
820
+ }
821
+ }
822
+ /** Reason to hold the pass back right now, or null to go ahead. */
823
+ function embedYieldReason(o) {
824
+ if (o.localBusy) return "local-model worker running";
825
+ if (o.load > o.maxLoad) return `load average ${o.load.toFixed(1)} above ${o.maxLoad}`;
826
+ return null;
827
+ }
828
+ /** The live yield check for a pass: reads the load average and the worker statuses. */
829
+ function liveYieldCheck(maxLoad) {
830
+ return () => {
831
+ let logDir = "";
832
+ let providers = {};
833
+ try {
834
+ const { workers } = readWorkersSection();
835
+ logDir = workersLogDir(workers);
836
+ providers = workers.providers ?? {};
837
+ } catch {}
838
+ return embedYieldReason({
839
+ load: loadavg()[0],
840
+ maxLoad,
841
+ localBusy: logDir ? localWorkerBusy(logDir, providers) : false
842
+ });
843
+ };
844
+ }
845
+ const OUTCOME_TEXT = {
846
+ done: "done",
847
+ budget: "budget hit",
848
+ yielded: "yielded"
849
+ };
850
+ /** The `pai daemon status` lines for the embed backlog and the last pass. */
851
+ function formatEmbedStatus(s) {
852
+ const lines = [];
853
+ if (typeof s.embedBacklog === "number") lines.push(`Embed: ${s.embedBacklog} chunks without vectors`);
854
+ const p = s.lastEmbedPass;
855
+ if (p) lines.push(`Last embed: ${OUTCOME_TEXT[p.outcome]}, ${p.embedded} chunks at ${p.at}${p.reason ? ` (${p.reason})` : ""}`);
856
+ return lines;
857
+ }
858
+
859
+ //#endregion
860
+ //#region src/daemon/quiet.ts
861
+ /**
862
+ * Daemon quiet mode: for benchmark and overnight runs, defer every maintenance
863
+ * job (registry scan, index/embed passes, triple extraction, the nightly
864
+ * cycle, disk-scanning housekeeping) and run each deferred kind once when
865
+ * quiet ends. Session notes, summaries and the context handover are never
866
+ * gated: compaction needs them.
867
+ *
868
+ * State lives in config.yaml as `quiet: { on: true, until?: <ISO> }`, so the
869
+ * CLI and the daemon share it without IPC. Jobs ask `deferIfQuiet(kind)` at
870
+ * their dispatch point; `flushDeferredIfEnded` (polled by the daemon) runs the
871
+ * registered runner of each deferred kind once.
872
+ */
873
+ /** How often the daemon checks whether quiet has ended. */
874
+ const QUIET_POLL_MS = 3e4;
875
+ /** Parse the `quiet` config value. An expired `until` reads as off. */
876
+ function parseQuiet(raw, now = Date.now()) {
877
+ if (!raw || typeof raw !== "object") return { on: false };
878
+ const q = raw;
879
+ if (q.on !== true) return { on: false };
880
+ if (q.until === void 0 || q.until === null) return { on: true };
881
+ const until = typeof q.until === "number" ? q.until : Date.parse(String(q.until));
882
+ if (Number.isNaN(until)) return { on: true };
883
+ return until > now ? {
884
+ on: true,
885
+ until
886
+ } : { on: false };
887
+ }
888
+ /** Next local time-of-day HH:MM strictly after `now`. Throws on bad input. */
889
+ function nextOccurrence(hhmm, now = Date.now()) {
890
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
891
+ if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new Error(`--until expects HH:MM (00:00-23:59), got "${hhmm}"`);
892
+ const d = new Date(now);
893
+ d.setHours(Number(m[1]), Number(m[2]), 0, 0);
894
+ if (d.getTime() <= now) d.setDate(d.getDate() + 1);
895
+ return d.getTime();
896
+ }
897
+ let source = () => readMainConfigRaw()["quiet"];
898
+ /** Current state, read fresh from config; an unreadable config reads as not quiet. */
899
+ function readQuiet(now = Date.now()) {
900
+ try {
901
+ return parseQuiet(source(), now);
902
+ } catch {
903
+ return { on: false };
904
+ }
905
+ }
906
+ /** CLI side: persist quiet on (optionally until `until` epoch ms) or off. */
907
+ function writeQuiet(on, until) {
908
+ const raw = readMainConfigRaw();
909
+ if (on) raw["quiet"] = until === void 0 ? { on: true } : {
910
+ on: true,
911
+ until: new Date(until).toISOString()
912
+ };
913
+ else delete raw["quiet"];
914
+ writeMainConfigRaw(raw);
915
+ }
916
+ /** One-line description for `pai daemon quiet status` and `pai daemon status`. */
917
+ function describeQuiet(s) {
918
+ if (!s.on) return "off";
919
+ return s.until === void 0 ? "on (until `pai daemon quiet off`)" : `on until ${new Date(s.until).toLocaleString()}`;
920
+ }
921
+ const deferred = /* @__PURE__ */ new Set();
922
+ const runners = /* @__PURE__ */ new Map();
923
+ /** What to run (once) for a kind when quiet ends. */
924
+ function registerQuietRunner(kind, run) {
925
+ runners.set(kind, run);
926
+ }
927
+ /** True (and the kind is remembered for later) while quiet; false = run now. */
928
+ function deferIfQuiet(kind, now = Date.now()) {
929
+ if (!readQuiet(now).on) return false;
930
+ if (!deferred.has(kind)) process.stderr.write(`[pai-daemon] quiet: deferring ${kind}.\n`);
931
+ deferred.add(kind);
932
+ return true;
933
+ }
934
+ /**
935
+ * When quiet is over, run each deferred kind once, in a fixed order. Returns
936
+ * the kinds run. A runner that throws is logged and not retried.
937
+ */
938
+ async function flushDeferredIfEnded(now = Date.now()) {
939
+ if (readQuiet(now).on || deferred.size === 0) return [];
940
+ const kinds = [
941
+ "registry-scan",
942
+ "index",
943
+ "maintenance",
944
+ "embed",
945
+ "triple-extraction",
946
+ "obsidian-sync"
947
+ ].filter((k) => deferred.has(k));
948
+ deferred.clear();
949
+ for (const kind of kinds) try {
950
+ await runners.get(kind)?.();
951
+ } catch (e) {
952
+ process.stderr.write(`[pai-daemon] quiet: deferred ${kind} failed: ${e instanceof Error ? e.message : String(e)}\n`);
953
+ }
954
+ process.stderr.write(`[pai-daemon] quiet ended: ran deferred ${kinds.join(", ")}.\n`);
955
+ return kinds;
956
+ }
957
+ /** Daemon side: poll for the end of quiet. */
958
+ function startQuietWatcher() {
959
+ const timer = setInterval(() => void flushDeferredIfEnded(), QUIET_POLL_MS);
960
+ if (timer.unref) timer.unref();
961
+ }
962
+
963
+ //#endregion
964
+ //#region src/audit/session-usage.ts
965
+ /**
966
+ * session-usage.ts — parse the usage numbers out of a Claude Code session
967
+ * (or subagent, or pai-worker event-mirror) JSONL transcript.
968
+ *
969
+ * All three log shapes share the same assistant-message envelope
970
+ * ({ type: "assistant", message: { id, model, usage } }), so one parser
971
+ * covers `pai audit tokens session` and the per-log readings feeding
972
+ * `pai audit tokens spawn`.
973
+ *
974
+ * Streaming writes one JSONL line per content block of the same logical
975
+ * turn, repeating message.id and usage each time — summing every line would
976
+ * multiply usage by the block count. Keeping only the first line seen per
977
+ * message.id is what the reference script (and this parser) count instead.
978
+ */
979
+ const USAGE_KEYS = [
980
+ "cache_read_input_tokens",
981
+ "cache_creation_input_tokens",
982
+ "input_tokens",
983
+ "output_tokens"
984
+ ];
985
+ function emptyTotals() {
986
+ return {
987
+ cache_read_input_tokens: 0,
988
+ cache_creation_input_tokens: 0,
989
+ input_tokens: 0,
990
+ output_tokens: 0
991
+ };
992
+ }
993
+ /**
994
+ * `type:"system"` `subtype:"compact_boundary"` lines mark a compaction.
995
+ * turnIndex is the count of assistant turns already folded when it fired,
996
+ * so it lines up with the turn numbering the text/JSON report prints.
997
+ */
998
+ function isCompactBoundary(line) {
999
+ return line.type === "system" && line.subtype === "compact_boundary";
1000
+ }
1001
+ function parseCompactionEvent(line, turnIndex) {
1002
+ const meta = line.compactMetadata;
1003
+ if (!meta || typeof meta.preTokens !== "number") return null;
1004
+ return {
1005
+ trigger: meta.trigger ?? "unknown",
1006
+ preTokens: meta.preTokens,
1007
+ turnIndex
1008
+ };
1009
+ }
1010
+ /**
1011
+ * `type:"system"` `subtype:"model_refusal_fallback"` lines mark an
1012
+ * automatic model switch fired by an API safety refusal (e.g. "cyber"
1013
+ * category), not a user or config choice — worth flagging separately since
1014
+ * it can rebuild the whole prompt cache mid-session.
1015
+ */
1016
+ function isModelFallback(line) {
1017
+ return line.type === "system" && line.subtype === "model_refusal_fallback";
1018
+ }
1019
+ function parseFallbackEvent(line, turnIndex) {
1020
+ return {
1021
+ turnIndex,
1022
+ from: line.originalModel ?? "unknown",
1023
+ to: line.fallbackModel ?? "unknown",
1024
+ category: line.apiRefusalCategory ?? "unknown",
1025
+ scope: line.scope ?? "unknown"
1026
+ };
1027
+ }
1028
+ /**
1029
+ * Fold one already-parsed JSONL line into a report being accumulated.
1030
+ * Exported separately so both the streaming file reader below and tests
1031
+ * (which build fixtures as arrays of objects, not files) share one path.
1032
+ * Returns the turn's context value when a new turn was counted, else null
1033
+ * (non-assistant line, no usage, or a duplicate message.id already seen).
1034
+ */
1035
+ function foldAssistantLine(report, seenIds, line, threshold) {
1036
+ if (line.type !== "assistant") return null;
1037
+ const message = line.message;
1038
+ const usage = message?.usage;
1039
+ if (!usage) return null;
1040
+ const id = message?.id ?? line.uuid;
1041
+ if (id) {
1042
+ if (seenIds.has(id)) return null;
1043
+ seenIds.add(id);
1044
+ }
1045
+ report.turns++;
1046
+ for (const key of USAGE_KEYS) {
1047
+ const v = usage[key];
1048
+ if (typeof v === "number") report.totals[key] += v;
1049
+ }
1050
+ const context = (Number(usage.cache_read_input_tokens) || 0) + (Number(usage.cache_creation_input_tokens) || 0) + (Number(usage.input_tokens) || 0);
1051
+ if (report.firstTurnContext === null) {
1052
+ report.firstTurnContext = context;
1053
+ report.firstTurnAt = line.timestamp ?? null;
1054
+ }
1055
+ const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;
1056
+ if (!Number.isNaN(atMs)) {
1057
+ if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 3600 * 1e3) report.idleGapsOver60min++;
1058
+ report.lastTurnAtMs = atMs;
1059
+ }
1060
+ report.lastTurnContext = context;
1061
+ report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);
1062
+ if (context > threshold) report.turnsAboveThreshold++;
1063
+ if ((Number(usage.cache_creation_input_tokens) || 0) > 2e4) report.cacheRebuildTurns++;
1064
+ const model = message?.model ?? "unknown";
1065
+ report.models[model] = (report.models[model] ?? 0) + 1;
1066
+ if (report.lastModel !== null && report.lastModel !== model) report.modelSwitches.push({
1067
+ turnIndex: report.turns,
1068
+ from: report.lastModel,
1069
+ to: model,
1070
+ cacheRead: Number(usage.cache_read_input_tokens) || 0,
1071
+ cacheCreation: Number(usage.cache_creation_input_tokens) || 0
1072
+ });
1073
+ report.lastModel = model;
1074
+ const split = usage.cache_creation;
1075
+ if (split) {
1076
+ report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;
1077
+ report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;
1078
+ }
1079
+ return context;
1080
+ }
1081
+ /**
1082
+ * True for a real human-authored `type:"user"` prompt line: content is a
1083
+ * plain string, or a content array with no `tool_result` block. Claude Code
1084
+ * encodes tool results as `type:"user"` messages whose content array is
1085
+ * entirely (or partly) tool_result blocks — those must not count as prompts.
1086
+ */
1087
+ function isRealUserPrompt(line) {
1088
+ if (line.type !== "user") return false;
1089
+ if (line.isMeta) return false;
1090
+ if (line.origin?.kind && line.origin.kind !== "human") return false;
1091
+ const content = line.message?.content;
1092
+ if (typeof content === "string") return true;
1093
+ if (Array.isArray(content)) return !content.some((block) => block?.type === "tool_result");
1094
+ return false;
1095
+ }
1096
+ /** Plain text of a real user prompt: the string content, or its first text block. */
1097
+ function extractUserPromptText(line) {
1098
+ const content = line.message?.content;
1099
+ if (typeof content === "string") return content;
1100
+ if (Array.isArray(content)) return content.find((b) => b?.type === "text")?.text ?? "";
1101
+ return "";
1102
+ }
1103
+ /**
1104
+ * Parse a session/subagent/worker-event JSONL file into a usage report.
1105
+ * `keepaliveWord`, when given, counts real user prompts whose text exactly
1106
+ * matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into
1107
+ * `keepaliveBeats`; omitted, `keepaliveBeats` stays null.
1108
+ */
1109
+ async function parseSessionUsage(path, threshold = 2e5, keepaliveWord) {
1110
+ const report = {
1111
+ path,
1112
+ sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,
1113
+ turns: 0,
1114
+ totals: emptyTotals(),
1115
+ models: {},
1116
+ firstTurnContext: null,
1117
+ lastTurnContext: null,
1118
+ cacheCreationSplit: {
1119
+ ephemeral5m: 0,
1120
+ ephemeral1h: 0
1121
+ },
1122
+ avgContext: null,
1123
+ maxContext: null,
1124
+ turnsAboveThreshold: 0,
1125
+ cacheRebuildTurns: 0,
1126
+ userPrompts: 0,
1127
+ promptExposure: 0,
1128
+ compactions: [],
1129
+ firstTurnAt: null,
1130
+ lastModel: null,
1131
+ modelSwitches: [],
1132
+ fallbacks: [],
1133
+ lastTurnAtMs: null,
1134
+ idleGapsOver60min: 0,
1135
+ keepaliveBeats: keepaliveWord ? 0 : null
1136
+ };
1137
+ const seenIds = /* @__PURE__ */ new Set();
1138
+ let contextSum = 0;
1139
+ const rl = createInterface({
1140
+ input: createReadStream(path, "utf8"),
1141
+ crlfDelay: Infinity
1142
+ });
1143
+ for await (const raw of rl) {
1144
+ if (!raw.trim()) continue;
1145
+ let obj;
1146
+ try {
1147
+ obj = JSON.parse(raw);
1148
+ } catch {
1149
+ continue;
1150
+ }
1151
+ if (obj.type === "user") {
1152
+ if (isRealUserPrompt(obj)) {
1153
+ report.userPrompts++;
1154
+ if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;
1155
+ }
1156
+ continue;
1157
+ }
1158
+ if (isCompactBoundary(obj)) {
1159
+ const event = parseCompactionEvent(obj, report.turns);
1160
+ if (event) report.compactions.push(event);
1161
+ continue;
1162
+ }
1163
+ if (isModelFallback(obj)) {
1164
+ report.fallbacks.push(parseFallbackEvent(obj, report.turns));
1165
+ continue;
1166
+ }
1167
+ const context = foldAssistantLine(report, seenIds, obj, threshold);
1168
+ if (context !== null) {
1169
+ contextSum += context;
1170
+ report.promptExposure += report.userPrompts;
1171
+ }
1172
+ }
1173
+ report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;
1174
+ return report;
1175
+ }
1176
+ function totalUsageTokens(totals) {
1177
+ return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;
1178
+ }
1179
+
1180
+ //#endregion
1181
+ //#region src/daemon/session-keepalive.ts
1182
+ /**
1183
+ * session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live
1184
+ * interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts
1185
+ * and docs/cache-keepalive.md, "Interactive sessions").
1186
+ *
1187
+ * One beat = one trivial prompt typed into a session through AIBroker's
1188
+ * send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`
1189
+ * so the target sees only the bare word typed into its input line and
1190
+ * nothing is queued into its mailbox as a peer message demanding a reply.
1191
+ * A cache READ refreshes the provider's ephemeral prompt-cache TTL at
1192
+ * roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next
1193
+ * real prompt.
1194
+ *
1195
+ * Distinct from workers/keepalive.ts, which beats a *worker provider's*
1196
+ * cache on a fixed timer regardless of activity: this only beats a session
1197
+ * that has actually been idle long enough to be at risk, and only inside a
1198
+ * configured working-hours window, so it never fires while the user is
1199
+ * present at the keyboard or overnight when nobody will read the reply
1200
+ * before the cache would have expired anyway.
1201
+ */
1202
+ function sessionKeepaliveStatePath() {
1203
+ return paiHomePath("session-keepalive.json");
1204
+ }
1205
+ function emptyEntry() {
1206
+ return {
1207
+ beats: 0,
1208
+ lastRealPromptKey: null,
1209
+ lastBeatAt: null
1210
+ };
1211
+ }
1212
+ function loadSessionKeepaliveState(path = sessionKeepaliveStatePath()) {
1213
+ if (!existsSync(path)) return {};
1214
+ try {
1215
+ return JSON.parse(readFileSync(path, "utf8"));
1216
+ } catch {
1217
+ return {};
1218
+ }
1219
+ }
1220
+ function saveSessionKeepaliveState(state, path = sessionKeepaliveStatePath()) {
1221
+ writeJsonAtomic(path, state, {
1222
+ backup: false,
1223
+ label: path
1224
+ });
1225
+ }
1226
+ /** Parse "HH:MM-HH:MM" into minutes-since-midnight. Throws on a malformed
1227
+ * spec — an explicit config error beats a window that is silently always
1228
+ * on or always off. */
1229
+ function parseActiveHours(spec) {
1230
+ const m = spec.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
1231
+ if (!m) throw new Error(`sessions.cacheKeepalive.activeHours: invalid "${spec}" (want "HH:MM-HH:MM")`);
1232
+ return {
1233
+ startMin: Number(m[1]) * 60 + Number(m[2]),
1234
+ endMin: Number(m[3]) * 60 + Number(m[4])
1235
+ };
1236
+ }
1237
+ /**
1238
+ * Whether `now` (local time) sits inside the window. A window that wraps
1239
+ * midnight (e.g. "22:00-06:00") is honoured by inverting the test instead of
1240
+ * requiring startMin < endMin.
1241
+ */
1242
+ function isWithinActiveHours(now, spec) {
1243
+ const { startMin, endMin } = parseActiveHours(spec);
1244
+ const nowMin = now.getHours() * 60 + now.getMinutes();
1245
+ if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;
1246
+ return nowMin >= startMin || nowMin < endMin;
1247
+ }
1248
+ /**
1249
+ * Full path to a live session's transcript under ~/.claude/projects, or null
1250
+ * when none is found — the case for a session too new to have written a file
1251
+ * yet. A worker running in a worktree DOES write a transcript here (Claude
1252
+ * Code encodes its worktree cwd as the project dir name), so workers are not
1253
+ * excluded "for free" — see isWorkerSession.
1254
+ */
1255
+ function findSessionTranscript(sessionId, projectsDir = join(homedir(), ".claude", "projects")) {
1256
+ let projectDirs;
1257
+ try {
1258
+ projectDirs = readdirSync(projectsDir);
1259
+ } catch {
1260
+ return null;
1261
+ }
1262
+ for (const projectDir of projectDirs) {
1263
+ const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);
1264
+ if (existsSync(candidate)) return candidate;
1265
+ }
1266
+ return null;
1267
+ }
1268
+ /**
1269
+ * AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id
1270
+ * (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`
1271
+ * transcripts are named after — the two are unrelated identifiers. The
1272
+ * status line bridges them on every refresh via `claude-session-map.json`
1273
+ * (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the
1274
+ * map entries whose `term` pane UUID matches, the most recently written one.
1275
+ * Returns null when the pane has no (fresh enough) mapped Claude session.
1276
+ */
1277
+ function resolveClaudeSessionIdFromMap(paneId, logDir) {
1278
+ const path = sessionMapPath(logDir);
1279
+ if (!paneId || !existsSync(path)) return null;
1280
+ let map;
1281
+ try {
1282
+ map = JSON.parse(readFileSync(path, "utf8"));
1283
+ } catch {
1284
+ return null;
1285
+ }
1286
+ let best = null;
1287
+ for (const entry of Object.values(map)) {
1288
+ if (!entry.term || itermUuid(entry.term) !== paneId) continue;
1289
+ if (!best || entry.ts > best.ts) best = entry;
1290
+ }
1291
+ return best?.session ?? null;
1292
+ }
1293
+ /** Claude Code's project-dir encoding of a cwd: every "/" becomes "-". */
1294
+ function encodeProjectDirName(cwd) {
1295
+ return cwd.replace(/\//g, "-");
1296
+ }
1297
+ /**
1298
+ * Is this live "claude"-kind session actually a worker pane rather than an
1299
+ * interactive one? `claude -p` workers running in a worktree write their
1300
+ * transcript under ~/.claude/projects too (encoded cwd = the worktree dir),
1301
+ * so a missing transcript is NOT how workers get excluded — this predicate
1302
+ * is. Either signal is enough:
1303
+ * (a) the transcript's project-dir name is the encoded form of a path
1304
+ * under <logDir>/worktrees (every worker worktree lives there), or
1305
+ * (b) the broker's session name/paiName names a path under the workers
1306
+ * log dir or one of its worktrees (best-effort: AIBroker does not
1307
+ * guarantee this, but honors it when present).
1308
+ */
1309
+ function isWorkerSession(meta, transcriptPath, logDir) {
1310
+ const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));
1311
+ if (transcriptPath) {
1312
+ if ((transcriptPath.split("/").slice(0, -1).pop() ?? "").startsWith(worktreesPrefix)) return true;
1313
+ }
1314
+ const rawPrefix = worktreesDir(logDir);
1315
+ for (const field of [meta.name, meta.paiName]) if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;
1316
+ return false;
1317
+ }
1318
+ function isMidTurn(line) {
1319
+ if (!line) return false;
1320
+ if (line.type === "user" && isRealUserPrompt(line)) return true;
1321
+ if (line.type === "assistant") {
1322
+ const stopReason = line.message?.stop_reason;
1323
+ if (stopReason === void 0 || stopReason === null) return true;
1324
+ return stopReason !== "end_turn" && stopReason !== "stop_sequence";
1325
+ }
1326
+ return false;
1327
+ }
1328
+ function readTranscriptSnapshot(path) {
1329
+ let lines;
1330
+ try {
1331
+ lines = readFileSync(path, "utf8").split("\n");
1332
+ } catch {
1333
+ return {
1334
+ context: null,
1335
+ midTurn: false,
1336
+ lastRealPrompt: null
1337
+ };
1338
+ }
1339
+ let lastLine = null;
1340
+ let context = null;
1341
+ let lastRealPrompt = null;
1342
+ for (let i = lines.length - 1; i >= 0; i--) {
1343
+ const raw = lines[i].trim();
1344
+ if (!raw) continue;
1345
+ let obj;
1346
+ try {
1347
+ obj = JSON.parse(raw);
1348
+ } catch {
1349
+ continue;
1350
+ }
1351
+ if (lastLine === null) lastLine = obj;
1352
+ if (context === null && obj.type === "assistant" && obj.message?.usage) {
1353
+ const usage = obj.message.usage;
1354
+ context = (Number(usage.cache_read_input_tokens) || 0) + (Number(usage.cache_creation_input_tokens) || 0) + (Number(usage.input_tokens) || 0);
1355
+ }
1356
+ if (lastRealPrompt === null && isRealUserPrompt(obj)) lastRealPrompt = {
1357
+ key: obj.uuid ?? obj.timestamp ?? String(i),
1358
+ text: extractUserPromptText(obj).trim()
1359
+ };
1360
+ if (context !== null && lastRealPrompt !== null) break;
1361
+ }
1362
+ return {
1363
+ context,
1364
+ midTurn: isMidTurn(lastLine),
1365
+ lastRealPrompt
1366
+ };
1367
+ }
1368
+ /** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */
1369
+ const SESSION_KEEPALIVE_EVENT = "SESSION-KEEPALIVE";
1370
+ function sessionKeepaliveLedgerPath() {
1371
+ const { workers } = readWorkersSection();
1372
+ return join(workersLogDir(workers), "ledger.log");
1373
+ }
1374
+ /** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */
1375
+ function sessionKeepaliveLedgerSummary(path = sessionKeepaliveLedgerPath(), lastN = 10) {
1376
+ if (!existsSync(path)) return {
1377
+ sent: 0,
1378
+ skipped: 0,
1379
+ lastLines: []
1380
+ };
1381
+ const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));
1382
+ const sent = lines.filter((l) => / result=sent(\s|$)/.test(l)).length;
1383
+ return {
1384
+ sent,
1385
+ skipped: lines.length - sent,
1386
+ lastLines: lines.slice(-lastN)
1387
+ };
1388
+ }
1389
+ function defaultDeps() {
1390
+ const { workers } = readWorkersSection();
1391
+ const logDir = workersLogDir(workers);
1392
+ return {
1393
+ now: () => /* @__PURE__ */ new Date(),
1394
+ fetchLiveSessions,
1395
+ resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),
1396
+ findTranscript: (id) => findSessionTranscript(id),
1397
+ mtimeMs: (p) => {
1398
+ try {
1399
+ return statSync(p).mtimeMs;
1400
+ } catch {
1401
+ return null;
1402
+ }
1403
+ },
1404
+ readSnapshot: readTranscriptSnapshot,
1405
+ sendBeat: (id, text) => sendToSession(id, text, void 0, { noReply: true }),
1406
+ loadState: () => loadSessionKeepaliveState(),
1407
+ saveState: (s) => saveSessionKeepaliveState(s),
1408
+ ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv)
1409
+ };
1410
+ }
1411
+ /**
1412
+ * One tick over every live interactive session: beat the ones that qualify,
1413
+ * skip (with a reason) the ones that don't. Returns one result per live
1414
+ * "claude"-kind session for tests to assert against; writes the ledger line
1415
+ * and (when any counter changed) the state file as side effects.
1416
+ */
1417
+ async function runSessionKeepaliveTick(config, overrides = {}) {
1418
+ if (!config.enabled) return [];
1419
+ const d = {
1420
+ ...defaultDeps(),
1421
+ ...overrides
1422
+ };
1423
+ const sessions = await d.fetchLiveSessions();
1424
+ const state = d.loadState();
1425
+ const now = d.now();
1426
+ const { workers } = readWorkersSection();
1427
+ const logDir = workersLogDir(workers);
1428
+ const results = [];
1429
+ let anyChanged = false;
1430
+ for (const s of sessions) {
1431
+ if (s.kind !== "claude") continue;
1432
+ const paneId = s.sessionId;
1433
+ const claudeId = d.resolveClaudeSessionId(paneId);
1434
+ if (!claudeId) {
1435
+ d.ledger({
1436
+ session: null,
1437
+ pane: paneId,
1438
+ result: "skipped:unmapped"
1439
+ });
1440
+ results.push({
1441
+ sessionId: paneId,
1442
+ result: "skipped:unmapped"
1443
+ });
1444
+ continue;
1445
+ }
1446
+ const sessionId = claudeId;
1447
+ const entry = { ...state[sessionId] ?? emptyEntry() };
1448
+ let changed = false;
1449
+ const transcript = d.findTranscript(sessionId);
1450
+ if (isWorkerSession(s, transcript, logDir)) {
1451
+ d.ledger({
1452
+ session: sessionId,
1453
+ pane: paneId,
1454
+ result: "skipped:worker"
1455
+ });
1456
+ results.push({
1457
+ sessionId,
1458
+ result: "skipped:worker"
1459
+ });
1460
+ continue;
1461
+ }
1462
+ if (!transcript) {
1463
+ d.ledger({
1464
+ session: sessionId,
1465
+ pane: paneId,
1466
+ result: "skipped:no-transcript"
1467
+ });
1468
+ results.push({
1469
+ sessionId,
1470
+ result: "skipped:no-transcript"
1471
+ });
1472
+ continue;
1473
+ }
1474
+ const snapshot = d.readSnapshot(transcript);
1475
+ if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {
1476
+ entry.lastRealPromptKey = snapshot.lastRealPrompt.key;
1477
+ if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;
1478
+ changed = true;
1479
+ }
1480
+ const mtime = d.mtimeMs(transcript);
1481
+ const idleMin = mtime === null ? null : (now.getTime() - mtime) / 6e4;
1482
+ const skip = mtime === null || idleMin === null ? "no-mtime" : idleMin < config.idleMinutes ? `idle:${idleMin.toFixed(1)}min` : !isWithinActiveHours(now, config.activeHours) ? "hours" : snapshot.context === null || snapshot.context < config.minContextTokens ? "context" : snapshot.midTurn ? "mid-turn" : entry.beats >= config.maxBeats ? "max-beats" : null;
1483
+ const ledgerBase = {
1484
+ session: sessionId,
1485
+ pane: paneId,
1486
+ idle_min: idleMin === null ? null : idleMin.toFixed(1),
1487
+ context: snapshot.context
1488
+ };
1489
+ if (skip) {
1490
+ d.ledger({
1491
+ ...ledgerBase,
1492
+ beat: `${entry.beats}/${config.maxBeats}`,
1493
+ result: `skipped:${skip}`
1494
+ });
1495
+ results.push({
1496
+ sessionId,
1497
+ result: `skipped:${skip}`
1498
+ });
1499
+ } else if ((await d.sendBeat(paneId, config.prompt)).ok) {
1500
+ entry.beats += 1;
1501
+ entry.lastBeatAt = now.toISOString();
1502
+ changed = true;
1503
+ d.ledger({
1504
+ ...ledgerBase,
1505
+ beat: `${entry.beats}/${config.maxBeats}`,
1506
+ result: "sent"
1507
+ });
1508
+ results.push({
1509
+ sessionId,
1510
+ result: "sent"
1511
+ });
1512
+ } else {
1513
+ d.ledger({
1514
+ ...ledgerBase,
1515
+ beat: `${entry.beats}/${config.maxBeats}`,
1516
+ result: "skipped:send-failed"
1517
+ });
1518
+ results.push({
1519
+ sessionId,
1520
+ result: "skipped:send-failed"
1521
+ });
1522
+ }
1523
+ if (changed) {
1524
+ state[sessionId] = entry;
1525
+ anyChanged = true;
1526
+ }
1527
+ }
1528
+ if (anyChanged) d.saveState(state);
1529
+ return results;
1530
+ }
1531
+
1532
+ //#endregion
1533
+ //#region src/memory/wakeup.ts
1534
+ /**
1535
+ * Wake-up context system — progressive context loading inspired by mempalace.
1536
+ *
1537
+ * Layers:
1538
+ * L0 Identity (~100 tokens) — user identity from ~/.pai/identity.txt. Always loaded.
1539
+ * L1 Essential Story (~500-800t) — top session notes for the project, key lines extracted.
1540
+ * L2 On-Demand — triggered by topic queries (handled by memory_search).
1541
+ * L3 Deep Search — unlimited federated memory search (memory_search tool).
1542
+ */
1543
+ /** Maximum tokens for the L1 essential story block. Approx 4 chars/token.
1544
+ * Measured 2026-09-20: 800 tokens was mostly stale bullets. Reduced to 300.
1545
+ */
1546
+ const L1_TOKEN_BUDGET = 300;
1547
+ L1_TOKEN_BUDGET * 4;
1548
+ /** Maximum session notes to scan when building L1. Reduced from 10 to 3 (2026-09-20). */
1549
+ const L1_MAX_NOTES = 3;
1550
+ /** Sections to extract from session notes (in priority order). */
1551
+ const EXTRACT_SECTIONS = [
1552
+ "Work Done",
1553
+ "Key Decisions",
1554
+ "Next Steps",
1555
+ "Checkpoint"
1556
+ ];
1557
+ /** Identity file location. */
1558
+ const IDENTITY_FILE = () => join(homedir(), ".pai", "identity.txt");
1559
+ /**
1560
+ * Load L0 identity. Prefers the PAI_HOME location (identity.txt migrated by
1561
+ * `pai config migrate`), falling back to the legacy ~/.pai/identity.txt so a
1562
+ * not-yet-migrated file keeps working. Returns "" if neither exists. Never
1563
+ * throws.
1564
+ */
1565
+ function loadL0Identity(opts = {}) {
1566
+ const newPath = opts.newPath ?? paiHomePath("identity.txt");
1567
+ const legacyPath = opts.legacyPath ?? IDENTITY_FILE();
1568
+ const path = existsSync(newPath) ? newPath : legacyPath;
1569
+ if (!existsSync(path)) return "";
1570
+ try {
1571
+ return readFileSync(path, "utf-8").trim();
1572
+ } catch {
1573
+ return "";
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Move ~/.pai/identity.txt into PAI_HOME, following the same copy /
1578
+ * verify-byte-identical / rename-aside semantics as every other per-user
1579
+ * file (see migratePaiFile). `from`/`to` are overridable for tests.
1580
+ */
1581
+ function migrateIdentityFile(opts = {}) {
1582
+ return migratePaiFile(opts.to ?? paiHomePath("identity.txt"), [opts.from ?? IDENTITY_FILE()], { dryRun: opts.dryRun });
1583
+ }
1584
+ /**
1585
+ * Find the Notes directory for a project given its root_path from the registry.
1586
+ * Checks local Notes/ first, then central ~/.claude/projects/... path.
1587
+ */
1588
+ function findNotesDirForProject(rootPath) {
1589
+ const localCandidates = [
1590
+ join(rootPath, "Notes"),
1591
+ join(rootPath, "notes"),
1592
+ join(rootPath, ".claude", "Notes")
1593
+ ];
1594
+ for (const p of localCandidates) if (existsSync(p)) return p;
1595
+ const encoded = rootPath.replace(/\//g, "-").replace(/\./g, "-").replace(/ /g, "-");
1596
+ const centralNotes = join(homedir(), ".claude", "projects", encoded, "Notes");
1597
+ if (existsSync(centralNotes)) return centralNotes;
1598
+ return null;
1599
+ }
1600
+ /**
1601
+ * Recursively find all .md session note files in a Notes directory.
1602
+ * Handles both flat layout (Notes/*.md) and month-subdirectory layout
1603
+ * (Notes/YYYY/MM/*.md). Returns files sorted newest-first by filename
1604
+ * (note numbers are monotonically increasing, so lexicographic = newest-last,
1605
+ * so we reverse).
1606
+ */
1607
+ function findSessionNotes(notesDir) {
1608
+ const result = [];
1609
+ const scanDir = (dir) => {
1610
+ if (!existsSync(dir)) return;
1611
+ let entries;
1612
+ try {
1613
+ entries = readdirSync(dir, { withFileTypes: true }).map((e) => ({
1614
+ name: e.name,
1615
+ isDir: e.isDirectory()
1616
+ }));
1617
+ } catch {
1618
+ return;
1619
+ }
1620
+ for (const entry of entries) {
1621
+ const fullPath = join(dir, entry.name);
1622
+ if (entry.isDir) scanDir(fullPath);
1623
+ else if (entry.name.match(/^\d{3,4}[\s_-].*\.md$/)) result.push(fullPath);
1624
+ }
1625
+ };
1626
+ scanDir(notesDir);
1627
+ const dateOf = (p) => basename(p).match(/(\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
1628
+ const numberOf = (p) => parseInt(basename(p).match(/^(\d+)/)?.[1] ?? "0", 10);
1629
+ const mtimeOf = (p) => {
1630
+ try {
1631
+ return statSync(p).mtimeMs;
1632
+ } catch {
1633
+ return 0;
1634
+ }
1635
+ };
1636
+ result.sort((a, b) => {
1637
+ const dateA = dateOf(a);
1638
+ const dateB = dateOf(b);
1639
+ if (dateA !== dateB) {
1640
+ if (!dateA) return 1;
1641
+ if (!dateB) return -1;
1642
+ return dateB.localeCompare(dateA);
1643
+ }
1644
+ const byNumber = numberOf(b) - numberOf(a);
1645
+ if (byNumber !== 0) return byNumber;
1646
+ return mtimeOf(b) - mtimeOf(a);
1647
+ });
1648
+ return result;
1649
+ }
1650
+ /**
1651
+ * Extract the most important lines from a session note.
1652
+ * Prioritises: Work Done items, Key Decisions, Next Steps, Checkpoint headings.
1653
+ * Returns a condensed string under maxChars.
1654
+ */
1655
+ function normalizeLine(line) {
1656
+ return line.replace(/^(- \[[ x]\] |- |\* |\d+\.\s)/, "").trim().replace(/\s+/g, " ");
1657
+ }
1658
+ function extractKeyLines(content, maxChars, seen = /* @__PURE__ */ new Set(), seenLabels = /* @__PURE__ */ new Set()) {
1659
+ const lines = content.split("\n");
1660
+ const selected = [];
1661
+ let inTargetSection = false;
1662
+ let currentSection = "";
1663
+ let charCount = 0;
1664
+ let pendingLabel = null;
1665
+ for (const line of lines) {
1666
+ const h2Match = line.match(/^## (.+)$/);
1667
+ const h3Match = line.match(/^### (.+)$/);
1668
+ if (h2Match) {
1669
+ currentSection = h2Match[1];
1670
+ inTargetSection = EXTRACT_SECTIONS.some((s) => currentSection.toLowerCase().includes(s.toLowerCase()));
1671
+ pendingLabel = null;
1672
+ continue;
1673
+ }
1674
+ if (h3Match) {
1675
+ if (inTargetSection) pendingLabel = h3Match[1];
1676
+ continue;
1677
+ }
1678
+ if (!inTargetSection) continue;
1679
+ const trimmed = line.trim();
1680
+ if (!trimmed || trimmed.startsWith("<!--") || trimmed === "---") continue;
1681
+ if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.match(/^\d+\./) || trimmed.startsWith("**")) {
1682
+ const normalized = normalizeLine(trimmed);
1683
+ if (seen.has(normalized)) continue;
1684
+ let labelText = "";
1685
+ let labelKey = "";
1686
+ if (pendingLabel !== null) {
1687
+ labelKey = pendingLabel.toLowerCase().trim();
1688
+ if (!seenLabels.has(labelKey)) labelText = `[${pendingLabel}]`;
1689
+ }
1690
+ if (charCount + labelText.length + trimmed.length + 2 > maxChars) break;
1691
+ if (labelText) {
1692
+ selected.push(labelText);
1693
+ seenLabels.add(labelKey);
1694
+ charCount += labelText.length + 1;
1695
+ }
1696
+ pendingLabel = null;
1697
+ seen.add(normalized);
1698
+ selected.push(trimmed);
1699
+ charCount += trimmed.length + 1;
1700
+ }
1701
+ }
1702
+ return selected.join("\n");
1703
+ }
1704
+ /**
1705
+ * Build the L1 essential story block.
1706
+ *
1707
+ * Reads the most recent session notes for the project and extracts the key
1708
+ * lines (Work Done, Key Decisions, Next Steps) within the token budget.
1709
+ *
1710
+ * @param rootPath The project root path (from the registry).
1711
+ * @param tokenBudget Max tokens to consume. Default 800 (~3200 chars).
1712
+ * @returns Formatted L1 block, or empty string if no notes found.
1713
+ */
1714
+ function buildL1EssentialStory(rootPath, tokenBudget = L1_TOKEN_BUDGET) {
1715
+ const charBudget = tokenBudget * 4;
1716
+ const notesDir = findNotesDirForProject(rootPath);
1717
+ if (!notesDir) return "";
1718
+ const noteFiles = findSessionNotes(notesDir).slice(0, L1_MAX_NOTES);
1719
+ if (noteFiles.length === 0) return "";
1720
+ const sections = [];
1721
+ const seen = /* @__PURE__ */ new Set();
1722
+ const seenLabels = /* @__PURE__ */ new Set();
1723
+ let remaining = charBudget;
1724
+ for (const noteFile of noteFiles) {
1725
+ if (remaining <= 50) break;
1726
+ let content;
1727
+ try {
1728
+ content = readFileSync(noteFile, "utf-8");
1729
+ } catch {
1730
+ continue;
1731
+ }
1732
+ const name = basename(noteFile);
1733
+ const titleMatch = name.match(/^\d+ - (\d{4}-\d{2}-\d{2}) - (.+)\.md$/);
1734
+ const dateLabel = titleMatch ? titleMatch[1] : "";
1735
+ const titleLabel = titleMatch ? titleMatch[2] : name.replace(/^\d+ - /, "").replace(/\.md$/, "");
1736
+ const perNoteChars = Math.min(remaining, Math.floor(charBudget / noteFiles.length) + 200);
1737
+ const extracted = extractKeyLines(content, perNoteChars, seen, seenLabels);
1738
+ const hasContentLine = extracted.split("\n").some((l) => !/^\[.*\]$/.test(l));
1739
+ if (!extracted || !hasContentLine) continue;
1740
+ const noteBlock = `[${dateLabel} - ${titleLabel}]\n${extracted}`;
1741
+ sections.push(noteBlock);
1742
+ remaining -= noteBlock.length + 1;
1743
+ }
1744
+ if (sections.length === 0) return "";
1745
+ return sections.join("\n\n");
1746
+ }
1747
+ /**
1748
+ * Build the combined wake-up context block (L0 + L1).
1749
+ *
1750
+ * Returns a formatted string suitable for injection as a system-reminder,
1751
+ * or an empty string if both layers are empty.
1752
+ *
1753
+ * @param rootPath Project root path for L1 note lookup. Optional.
1754
+ * @param tokenBudget L1 token budget. Default 800.
1755
+ * @param opts.skipStory Skip the L1 essential story. Set when a handover
1756
+ * checkpoint was already injected — the story re-summarises the same note
1757
+ * the handover came from, at lower quality.
1758
+ */
1759
+ function buildWakeupContext(rootPath, tokenBudget = L1_TOKEN_BUDGET, opts = {}) {
1760
+ const identity = loadL0Identity();
1761
+ const essentialStory = rootPath && !opts.skipStory ? buildL1EssentialStory(rootPath, tokenBudget) : "";
1762
+ if (!identity && !essentialStory) return "";
1763
+ const parts = [];
1764
+ if (identity) parts.push(`## L0 Identity\n\n${identity}`);
1765
+ if (essentialStory) parts.push(`## L1 Essential Story\n\n${essentialStory}`);
1766
+ return parts.join("\n\n");
1767
+ }
1768
+
1769
+ //#endregion
1770
+ //#region src/obsidian/enabled.ts
1771
+ /** Switch for the Obsidian integration that WRITES into the PAI-generated vault (sync, master notes, symlinks): `obsidian: { enabled: true }` in config.yaml (default off). Reading/indexing `vaultPath` is separate: `vaultIndex.enabled`. */
1772
+ const OBSIDIAN_DISABLED_MESSAGE = "Obsidian sync is disabled. Enable it with: pai config set obsidian.enabled true";
1773
+ /** Read fresh from config; an unreadable config reads as disabled. */
1774
+ function obsidianEnabled() {
1775
+ try {
1776
+ const o = readMainConfigRaw()["obsidian"];
1777
+ return !!o && typeof o === "object" && o.enabled === true;
1778
+ } catch {
1779
+ return false;
1780
+ }
1781
+ }
1782
+ /** Vault indexing (read-only, `vaultPath`) is on unless `vaultIndex: { enabled: false }`; independent of `obsidian.enabled`. */
1783
+ function vaultIndexEnabled() {
1784
+ try {
1785
+ const v = readMainConfigRaw()["vaultIndex"];
1786
+ return !(v && typeof v === "object" && v.enabled === false);
1787
+ } catch {
1788
+ return true;
1789
+ }
1790
+ }
1791
+
1792
+ //#endregion
1793
+ //#region src/hooks/ts/lib/pai-paths.ts
1794
+ /**
1795
+ * PAI Path Resolution - Single Source of Truth
1796
+ *
1797
+ * This module provides consistent path resolution across all PAI hooks.
1798
+ *
1799
+ * Two different things live here, and they must not be confused:
1800
+ *
1801
+ * - ADAPTER_DIR (~/.claude by default) is the Claude Code harness adapter —
1802
+ * fixed, hardcoded paths the harness itself loads from (Hooks/, skills/,
1803
+ * agents/, commands/, settings.json, statusline-command.sh,
1804
+ * tab-color-command.sh). It is harness-specific: a future harness would
1805
+ * need its own adapter directory with its own conventions.
1806
+ * - PAI_HOME (~/.pai by default — see ../../../config/pai-home.ts) is
1807
+ * where PAI's own state lives: everything hooks WRITE (History/,
1808
+ * agent-sessions.json, session-routing.json, ...) resolves there, with a
1809
+ * fallback to the pre-2026-09-19 ADAPTER_DIR location and a one-time
1810
+ * stderr notice, exactly like every other PAI_HOME file.
1811
+ *
1812
+ * ALSO loads .env file from ADAPTER_DIR so all hooks get environment
1813
+ * variables without relying on Claude Code's settings.json injection.
1814
+ *
1815
+ * Usage in hooks:
1816
+ * import { ADAPTER_DIR, HOOKS_DIR, SKILLS_DIR, historyDir } from './lib/pai-paths';
1817
+ */
1818
+ /**
1819
+ * Load .env file and inject into process.env
1820
+ * Must run BEFORE ADAPTER_DIR resolution so .env can set ADAPTER_DIR/PAI_DIR if needed
1821
+ */
1822
+ function loadEnvFile() {
1823
+ const possiblePaths = [resolve$1(process.env.ADAPTER_DIR || process.env.PAI_DIR || "", ".env"), resolve$1(homedir$1(), ".claude", ".env")];
1824
+ for (const envPath of possiblePaths) if (existsSync$1(envPath)) try {
1825
+ const content = readFileSync$1(envPath, "utf-8");
1826
+ for (const line of content.split("\n")) {
1827
+ const trimmed = line.trim();
1828
+ if (!trimmed || trimmed.startsWith("#")) continue;
1829
+ const eqIndex = trimmed.indexOf("=");
1830
+ if (eqIndex > 0) {
1831
+ const key = trimmed.substring(0, eqIndex).trim();
1832
+ let value = trimmed.substring(eqIndex + 1).trim();
1833
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
1834
+ value = value.replace(/\$HOME/g, homedir$1());
1835
+ value = value.replace(/^~(?=\/|$)/, homedir$1());
1836
+ if (process.env[key] === void 0) process.env[key] = value;
1837
+ }
1838
+ }
1839
+ break;
1840
+ } catch {}
1841
+ }
1842
+ loadEnvFile();
1843
+ function defaultAdapterDir() {
1844
+ return resolve$1(homedir$1(), ".claude");
1845
+ }
1846
+ /**
1847
+ * Never print when stdout/stderr is a data channel, not a human: hook
1848
+ * bundles and worker-status-line.mjs set PAI_QUIET_NOTICES=1 via their
1849
+ * esbuild banner (scripts/build-hooks.mjs), and a `pai worker run
1850
+ * --output-format json` invocation is detected directly off argv since its
1851
+ * env can't be set before this module's static imports resolve.
1852
+ */
1853
+ function suppressDeprecationNotices() {
1854
+ if (process.env.PAI_QUIET_NOTICES === "1") return true;
1855
+ const idx = process.argv.indexOf("--output-format");
1856
+ return idx !== -1 && process.argv[idx + 1] === "json";
1857
+ }
1858
+ /**
1859
+ * At most once per process — a `globalThis` flag rather than a module-level
1860
+ * `let` because hooks and the CLI can end up with more than one instance of
1861
+ * this module loaded into the same process (separate bundles), each with its
1862
+ * own module scope; only a property on the shared global survives that.
1863
+ */
1864
+ function warnPaiDirDeprecatedOnce(message) {
1865
+ const g = globalThis;
1866
+ if (g.__paiDirNoticePrinted || suppressDeprecationNotices()) return;
1867
+ g.__paiDirNoticePrinted = true;
1868
+ process.stderr.write(`pai: ${message}\n`);
1869
+ }
1870
+ /**
1871
+ * Smart ADAPTER_DIR detection with fallback
1872
+ * Priority:
1873
+ * 1. ADAPTER_DIR environment variable (if set) — warns once if PAI_DIR is
1874
+ * ALSO set and resolves to a different path (naming both).
1875
+ * 2. PAI_DIR environment variable (deprecated alias, one release) — warns
1876
+ * once only when it differs from the default adapter root; PAI_DIR set to
1877
+ * the same value as the default is the operator's current, correct
1878
+ * setting and stays silent.
1879
+ * 3. ~/.claude (standard location)
1880
+ */
1881
+ function resolveAdapterDir() {
1882
+ if (process.env.ADAPTER_DIR) {
1883
+ const adapterDir = resolve$1(process.env.ADAPTER_DIR);
1884
+ if (process.env.PAI_DIR) {
1885
+ const paiDir = resolve$1(process.env.PAI_DIR);
1886
+ if (paiDir !== adapterDir) warnPaiDirDeprecatedOnce(`ADAPTER_DIR (${adapterDir}) and PAI_DIR (${paiDir}) are both set and differ — ADAPTER_DIR wins. PAI_DIR still works for one release.`);
1887
+ }
1888
+ return adapterDir;
1889
+ }
1890
+ if (process.env.PAI_DIR) {
1891
+ const paiDir = resolve$1(process.env.PAI_DIR);
1892
+ if (paiDir !== defaultAdapterDir()) warnPaiDirDeprecatedOnce("PAI_DIR is deprecated — use ADAPTER_DIR for the harness adapter root (~/.claude). PAI_DIR still works for one release.");
1893
+ return paiDir;
1894
+ }
1895
+ return defaultAdapterDir();
1896
+ }
1897
+ /** The Claude Code harness adapter root — NOT PAI's state home. See module doc above. */
1898
+ const ADAPTER_DIR = resolveAdapterDir();
1899
+ /**
1900
+ * Adapter directories — fixed paths the Claude Code harness itself loads
1901
+ * from. These stay under ADAPTER_DIR; they are not PAI state.
1902
+ */
1903
+ const HOOKS_DIR = join$1(ADAPTER_DIR, "Hooks");
1904
+ const SKILLS_DIR = join$1(ADAPTER_DIR, "skills");
1905
+ const AGENTS_DIR = join$1(ADAPTER_DIR, "agents");
1906
+ const COMMANDS_DIR = join$1(ADAPTER_DIR, "commands");
1907
+
1908
+ //#endregion
1909
+ //#region src/hooks/ts/lib/project-utils/paths.ts
1910
+ /**
1911
+ * Path utilities — encoding, Notes/Sessions directory discovery and creation.
1912
+ */
1913
+ const PROJECTS_DIR = join$1(ADAPTER_DIR, "projects");
1914
+ /**
1915
+ * Encode a path the same way Claude Code does:
1916
+ * - Replace / with -
1917
+ * - Replace . with -
1918
+ * - Replace space with -
1919
+ */
1920
+ function encodePath(path) {
1921
+ return path.replace(/\//g, "-").replace(/\./g, "-").replace(/ /g, "-");
1922
+ }
1923
+ /** Get the project directory for a given working directory. */
1924
+ function getProjectDir(cwd) {
1925
+ return join$1(PROJECTS_DIR, encodePath(cwd));
1926
+ }
1927
+ /** Get the Notes directory for a project (central location). */
1928
+ function getNotesDir(cwd) {
1929
+ return join$1(getProjectDir(cwd), "Notes");
1930
+ }
1931
+ /**
1932
+ * Find Notes directory — checks local first, falls back to central.
1933
+ * Does NOT create the directory.
1934
+ */
1935
+ function findNotesDir(cwd) {
1936
+ if (basename$1(cwd).toLowerCase() === "notes" && existsSync$1(cwd)) return {
1937
+ path: cwd,
1938
+ isLocal: true
1939
+ };
1940
+ const localPaths = [
1941
+ join$1(cwd, "Notes"),
1942
+ join$1(cwd, "notes"),
1943
+ join$1(cwd, ".claude", "Notes")
1944
+ ];
1945
+ for (const path of localPaths) if (existsSync$1(path)) return {
1946
+ path,
1947
+ isLocal: true
1948
+ };
1949
+ return {
1950
+ path: getNotesDir(cwd),
1951
+ isLocal: false
1952
+ };
1953
+ }
1954
+ /** Get the sessions/ directory from a project directory path. */
1955
+ function getSessionsDirFromProjectDir(projectDir) {
1956
+ return join$1(projectDir, "sessions");
1957
+ }
1958
+ /** Ensure the sessions/ directory exists (from project dir path). */
1959
+ function ensureSessionsDirFromProjectDir(projectDir) {
1960
+ const sessionsDir = getSessionsDirFromProjectDir(projectDir);
1961
+ if (!existsSync$1(sessionsDir)) {
1962
+ mkdirSync$1(sessionsDir, { recursive: true });
1963
+ console.error(`Created sessions directory: ${sessionsDir}`);
1964
+ }
1965
+ return sessionsDir;
1966
+ }
1967
+ /**
1968
+ * Publish every project-root .jsonl into sessions/ as well, WITHOUT removing it.
1969
+ *
1970
+ * This used to renameSync, and that is how PAI destroyed its users' sessions.
1971
+ *
1972
+ * `claude --resume <uuid>` finds a transcript only at the project root. Move it
1973
+ * into sessions/ and the session becomes permanently unresumable — measured
1974
+ * 2026-08-04: `claude --resume b3462801` (867 KB of real work, sessions/ only)
1975
+ * answers "No conversation found with session ID", while a top-level id is found
1976
+ * fine. Nothing warned; the id still looked valid everywhere PAI displayed it.
1977
+ *
1978
+ * The damage was not occasional. This ran from a UserPromptSubmit hook excluding
1979
+ * only the CURRENT session, so every prompt anyone typed unresumed every other
1980
+ * session in the project. One PAI project measured 1 transcript at top level
1981
+ * against 52 underneath. Among the casualties was 046bb712 — the exact id PAI's
1982
+ * own handover tells the user to resume.
1983
+ *
1984
+ * A hardlink satisfies both sides, which is why this is a two-line fix rather
1985
+ * than a redesign: the archive genuinely has consumers that read sessions/
1986
+ * (session-summary-worker, registry/moved, session/autosave), and `--resume`
1987
+ * needs the root path. One inode, two names, no copy, no window where the file
1988
+ * is missing from either place.
1989
+ *
1990
+ * Never unlink the source. Tidying up another tool's store was the whole
1991
+ * mistake; a stale duplicate is free, a lost session is not. Every caller of
1992
+ * `transcriptFiles()` was checked before choosing this — they all test emptiness
1993
+ * (`.length > 0`), never count, so the duplicate cannot skew a project's stats.
1994
+ *
1995
+ * `excludeFile` keeps a hook from archiving the transcript it is itself watching
1996
+ * being written. It is not a "finished sessions only" guard and must not be read
1997
+ * as one: it excludes exactly one file, the caller's own, so with two sessions
1998
+ * live in one project each still archives the other mid-turn. A consumer that
1999
+ * needs "finished only" has to enforce that itself — this function cannot know
2000
+ * what is live.
2001
+ *
2002
+ * Returns the number of files newly archived.
2003
+ */
2004
+ function archiveSessionFilesToSessionsDir(projectDir, excludeFile, silent = false) {
2005
+ const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);
2006
+ if (!existsSync$1(projectDir)) return 0;
2007
+ const files = readdirSync$1(projectDir);
2008
+ let archivedCount = 0;
2009
+ for (const file of files) {
2010
+ if (!file.endsWith(".jsonl") || file === excludeFile) continue;
2011
+ const sourcePath = join$1(projectDir, file);
2012
+ const destPath = join$1(sessionsDir, file);
2013
+ if (existsSync$1(destPath)) continue;
2014
+ try {
2015
+ linkSync$1(sourcePath, destPath);
2016
+ if (!silent) console.error(`Archived ${file} → sessions/ (still resumable)`);
2017
+ archivedCount++;
2018
+ } catch (error) {
2019
+ try {
2020
+ copyFileSync$1(sourcePath, destPath);
2021
+ if (!silent) console.error(`Copied ${file} → sessions/ (hardlink unavailable)`);
2022
+ archivedCount++;
2023
+ } catch {
2024
+ if (!silent) console.error(`Could not archive ${file}: ${error}`);
2025
+ }
2026
+ }
2027
+ }
2028
+ return archivedCount;
2029
+ }
2030
+ /** Find TODO.md — check local first, fallback to central. */
2031
+ function findTodoPath(cwd) {
2032
+ const localPaths = [
2033
+ join$1(cwd, "TODO.md"),
2034
+ join$1(cwd, "notes", "TODO.md"),
2035
+ join$1(cwd, "Notes", "TODO.md"),
2036
+ join$1(cwd, ".claude", "TODO.md")
2037
+ ];
2038
+ for (const path of localPaths) if (existsSync$1(path)) return path;
2039
+ return join$1(getNotesDir(cwd), "TODO.md");
2040
+ }
2041
+
2042
+ //#endregion
2043
+ //#region src/hooks/ts/lib/project-utils/session-notes.ts
2044
+ /**
2045
+ * Session note creation, editing, checkpointing, renaming, and finalization.
2046
+ */
2047
+ /** Get or create the YYYY/MM subdirectory for the current month inside notesDir. */
2048
+ function getMonthDir(notesDir) {
2049
+ const now = /* @__PURE__ */ new Date();
2050
+ const monthDir = join$1(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0"));
2051
+ if (!existsSync$1(monthDir)) mkdirSync$1(monthDir, { recursive: true });
2052
+ return monthDir;
2053
+ }
2054
+ /**
2055
+ * Get the next note number (4-digit format: 0001, 0002, etc.).
2056
+ * Numbers are scoped per YYYY/MM directory.
2057
+ */
2058
+ function getNextNoteNumber(notesDir) {
2059
+ const files = readdirSync$1(getMonthDir(notesDir)).filter((f) => f.match(/^\d{3,4}[\s_-]/)).sort();
2060
+ if (files.length === 0) return "0001";
2061
+ let maxNumber = 0;
2062
+ for (const file of files) {
2063
+ const digitMatch = file.match(/^(\d+)/);
2064
+ if (digitMatch) {
2065
+ const num = parseInt(digitMatch[1], 10);
2066
+ if (num > maxNumber) maxNumber = num;
2067
+ }
2068
+ }
2069
+ return String(maxNumber + 1).padStart(4, "0");
2070
+ }
2071
+ /**
2072
+ * Get the current (latest) note file path, or null if none exists.
2073
+ * Searches current month → previous month → flat notesDir (legacy).
2074
+ */
2075
+ function getCurrentNotePath(notesDir) {
2076
+ if (!existsSync$1(notesDir)) return null;
2077
+ const findLatestIn = (dir) => {
2078
+ if (!existsSync$1(dir)) return null;
2079
+ const files = readdirSync$1(dir).filter((f) => f.match(/^\d{3,4}[\s_-].*\.md$/)).sort((a, b) => {
2080
+ return parseInt(a.match(/^(\d+)/)?.[1] || "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] || "0", 10);
2081
+ });
2082
+ if (files.length === 0) return null;
2083
+ return join$1(dir, files[files.length - 1]);
2084
+ };
2085
+ const now = /* @__PURE__ */ new Date();
2086
+ const found = findLatestIn(join$1(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
2087
+ if (found) return found;
2088
+ const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
2089
+ const prevFound = findLatestIn(join$1(notesDir, String(prevDate.getFullYear()), String(prevDate.getMonth() + 1).padStart(2, "0")));
2090
+ if (prevFound) return prevFound;
2091
+ return findLatestIn(notesDir);
2092
+ }
2093
+ /**
2094
+ * Create a new session note.
2095
+ * Format: "NNNN - YYYY-MM-DD - New Session.md" filed into YYYY/MM subdirectory.
2096
+ * Claude MUST rename at session end with a meaningful description.
2097
+ */
2098
+ function createSessionNote(notesDir, description) {
2099
+ const noteNumber = getNextNoteNumber(notesDir);
2100
+ const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2101
+ const monthDir = getMonthDir(notesDir);
2102
+ const filename = `${noteNumber} - ${date} - New Session.md`;
2103
+ const filepath = join$1(monthDir, filename);
2104
+ writeFileSync$1(filepath, `# Session ${noteNumber}: ${description}
2105
+
2106
+ **Date:** ${date}
2107
+ **Status:** In Progress
2108
+
2109
+ ---
2110
+
2111
+ ## Work Done
2112
+
2113
+ <!-- PAI will add completed work here during session -->
2114
+
2115
+ ---
2116
+
2117
+ ## Next Steps
2118
+
2119
+ <!-- To be filled at session end -->
2120
+
2121
+ ---
2122
+
2123
+ **Tags:** #Session
2124
+ `);
2125
+ console.error(`Created session note: ${filename}`);
2126
+ return filepath;
2127
+ }
2128
+ /** Append a checkpoint to the current session note. */
2129
+ function appendCheckpoint(notePath, checkpoint) {
2130
+ if (!existsSync$1(notePath)) {
2131
+ console.error(`Note file not found, recreating: ${notePath}`);
2132
+ try {
2133
+ const parentDir = join$1(notePath, "..");
2134
+ if (!existsSync$1(parentDir)) mkdirSync$1(parentDir, { recursive: true });
2135
+ const noteFilename = basename$1(notePath);
2136
+ const numberMatch = noteFilename.match(/^(\d+)/);
2137
+ writeFileSync$1(notePath, `# Session ${numberMatch ? numberMatch[1] : "0000"}: Recovered\n\n**Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`);
2138
+ console.error(`Recreated session note: ${noteFilename}`);
2139
+ } catch (err) {
2140
+ console.error(`Failed to recreate note: ${err}`);
2141
+ return;
2142
+ }
2143
+ }
2144
+ const content = readFileSync$1(notePath, "utf-8");
2145
+ const checkpointText = `\n### Checkpoint ${(/* @__PURE__ */ new Date()).toISOString()}\n\n${checkpoint}\n`;
2146
+ const nextStepsIndex = content.indexOf("## Next Steps");
2147
+ writeFileSync$1(notePath, nextStepsIndex !== -1 ? content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex) : content + checkpointText);
2148
+ console.error(`Checkpoint added to: ${basename$1(notePath)}`);
2149
+ }
2150
+ /** Add work items to the "Work Done" section of a session note. */
2151
+ function addWorkToSessionNote(notePath, workItems, sectionTitle) {
2152
+ if (!existsSync$1(notePath)) {
2153
+ console.error(`Note file not found: ${notePath}`);
2154
+ return;
2155
+ }
2156
+ let content = readFileSync$1(notePath, "utf-8");
2157
+ let workText = "";
2158
+ if (sectionTitle) workText += `\n### ${sectionTitle}\n\n`;
2159
+ for (const item of workItems) {
2160
+ const checkbox = item.completed !== false ? "[x]" : "[ ]";
2161
+ workText += `- ${checkbox} **${item.title}**\n`;
2162
+ if (item.details && item.details.length > 0) for (const detail of item.details) workText += ` - ${detail}\n`;
2163
+ }
2164
+ const workDoneMatch = content.match(/## Work Done\n\n(<!-- .*? -->)?/);
2165
+ if (workDoneMatch) {
2166
+ const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;
2167
+ content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);
2168
+ } else {
2169
+ const nextStepsIndex = content.indexOf("## Next Steps");
2170
+ if (nextStepsIndex !== -1) content = content.substring(0, nextStepsIndex) + workText + "\n" + content.substring(nextStepsIndex);
2171
+ }
2172
+ writeFileSync$1(notePath, content);
2173
+ console.error(`Added ${workItems.length} work item(s) to: ${basename$1(notePath)}`);
2174
+ }
2175
+ /** Sanitize a string for use in a filename. */
2176
+ function sanitizeForFilename(str) {
2177
+ return str.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 50);
2178
+ }
2179
+ /**
2180
+ * Return true if the candidate string should be rejected as a meaningful name.
2181
+ * Rejects file paths, shebangs, timestamps, system noise, XML tags, hashes, etc.
2182
+ */
2183
+ function isMeaninglessCandidate(text) {
2184
+ const t = text.trim();
2185
+ if (!t) return true;
2186
+ if (t.length < 5) return true;
2187
+ if (t.startsWith("/") || t.startsWith("~")) return true;
2188
+ if (t.startsWith("#!")) return true;
2189
+ if (t.includes("[object Object]")) return true;
2190
+ if (/^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/.test(t)) return true;
2191
+ if (/^\d{1,2}:\d{2}(:\d{2})?(\s*(AM|PM))?$/i.test(t)) return true;
2192
+ if (/^<[a-z-]+[\s/>]/i.test(t)) return true;
2193
+ if (/^[0-9a-f]{10,}$/i.test(t)) return true;
2194
+ if (/^Exit code \d+/i.test(t)) return true;
2195
+ if (/^Error:/i.test(t)) return true;
2196
+ if (/^This session is being continued/i.test(t)) return true;
2197
+ if (/^\(Bash completed/i.test(t)) return true;
2198
+ if (/^Task Notification$/i.test(t)) return true;
2199
+ if (/^New Session$/i.test(t)) return true;
2200
+ if (/^Recovered Session$/i.test(t)) return true;
2201
+ if (/^Continued Session$/i.test(t)) return true;
2202
+ if (/^Untitled Session$/i.test(t)) return true;
2203
+ if (/^Context Compression$/i.test(t)) return true;
2204
+ if (/^[A-Fa-f0-9]{8,}\s+Output$/i.test(t)) return true;
2205
+ return false;
2206
+ }
2207
+ /**
2208
+ * Extract a meaningful name from session note content and summary.
2209
+ * Looks at Work Done section headers, bold text, and summary.
2210
+ */
2211
+ function extractMeaningfulName(noteContent, summary) {
2212
+ const workDoneMatch = noteContent.match(/## Work Done\n\n([\s\S]*?)(?=\n---|\n## Next)/);
2213
+ if (workDoneMatch) {
2214
+ const workDoneSection = workDoneMatch[1];
2215
+ const subheadings = workDoneSection.match(/### ([^\n]+)/g);
2216
+ if (subheadings && subheadings.length > 0) {
2217
+ const firstHeading = subheadings[0].replace("### ", "").trim();
2218
+ if (!isMeaninglessCandidate(firstHeading) && firstHeading.length > 5 && firstHeading.length < 60) return sanitizeForFilename(firstHeading);
2219
+ }
2220
+ const boldMatches = workDoneSection.match(/\*\*([^*]+)\*\*/g);
2221
+ if (boldMatches && boldMatches.length > 0) {
2222
+ const firstBold = boldMatches[0].replace(/\*\*/g, "").trim();
2223
+ if (!isMeaninglessCandidate(firstBold) && firstBold.length > 3 && firstBold.length < 50) return sanitizeForFilename(firstBold);
2224
+ }
2225
+ const numberedItems = workDoneSection.match(/^\d+\.\s+\*\*([^*]+)\*\*/m);
2226
+ if (numberedItems && !isMeaninglessCandidate(numberedItems[1])) return sanitizeForFilename(numberedItems[1]);
2227
+ }
2228
+ if (summary && summary.length > 5 && summary !== "Session completed." && !isMeaninglessCandidate(summary)) {
2229
+ const cleanSummary = summary.replace(/[^\w\s-]/g, " ").trim().split(/\s+/).slice(0, 5).join(" ");
2230
+ if (cleanSummary.length > 3 && !isMeaninglessCandidate(cleanSummary)) return sanitizeForFilename(cleanSummary);
2231
+ }
2232
+ return "";
2233
+ }
2234
+ /**
2235
+ * Rename a session note with a meaningful name.
2236
+ * Always uses "NNNN - YYYY-MM-DD - Description.md" format.
2237
+ * Returns the new path, or original path if rename fails.
2238
+ */
2239
+ function renameSessionNote(notePath, meaningfulName) {
2240
+ if (!meaningfulName || !existsSync$1(notePath)) return notePath;
2241
+ const dir = join$1(notePath, "..");
2242
+ const oldFilename = basename$1(notePath);
2243
+ const correctMatch = oldFilename.match(/^(\d{3,4}) - (\d{4}-\d{2}-\d{2}) - .*\.md$/);
2244
+ const legacyMatch = oldFilename.match(/^(\d{3,4})_(\d{4}-\d{2}-\d{2})_.*\.md$/);
2245
+ const match = correctMatch || legacyMatch;
2246
+ if (!match) return notePath;
2247
+ const [, noteNumber, date] = match;
2248
+ const titleCaseName = meaningfulName.replace(/[\/\\:\0]/g, " ").split(/[\s_-]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ").trim();
2249
+ const newFilename = `${noteNumber.padStart(4, "0")} - ${date} - ${titleCaseName}.md`;
2250
+ const newPath = join$1(dir, newFilename);
2251
+ if (newFilename === oldFilename) return notePath;
2252
+ try {
2253
+ renameSync$1(notePath, newPath);
2254
+ console.error(`Renamed note: ${oldFilename} → ${newFilename}`);
2255
+ return newPath;
2256
+ } catch (error) {
2257
+ console.error(`Could not rename note: ${error}`);
2258
+ return notePath;
2259
+ }
2260
+ }
2261
+ /**
2262
+ * Finalize session note — mark as complete, add summary, rename with meaningful name.
2263
+ * IDEMPOTENT: subsequent calls are no-ops if already finalized.
2264
+ * Returns the final path (may be renamed).
2265
+ */
2266
+ function finalizeSessionNote(notePath, summary) {
2267
+ if (!existsSync$1(notePath)) {
2268
+ console.error(`Note file not found: ${notePath}`);
2269
+ return notePath;
2270
+ }
2271
+ let content = readFileSync$1(notePath, "utf-8");
2272
+ if (content.includes("**Status:** Completed")) {
2273
+ console.error(`Note already finalized: ${basename$1(notePath)}`);
2274
+ return notePath;
2275
+ }
2276
+ content = content.replace("**Status:** In Progress", "**Status:** Completed");
2277
+ if (!content.includes("**Completed:**")) {
2278
+ const completionTime = (/* @__PURE__ */ new Date()).toISOString();
2279
+ content = content.replace("---\n\n## Work Done", `**Completed:** ${completionTime}\n\n---\n\n## Work Done`);
2280
+ }
2281
+ const nextStepsMatch = content.match(/## Next Steps\n\n(<!-- .*? -->)/);
2282
+ if (nextStepsMatch) content = content.replace(nextStepsMatch[0], `## Next Steps\n\n${summary || "Session completed."}`);
2283
+ writeFileSync$1(notePath, content);
2284
+ console.error(`Session note finalized: ${basename$1(notePath)}`);
2285
+ const meaningfulName = extractMeaningfulName(content, summary);
2286
+ if (meaningfulName) return renameSessionNote(notePath, meaningfulName);
2287
+ return notePath;
2288
+ }
2289
+
2290
+ //#endregion
2291
+ //#region src/hooks/ts/lib/project-utils/todo.ts
2292
+ /**
2293
+ * TODO.md management — creation, task updates, checkpoints, and Continue section.
2294
+ */
2295
+ /**
2296
+ * Ensure TODO.md exists. Creates it with default structure if missing.
2297
+ * Returns the path to the TODO.md file.
2298
+ */
2299
+ function ensureTodoMd(cwd) {
2300
+ const todoPath = findTodoPath(cwd);
2301
+ if (!existsSync$1(todoPath)) {
2302
+ const parentDir = join$1(todoPath, "..");
2303
+ if (!existsSync$1(parentDir)) mkdirSync$1(parentDir, { recursive: true });
2304
+ writeFileSync$1(todoPath, `# TODO
2305
+
2306
+ ## Current Session
2307
+
2308
+ - [ ] (Tasks will be tracked here)
2309
+
2310
+ ## Backlog
2311
+
2312
+ - [ ] (Future tasks)
2313
+
2314
+ ---
2315
+
2316
+ *Last updated: ${(/* @__PURE__ */ new Date()).toISOString()}*
2317
+ `);
2318
+ console.error(`Created TODO.md: ${todoPath}`);
2319
+ }
2320
+ return todoPath;
2321
+ }
2322
+ /**
2323
+ * The Claude Code session UUID, read off the transcript path.
2324
+ *
2325
+ * Claude Code names a transcript `<session-uuid>.jsonl`, so the hooks that
2326
+ * receive a transcript path are holding the session's identity without knowing
2327
+ * it. Both session-end writers had one available and neither used it.
2328
+ *
2329
+ * Shape-checked rather than trusted: only a UUID is returned, so a renamed,
2330
+ * archived or otherwise unexpected filename yields undefined and the caller
2331
+ * falls back to the session line rather than writing a `session-id` that
2332
+ * identifies nothing. A wrong id is worse than none — it would make two
2333
+ * different sessions compare equal.
2334
+ */
2335
+ function sessionIdFromTranscript(transcriptPath) {
2336
+ if (!transcriptPath) return void 0;
2337
+ const base = basename$1(transcriptPath).replace(/\.jsonl$/, "");
2338
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(base) ? base : void 0;
2339
+ }
2340
+ /**
2341
+ * Update the ## Continue section at the top of TODO.md.
2342
+ *
2343
+ * This is the unattended writer: the pre-compact hook and the daemon's
2344
+ * work-queue worker both come through here. It used to build its own block and
2345
+ * strip the existing section with `/## Continue\n[\s\S]*?\n---\n+/` — a
2346
+ * non-greedy match to the first `---`, which cuts a rich checkpoint in half at
2347
+ * the first horizontal rule inside its body and leaves the remainder orphaned
2348
+ * in the document.
2349
+ *
2350
+ * More importantly it was the writer that actually destroyed model-authored
2351
+ * checkpoints: `pai pause` wrote one, then this ran seconds later on session
2352
+ * end and replaced it with `Working directory: … Check the latest session note
2353
+ * for details.`
2354
+ *
2355
+ * It now delegates to the shared checkpoint module in "auto" mode, which means
2356
+ * it inherits the preservation rules rather than reimplementing them. Guarding
2357
+ * here rather than at each of the three call sites is deliberate: any future
2358
+ * caller inherits the behaviour without knowing it exists.
2359
+ */
2360
+ function updateTodoContinue(cwd, noteFilename, state, tokenDisplay, sessionId) {
2361
+ ensureTodoMd(cwd);
2362
+ const result = applyContinue({
2363
+ rootPath: cwd,
2364
+ authored: "auto",
2365
+ sessionLine: noteFilename.replace(/\.md$/, ""),
2366
+ sessionId,
2367
+ cwd,
2368
+ body: state?.trim() || void 0
2369
+ });
2370
+ if (result.action === "preserved") {
2371
+ console.error("TODO.md ## Continue left intact — authored checkpoint for this session");
2372
+ return;
2373
+ }
2374
+ if (result.path === null) {
2375
+ console.error(result.action === "written" ? "handover written to forge issue" : "forge handover left intact — nothing to say");
2376
+ return;
2377
+ }
2378
+ if (result.action === "failed") {
2379
+ console.error(`TODO.md ## Continue update failed: ${result.error}`);
2380
+ return;
2381
+ }
2382
+ try {
2383
+ const todoPath = result.path;
2384
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2385
+ let content = readFileSync$1(todoPath, "utf-8");
2386
+ content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, "");
2387
+ content = content.trimEnd() + `\n\n---\n\n*Last updated: ${now}*\n`;
2388
+ writeFileSync$1(todoPath, content);
2389
+ } catch {}
2390
+ console.error(result.carriedForward ? "TODO.md ## Continue section updated (previous content carried forward)" : "TODO.md ## Continue section updated");
2391
+ }
2392
+
2393
+ //#endregion
2394
+ //#region src/daemon/templates/session-summary-prompt.ts
2395
+ /**
2396
+ * Build the prompt string to send to the summarizer model.
2397
+ *
2398
+ * Returns a single string suitable for piping to `claude --model <model> --print`.
2399
+ */
2400
+ function buildSessionSummaryPrompt(params) {
2401
+ const { userMessages, gitLog, cwd, date, filesModified, existingNote } = params;
2402
+ const userSection = userMessages.length > 0 ? userMessages.map((m, i) => `[${i + 1}] ${m}`).join("\n\n") : "(No user messages extracted)";
2403
+ const gitSection = gitLog.trim() || "(No git commits during this session)";
2404
+ const filesSection = filesModified && filesModified.length > 0 ? filesModified.map((f) => `- ${f}`).join("\n") : "";
2405
+ return `You are summarizing a coding session. Given the user messages and git commits below, write a session note.
2406
+
2407
+ Project directory: ${cwd}
2408
+ Date: ${date}
2409
+
2410
+ Focus on:
2411
+ - What problems were encountered and how they were solved
2412
+ - Key architectural decisions and their rationale
2413
+ - What was built (reference actual files and code patterns)
2414
+ - What was left unfinished or needs follow-up
2415
+
2416
+ Do NOT include:
2417
+ - Mechanical metadata (token counts, checkpoint timestamps)
2418
+ - System messages or tool results verbatim
2419
+ - Generic descriptions — be specific about what happened
2420
+ - Markdown frontmatter or YAML headers
2421
+ ${existingNote ? `\nAn existing session note is provided below. Merge the new information into it,
2422
+ preserving what was already written. Add new work items and update the summary.
2423
+ Do NOT duplicate existing content.
2424
+
2425
+ EXISTING NOTE:
2426
+ ${existingNote}
2427
+ ` : ""}
2428
+ Format your response EXACTLY as follows (no extra text before or after):
2429
+
2430
+ TOPIC: [A short topic label, max 60 characters, describing the WORK DONE — not quoting user messages. Format as "Topic1, Topic2, and Topic3" if multiple themes. Example: "Session Summary Worker, Topic Detection"]
2431
+
2432
+ # Session: [Descriptive title summarizing what was ACCOMPLISHED, max 60 characters. Describe the work done, not the user's request. Bad: "Dark Mode Button Does Nothing". Good: "Dark Mode Toggle, Keyboard IPC, and Audio Fix"]
2433
+
2434
+ **Date:** ${date}
2435
+ **Status:** In Progress
2436
+
2437
+ ---
2438
+
2439
+ ## Work Done
2440
+
2441
+ [Organize by theme, not chronologically. Group related work under descriptive bullet points.
2442
+ Use checkbox format: - [x] for completed items, - [ ] for incomplete items.
2443
+ Include specific file names, function names, and technical details.]
2444
+
2445
+ ## Key Decisions
2446
+
2447
+ [List important choices made during the session with brief rationale.
2448
+ Skip this section entirely if no significant decisions were made.]
2449
+
2450
+ ## Known Issues
2451
+
2452
+ [What was left unfinished, bugs discovered, or follow-up items needed.
2453
+ Skip this section entirely if nothing is pending.]
2454
+
2455
+ ---
2456
+
2457
+ USER MESSAGES:
2458
+ ${userSection}
2459
+
2460
+ GIT COMMITS:
2461
+ ${gitSection}
2462
+ ${filesSection ? `\nFILES MODIFIED:\n${filesSection}` : ""}`;
2463
+ }
2464
+
2465
+ //#endregion
2466
+ //#region src/daemon/templates/triple-extraction-prompt.ts
2467
+ /**
2468
+ * triple-extraction-prompt.ts — Prompt template for KG triple extraction.
2469
+ *
2470
+ * Used by the session-summary-worker to extract structured facts from
2471
+ * a completed session summary and store them in the temporal knowledge graph.
2472
+ */
2473
+ function buildTripleExtractionPrompt(params) {
2474
+ return `Extract structured entities and relations from this coding session.
2475
+
2476
+ Output a single JSON object with two arrays: "entities" and "relations".
2477
+
2478
+ Entity types: project | person | concept | tool | file | version | decision | technology | organization
2479
+
2480
+ Rules:
2481
+ - Be SPECIFIC: entity names must be concrete (e.g., "FSRS", "Glidr", "the owner")
2482
+ - Use snake_case relation verb phrases (e.g., "uses_algorithm", "decided_to", "shipped_version")
2483
+ - Skip opinions, speculation, and "we should" statements
2484
+ - Skip entities obvious from project metadata unless they have a meaningful relation
2485
+ - Maximum 15 relations per session — pick the most important
2486
+ - Each entity should have a brief description (1 sentence, what it is in this context)
2487
+ - Each relation must reference entity names that appear in the entities array
2488
+
2489
+ Example output:
2490
+ {
2491
+ "entities": [
2492
+ {"name": "Glidr", "type": "project", "description": "Flashcard app using FSRS spaced repetition"},
2493
+ {"name": "FSRS", "type": "concept", "description": "Free Spaced Repetition Scheduler algorithm"},
2494
+ {"name": "the owner", "type": "person", "description": "Developer of Glidr and Quassl"},
2495
+ {"name": "Quassl", "type": "project", "description": "iOS app being rewritten in Flutter"},
2496
+ {"name": "Flutter", "type": "technology", "description": "Cross-platform mobile framework"}
2497
+ ],
2498
+ "relations": [
2499
+ {"source": "Glidr", "relation": "uses_algorithm", "target": "FSRS"},
2500
+ {"source": "Glidr", "relation": "shipped_version", "target": "1.0.5"},
2501
+ {"source": "the owner", "relation": "decided_to_rewrite", "target": "Quassl"},
2502
+ {"source": "Quassl", "relation": "migrating_to", "target": "Flutter"}
2503
+ ]
2504
+ }
2505
+
2506
+ PROJECT: ${params.projectSlug}
2507
+
2508
+ SESSION CONTENT:
2509
+ ${params.sessionContent}
2510
+
2511
+ GIT COMMITS:
2512
+ ${params.gitLog}
2513
+
2514
+ Output ONLY the JSON object with no surrounding prose, no code fences, no trailing commas, and no comments.
2515
+
2516
+ JSON object (entities + relations):`;
2517
+ }
2518
+
2519
+ //#endregion
2520
+ //#region src/workers/daemon-llm.ts
2521
+ /**
2522
+ * daemon-llm.ts — provider-routed LLM spawns for daemon-side background calls.
2523
+ *
2524
+ * Session summaries, context handovers and KG extraction used to spawn a bare
2525
+ * `claude --model <tier>` with the ambient environment: on a machine whose
2526
+ * only login is a worker provider that spawn either dies (no Anthropic
2527
+ * credentials) or bills Anthropic outside the provider abstraction. This
2528
+ * module routes those spawns through the same registry and env build the
2529
+ * worker runner uses (`buildRunEnv`), resolving the tier alias to the
2530
+ * provider's concrete model id. With no usable provider configured it falls
2531
+ * back to the historical behaviour: the tier alias itself, ambient env minus
2532
+ * the Anthropic API key.
2533
+ */
2534
+ /** Timeout per tier (ms) — a class-appropriate budget, not a per-model one. */
2535
+ const LLM_TIMEOUT_MS = {
2536
+ haiku: 6e4,
2537
+ sonnet: 12e4,
2538
+ opus: 3e5
2539
+ };
2540
+ /** Resolve a tier to the provider's concrete model id: the fast capability
2541
+ * for the cheap tier, the default model for the middle and top tiers. */
2542
+ function tierModel(provider, tier) {
2543
+ return resolveModelCapability(provider, tier === "haiku" ? "fast" : "default");
2544
+ }
2545
+ /**
2546
+ * Containment every daemon-side LLM spawn carries. These calls are
2547
+ * text-in/text-out: the prompt carries the context, the answer is the
2548
+ * product, and nothing about summarizing a session justifies a tool. An
2549
+ * unrestricted background summarizer was observed on 2026-09-18 (01:33-01:51)
2550
+ * rewriting the live config with a schema-shaped dump while the daemon ran;
2551
+ * with the tool grant empty and MCP strictly empty, that class of damage is
2552
+ * structurally impossible rather than merely unlikely.
2553
+ */
2554
+ function containmentArgs(logDir) {
2555
+ return [
2556
+ "--strict-mcp-config",
2557
+ "--mcp-config",
2558
+ ensureNoMcpConfig(logDir),
2559
+ "--allowedTools",
2560
+ "",
2561
+ "--tools",
2562
+ ""
2563
+ ];
2564
+ }
2565
+ /** The historical fallback: tier alias as the model id, ambient env with the
2566
+ * Anthropic API key stripped (so the CLI uses its interactive login). */
2567
+ function legacyPlan(tier, config) {
2568
+ const { ANTHROPIC_API_KEY: _drop, ...env } = process.env;
2569
+ return {
2570
+ model: tier,
2571
+ args: [
2572
+ "--model",
2573
+ tier,
2574
+ ...containmentArgs(workersLogDir(config ?? defaultWorkersConfig())),
2575
+ "-p",
2576
+ "--no-session-persistence"
2577
+ ],
2578
+ env,
2579
+ timeoutMs: LLM_TIMEOUT_MS[tier],
2580
+ provider: null
2581
+ };
2582
+ }
2583
+ /**
2584
+ * Plan a daemon-side LLM spawn for a tier. Provider resolution failures (no
2585
+ * config, workers off, no usable provider) fall back to the tier-alias path;
2586
+ * a configured-but-broken provider (unreadable key file) throws, because
2587
+ * silently degrading to Anthropic billing is worse than a failed summary.
2588
+ */
2589
+ async function planLlmSpawn(tier, configPath, probe = probeRouter) {
2590
+ let provider = null;
2591
+ let providerName = null;
2592
+ let config = null;
2593
+ try {
2594
+ const { workers } = readWorkersSection(configPath);
2595
+ config = workers;
2596
+ if (workers.enabled) {
2597
+ const target = resolveTarget(workers, workersLogDir(workers), { sessionEnv: {} });
2598
+ provider = target.provider;
2599
+ providerName = target.providerName;
2600
+ }
2601
+ } catch {
2602
+ provider = null;
2603
+ providerName = null;
2604
+ }
2605
+ if (!provider || !providerName || !config) return legacyPlan(tier, config);
2606
+ const logDir = workersLogDir(config);
2607
+ let proxyUrl;
2608
+ if (provider.protocol === "openai") {
2609
+ mkdirSync(logDir, { recursive: true });
2610
+ proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${providerName}`;
2611
+ }
2612
+ const model = tierModel(provider, tier);
2613
+ const viaRouter = proxyUrl ? void 0 : await loopbackRouterChoice(provider, providerName, void 0, probe);
2614
+ return {
2615
+ model,
2616
+ args: [
2617
+ "--model",
2618
+ model,
2619
+ ...containmentArgs(logDir),
2620
+ "-p",
2621
+ "--no-session-persistence"
2622
+ ],
2623
+ env: buildRunEnv(provider, true, proxyUrl, providerName, viaRouter),
2624
+ timeoutMs: LLM_TIMEOUT_MS[tier],
2625
+ provider: providerName
2626
+ };
2627
+ }
2628
+
2629
+ //#endregion
2630
+ //#region src/memory/kg-extraction.ts
2631
+ /**
2632
+ * kg-extraction.ts — Shared KG triple extraction logic.
2633
+ *
2634
+ * Extracted from session-summary-worker.ts so both the worker and the
2635
+ * CLI backfill (`pai kg backfill`) can use the same code path.
2636
+ *
2637
+ * Provides:
2638
+ * - findClaudeBinary() — locate the claude CLI
2639
+ * - spawnClaude() — generic prompt -> response runner (provider-routed)
2640
+ * - extractAndStoreTriples() — run the extractor prompt and persist triples to Postgres
2641
+ */
2642
+ /**
2643
+ * Find the `claude` CLI binary. Checks common installation locations first
2644
+ * (launchd PATH is minimal so bare "claude" often won't resolve).
2645
+ */
2646
+ function findClaudeBinary$1() {
2647
+ const candidates = [
2648
+ join(homedir(), ".local", "bin", "claude"),
2649
+ join(homedir(), ".claude", "local", "claude"),
2650
+ "/usr/local/bin/claude",
2651
+ "/opt/homebrew/bin/claude"
2652
+ ];
2653
+ for (const candidate of candidates) try {
2654
+ if (existsSync(candidate)) return candidate;
2655
+ } catch {}
2656
+ return "claude";
2657
+ }
2658
+ /**
2659
+ * Spawn the claude CLI with a prompt on stdin and return stdout.
2660
+ *
2661
+ * Routed through the provider registry (planLlmSpawn): the tier resolves to
2662
+ * the configured provider's model id and the env carries its base URL and
2663
+ * token. With no provider configured it degrades to the historical behaviour
2664
+ * — the tier alias itself, ambient env minus ANTHROPIC_API_KEY.
2665
+ */
2666
+ async function spawnClaude(prompt, tier = "sonnet") {
2667
+ const claudeBin = findClaudeBinary$1();
2668
+ if (!claudeBin) {
2669
+ process.stderr.write("[kg-extraction] claude CLI not found.\n");
2670
+ return null;
2671
+ }
2672
+ let plan;
2673
+ try {
2674
+ plan = await planLlmSpawn(tier);
2675
+ } catch (e) {
2676
+ process.stderr.write(`[kg-extraction] could not plan ${tier} spawn: ${e}\n`);
2677
+ return null;
2678
+ }
2679
+ const { spawn } = await import("node:child_process");
2680
+ return new Promise((resolve) => {
2681
+ let timer = null;
2682
+ const child = spawn(claudeBin, plan.args, {
2683
+ env: plan.env,
2684
+ stdio: [
2685
+ "pipe",
2686
+ "pipe",
2687
+ "pipe"
2688
+ ]
2689
+ });
2690
+ let stdout = "";
2691
+ let stderr = "";
2692
+ child.stdout.on("data", (chunk) => {
2693
+ stdout += chunk.toString();
2694
+ });
2695
+ child.stderr.on("data", (chunk) => {
2696
+ stderr += chunk.toString();
2697
+ });
2698
+ child.on("error", (err) => {
2699
+ if (timer) {
2700
+ clearTimeout(timer);
2701
+ timer = null;
2702
+ }
2703
+ process.stderr.write(`[kg-extraction] ${plan.model} spawn error: ${err.message}\n`);
2704
+ resolve(null);
2705
+ });
2706
+ child.on("close", (code) => {
2707
+ if (timer) {
2708
+ clearTimeout(timer);
2709
+ timer = null;
2710
+ }
2711
+ if (code !== 0) {
2712
+ process.stderr.write(`[kg-extraction] ${plan.model} exited ${code}: ${stderr.slice(0, 300)}\n`);
2713
+ resolve(null);
2714
+ } else resolve(stdout.trim() || null);
2715
+ });
2716
+ timer = setTimeout(() => {
2717
+ process.stderr.write(`[kg-extraction] ${plan.model} timed out — killing process.\n`);
2718
+ child.kill("SIGTERM");
2719
+ resolve(null);
2720
+ }, plan.timeoutMs);
2721
+ child.stdin.write(prompt);
2722
+ child.stdin.end();
2723
+ });
2724
+ }
2725
+ /**
2726
+ * Slice text down to the outermost JSON structure, dropping any surrounding
2727
+ * prose or code-fence artifacts the LLM added around the JSON object/array.
2728
+ * Returns the input unchanged if no opening brace/bracket is found.
2729
+ */
2730
+ function extractJsonSpan(text) {
2731
+ const firstBrace = text.indexOf("{");
2732
+ const firstBracket = text.indexOf("[");
2733
+ let start = -1;
2734
+ let closeChar = "";
2735
+ if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
2736
+ start = firstBrace;
2737
+ closeChar = "}";
2738
+ } else if (firstBracket !== -1) {
2739
+ start = firstBracket;
2740
+ closeChar = "]";
2741
+ }
2742
+ if (start === -1) return text;
2743
+ const end = text.lastIndexOf(closeChar);
2744
+ if (end === -1 || end <= start) return text;
2745
+ return text.slice(start, end + 1);
2746
+ }
2747
+ /**
2748
+ * Extract structured KG triples from a session summary and store them in
2749
+ * Postgres. Idempotent: if a (subject, predicate) pair already has the same
2750
+ * object, no new row is added; if the object differs, the old triple is
2751
+ * invalidated (valid_to = NOW()) and a new one is inserted.
2752
+ *
2753
+ * Best-effort: per-triple errors are caught and logged but never thrown.
2754
+ * Returns a small stats object so callers can report progress.
2755
+ */
2756
+ async function extractAndStoreTriples$1(backend, params) {
2757
+ const stats = {
2758
+ extracted: 0,
2759
+ added: 0,
2760
+ superseded: 0
2761
+ };
2762
+ const jsonOutput = await spawnClaude(buildTripleExtractionPrompt({
2763
+ sessionContent: params.summaryText,
2764
+ projectSlug: params.projectSlug,
2765
+ gitLog: params.gitLog ?? ""
2766
+ }), params.model ?? "sonnet");
2767
+ if (!jsonOutput) return stats;
2768
+ let cleaned = jsonOutput.replace(/^```json\s*/m, "").replace(/^```\s*/m, "").replace(/\s*```$/m, "").trim();
2769
+ cleaned = extractJsonSpan(cleaned);
2770
+ let triples;
2771
+ try {
2772
+ const parsed = JSON.parse(cleaned);
2773
+ if (Array.isArray(parsed)) triples = parsed;
2774
+ else if (parsed && typeof parsed === "object" && Array.isArray(parsed.relations)) {
2775
+ const newFmt = parsed;
2776
+ if (Array.isArray(newFmt.entities)) {
2777
+ const tenantId = params.tenantId ?? "default";
2778
+ for (const entity of newFmt.entities) {
2779
+ if (!entity.name) continue;
2780
+ try {
2781
+ await backend.upsertKgEntity({
2782
+ name: entity.name,
2783
+ type: entity.type ?? "unknown",
2784
+ description: entity.description,
2785
+ tenantId
2786
+ });
2787
+ } catch (entityErr) {
2788
+ process.stderr.write(`[kg-extraction] entity upsert error (${entity.name}): ${entityErr}\n`);
2789
+ }
2790
+ }
2791
+ }
2792
+ triples = newFmt.relations.map((r) => ({
2793
+ subject: r.source,
2794
+ predicate: r.relation,
2795
+ object: r.target
2796
+ }));
2797
+ } else {
2798
+ process.stderr.write(`[kg-extraction] Unexpected JSON shape — neither array nor {entities,relations}\n`);
2799
+ return stats;
2800
+ }
2801
+ } catch (e) {
2802
+ const collapse = (s) => s.replace(/\s+/g, " ").trim();
2803
+ const head200 = collapse(jsonOutput.slice(0, 200));
2804
+ const tail200 = collapse(jsonOutput.slice(-200));
2805
+ process.stderr.write(`[kg-extraction] JSON parse failed: ${e}. head200="${head200}" tail200="${tail200}"\n`);
2806
+ return stats;
2807
+ }
2808
+ if (!Array.isArray(triples)) return stats;
2809
+ stats.extracted = triples.length;
2810
+ for (const t of triples) {
2811
+ if (!t.subject || !t.predicate || !t.object) continue;
2812
+ try {
2813
+ const existing = await backend.queryKgTriples({
2814
+ subject: t.subject,
2815
+ predicate: t.predicate,
2816
+ project_id: params.projectId ?? void 0
2817
+ });
2818
+ if (existing.find((e) => e.object === t.object && !e.valid_to)) continue;
2819
+ const supersedes = existing.find((e) => e.object !== t.object && !e.valid_to);
2820
+ if (supersedes) {
2821
+ await backend.invalidateKgTriple(supersedes.id);
2822
+ stats.superseded++;
2823
+ }
2824
+ await backend.addKgTriple({
2825
+ subject: t.subject,
2826
+ predicate: t.predicate,
2827
+ object: t.object,
2828
+ project_id: params.projectId ?? void 0,
2829
+ source_session: params.sessionId,
2830
+ confidence: "EXTRACTED"
2831
+ });
2832
+ stats.added++;
2833
+ } catch (tripleErr) {
2834
+ process.stderr.write(`[kg-extraction] store error (${t.subject}): ${tripleErr}\n`);
2835
+ }
2836
+ }
2837
+ return stats;
2838
+ }
2839
+
2840
+ //#endregion
2841
+ //#region src/daemon/daemon/state.ts
2842
+ /** RegistryBackend-based access (projects, sessions, tags, aliases, links, compaction_log). */
2843
+ let registryBackend;
2844
+ let storageBackend;
2845
+ let daemonConfig;
2846
+ let startTime = Date.now();
2847
+ /** True while a project index pass is running. */
2848
+ let indexInProgress = false;
2849
+ let lastIndexTime = 0;
2850
+ let indexSchedulerTimer = null;
2851
+ /** True while an embedding pass is running. */
2852
+ let embedInProgress = false;
2853
+ let lastEmbedTime = 0;
2854
+ /** Outcome of the latest embed pass (budget hit, yielded, done), null before the first. */
2855
+ let lastEmbedPass = null;
2856
+ let embedSchedulerTimer = null;
2857
+ /** True while a vault index pass is running. */
2858
+ let vaultIndexInProgress = false;
2859
+ let lastVaultIndexTime = 0;
2860
+ /** Interval timer of the worker prompt-cache keepalive (null = not armed). */
2861
+ let cacheKeepaliveTimer = null;
2862
+ /** Mutable notification config — loaded from disk at startup, patchable at runtime. */
2863
+ let notificationConfig;
2864
+ /**
2865
+ * Set to true when a SIGTERM/SIGINT is received so that long-running loops
2866
+ * (embed, index) can detect the signal and exit their inner loops before the
2867
+ * pool/backend is closed.
2868
+ */
2869
+ let shutdownRequested = false;
2870
+ function setRegistryBackend(b) {
2871
+ registryBackend = b;
2872
+ }
2873
+ function setStorageBackend(b) {
2874
+ storageBackend = b;
2875
+ }
2876
+ function setDaemonConfig(c) {
2877
+ daemonConfig = c;
2878
+ }
2879
+ function setStartTime(t) {
2880
+ startTime = t;
2881
+ }
2882
+ function setNotificationConfig(c) {
2883
+ notificationConfig = c;
2884
+ }
2885
+ function setShutdownRequested(v) {
2886
+ shutdownRequested = v;
2887
+ }
2888
+ function setIndexInProgress(v) {
2889
+ indexInProgress = v;
2890
+ }
2891
+ function setLastIndexTime(v) {
2892
+ lastIndexTime = v;
2893
+ }
2894
+ function setIndexSchedulerTimer(v) {
2895
+ indexSchedulerTimer = v;
2896
+ }
2897
+ function setEmbedInProgress(v) {
2898
+ embedInProgress = v;
2899
+ }
2900
+ function setLastEmbedPass(v) {
2901
+ lastEmbedPass = v;
2902
+ }
2903
+ function setLastEmbedTime(v) {
2904
+ lastEmbedTime = v;
2905
+ }
2906
+ function setEmbedSchedulerTimer(v) {
2907
+ embedSchedulerTimer = v;
2908
+ }
2909
+ function setVaultIndexInProgress(v) {
2910
+ vaultIndexInProgress = v;
2911
+ }
2912
+ function setLastVaultIndexTime(v) {
2913
+ lastVaultIndexTime = v;
2914
+ }
2915
+ function setCacheKeepaliveTimer(v) {
2916
+ cacheKeepaliveTimer = v;
2917
+ }
2918
+ /** Failing passes by name ("index" | "embed" | "vault"); absent = healthy. */
2919
+ const passFailures = /* @__PURE__ */ new Map();
2920
+
2921
+ //#endregion
2922
+ //#region src/daemon/session-summary-worker.ts
2923
+ /**
2924
+ * session-summary-worker.ts — AI-powered session note generation
2925
+ *
2926
+ * Processes `session-summary` work items by:
2927
+ * 1. Finding the current session's JSONL transcript
2928
+ * 2. Extracting user messages and assistant context
2929
+ * 3. Gathering git commits from the session period
2930
+ * 4. Spawning Claude (sonnet for compaction, opus for session end) to generate a structured summary
2931
+ * 5. Comparing the new topic against the existing note's topic
2932
+ * 6. Creating a NEW note if the topic shifted, or updating the existing one
2933
+ *
2934
+ * Topic detection: the summarizer outputs a TOPIC: line as the first line of
2935
+ * its response. This is compared against the existing note's title using word
2936
+ * overlap. If overlap is below ~30%, a new note is created.
2937
+ *
2938
+ * Designed to run inside the daemon's work queue worker. All errors are
2939
+ * thrown (not swallowed) so the work queue retry logic handles them.
2940
+ */
2941
+ /** Minimum interval between summaries for the same project (ms). */
2942
+ const SUMMARY_COOLDOWN_MS = 1800 * 1e3;
2943
+ /** Maximum JSONL content to feed to the summarizer (characters). */
2944
+ /** Max JSONL chars per model. Opus/Sonnet can handle much more than Haiku. */
2945
+ const MAX_JSONL_CHARS = {
2946
+ haiku: 5e4,
2947
+ sonnet: 2e5,
2948
+ opus: 5e5
2949
+ };
2950
+ /** Maximum user messages to include in the prompt. */
2951
+ const MAX_USER_MESSAGES = 30;
2952
+ /** File tracking last summary timestamps per project — resolved under
2953
+ * PAI_HOME, falling back to the pre-2026-09-19 ~/.config/pai location. */
2954
+ function oldCooldownFile() {
2955
+ return join(homedir(), ".config", "pai", "summary-cooldowns.json");
2956
+ }
2957
+ function cooldownFilePath() {
2958
+ return resolvePaiFile(paiHomePath("summary-cooldowns.json"), [oldCooldownFile()], "pai config migrate");
2959
+ }
2960
+ function migrateSummaryCooldowns(opts = {}) {
2961
+ return migratePaiFile(paiHomePath("summary-cooldowns.json"), [oldCooldownFile()], opts);
2962
+ }
2963
+ /** Claude Code projects directory. */
2964
+ const CLAUDE_PROJECTS_DIR = () => join(homedir(), ".claude", "projects");
2965
+ function loadCooldowns() {
2966
+ try {
2967
+ const file = cooldownFilePath();
2968
+ if (existsSync(file)) return JSON.parse(readFileSync(file, "utf-8"));
2969
+ } catch {}
2970
+ return {};
2971
+ }
2972
+ function saveCooldowns(cooldowns) {
2973
+ try {
2974
+ const file = paiHomePath("summary-cooldowns.json");
2975
+ mkdirSync(dirname(file), { recursive: true });
2976
+ writeFileSync(file, JSON.stringify(cooldowns, null, 2), "utf-8");
2977
+ } catch {}
2978
+ }
2979
+ function isOnCooldown(cwd) {
2980
+ const lastRun = loadCooldowns()[cwd];
2981
+ if (!lastRun) return false;
2982
+ return Date.now() - lastRun < SUMMARY_COOLDOWN_MS;
2983
+ }
2984
+ function markCooldown(cwd) {
2985
+ const cooldowns = loadCooldowns();
2986
+ cooldowns[cwd] = Date.now();
2987
+ const cutoff = Date.now() - 1440 * 60 * 1e3;
2988
+ for (const key of Object.keys(cooldowns)) if (cooldowns[key] < cutoff) delete cooldowns[key];
2989
+ saveCooldowns(cooldowns);
2990
+ }
2991
+ /**
2992
+ * Encode a cwd path the same way Claude Code does for its project directories.
2993
+ * Replaces /, space, dot, and hyphen with -.
2994
+ */
2995
+ function encodeProjectPath(cwd) {
2996
+ return cwd.replace(/[\/\s.\-]/g, "-");
2997
+ }
2998
+ /**
2999
+ * Find the most recently modified JSONL file for the given project.
3000
+ *
3001
+ * Claude Code stores transcripts in:
3002
+ * ~/.claude/projects/<encoded-path>/sessions/*.jsonl
3003
+ * ~/.claude/projects/<encoded-path>/<uuid>.jsonl (legacy)
3004
+ */
3005
+ function findLatestJsonl(cwd) {
3006
+ const encoded = encodeProjectPath(cwd);
3007
+ const projectDir = join(CLAUDE_PROJECTS_DIR(), encoded);
3008
+ if (!existsSync(projectDir)) {
3009
+ process.stderr.write(`[session-summary] No Claude project dir found: ${projectDir}\n`);
3010
+ return null;
3011
+ }
3012
+ const candidates = [];
3013
+ const sessionsDir = join(projectDir, "sessions");
3014
+ if (existsSync(sessionsDir)) try {
3015
+ for (const f of readdirSync(sessionsDir)) {
3016
+ if (!f.endsWith(".jsonl")) continue;
3017
+ const fullPath = join(sessionsDir, f);
3018
+ try {
3019
+ const st = statSync(fullPath);
3020
+ candidates.push({
3021
+ path: fullPath,
3022
+ mtime: st.mtimeMs
3023
+ });
3024
+ } catch {}
3025
+ }
3026
+ } catch {}
3027
+ try {
3028
+ for (const f of readdirSync(projectDir)) {
3029
+ if (!f.endsWith(".jsonl")) continue;
3030
+ const fullPath = join(projectDir, f);
3031
+ try {
3032
+ const st = statSync(fullPath);
3033
+ candidates.push({
3034
+ path: fullPath,
3035
+ mtime: st.mtimeMs
3036
+ });
3037
+ } catch {}
3038
+ }
3039
+ } catch {}
3040
+ if (candidates.length === 0) {
3041
+ process.stderr.write(`[session-summary] No JSONL files found in ${projectDir}\n`);
3042
+ return null;
3043
+ }
3044
+ candidates.sort((a, b) => b.mtime - a.mtime);
3045
+ return candidates[0].path;
3046
+ }
3047
+ /**
3048
+ * Parse a JSONL transcript and extract relevant content.
3049
+ * Filters noise, truncates to model-appropriate size from the end of the file.
3050
+ */
3051
+ function extractFromJsonl(jsonlPath, tier = "sonnet") {
3052
+ const result = {
3053
+ userMessages: [],
3054
+ filesModified: [],
3055
+ sessionStartTime: ""
3056
+ };
3057
+ let raw;
3058
+ try {
3059
+ raw = readFileSync(jsonlPath, "utf-8");
3060
+ } catch (e) {
3061
+ throw new Error(`Could not read JSONL at ${jsonlPath}: ${e}`);
3062
+ }
3063
+ const maxChars = MAX_JSONL_CHARS[tier] ?? 2e5;
3064
+ if (raw.length > maxChars) {
3065
+ const truncPoint = raw.indexOf("\n", raw.length - maxChars);
3066
+ raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_JSONL_CHARS);
3067
+ }
3068
+ const lines = raw.trim().split("\n");
3069
+ const seenMessages = /* @__PURE__ */ new Set();
3070
+ for (const line of lines) {
3071
+ if (!line.trim()) continue;
3072
+ let entry;
3073
+ try {
3074
+ entry = JSON.parse(line);
3075
+ } catch {
3076
+ continue;
3077
+ }
3078
+ if (entry.timestamp && !result.sessionStartTime) result.sessionStartTime = String(entry.timestamp);
3079
+ if (entry.type === "user") {
3080
+ const msg = entry.message;
3081
+ if (msg?.content) {
3082
+ const text = contentToText(msg.content);
3083
+ if (text && !isNoise(text) && !seenMessages.has(text)) {
3084
+ seenMessages.add(text);
3085
+ result.userMessages.push(text.slice(0, 500));
3086
+ }
3087
+ }
3088
+ }
3089
+ if (entry.type === "assistant") {
3090
+ const msg = entry.message;
3091
+ if (msg?.content && Array.isArray(msg.content)) {
3092
+ for (const block of msg.content) if (block.type === "tool_use") {
3093
+ const name = block.name;
3094
+ const input = block.input;
3095
+ if ((name === "Edit" || name === "Write") && input?.file_path) {
3096
+ const fp = String(input.file_path);
3097
+ if (!result.filesModified.includes(fp)) result.filesModified.push(fp);
3098
+ }
3099
+ }
3100
+ }
3101
+ }
3102
+ }
3103
+ if (result.userMessages.length > MAX_USER_MESSAGES) result.userMessages = result.userMessages.slice(-MAX_USER_MESSAGES);
3104
+ return result;
3105
+ }
3106
+ /** Convert Claude content (string or content block array) to plain text. */
3107
+ function contentToText(content) {
3108
+ if (typeof content === "string") return content;
3109
+ if (Array.isArray(content)) return content.map((c) => {
3110
+ if (typeof c === "string") return c;
3111
+ const block = c;
3112
+ if (block?.text) return String(block.text);
3113
+ if (block?.content) return String(block.content);
3114
+ return "";
3115
+ }).join(" ").trim();
3116
+ return "";
3117
+ }
3118
+ /** Filter out noise entries that shouldn't be included in the summary. */
3119
+ function isNoise(text) {
3120
+ if (!text || text.length < 3) return true;
3121
+ if (text.includes("<task-notification>")) return true;
3122
+ if (text.includes("[object Object]")) return true;
3123
+ if (text.startsWith("<system-reminder>")) return true;
3124
+ if (/^(yes|ok|sure|go|continue|weiter|thanks|thank you)\.?$/i.test(text.trim())) return true;
3125
+ if (text.startsWith("Tool Result:") || text.startsWith("tool_result")) return true;
3126
+ return false;
3127
+ }
3128
+ /**
3129
+ * Get git log for the session period.
3130
+ * Falls back gracefully if git is not available or the dir is not a repo.
3131
+ */
3132
+ async function getGitContext(cwd, sinceTime) {
3133
+ let since = "6 hours ago";
3134
+ if (sinceTime) {
3135
+ const asNum = Number(sinceTime);
3136
+ if (!isNaN(asNum) && asNum > 1e9) since = (/* @__PURE__ */ new Date(asNum * 1e3)).toISOString();
3137
+ else since = sinceTime;
3138
+ }
3139
+ try {
3140
+ const { execFile: execFileCb } = await import("node:child_process");
3141
+ const { promisify } = await import("node:util");
3142
+ const { stdout } = await promisify(execFileCb)("git", [
3143
+ "log",
3144
+ "--format=%h %ai %s",
3145
+ `--since=${since}`,
3146
+ "--stat",
3147
+ "--no-color"
3148
+ ], {
3149
+ cwd,
3150
+ timeout: 1e4,
3151
+ env: {
3152
+ ...process.env,
3153
+ GIT_TERMINAL_PROMPT: "0"
3154
+ }
3155
+ });
3156
+ return stdout.trim();
3157
+ } catch {
3158
+ return "";
3159
+ }
3160
+ }
3161
+ /**
3162
+ * Find the `claude` CLI binary.
3163
+ * Checks PATH first, then common installation locations.
3164
+ */
3165
+ function findClaudeBinary() {
3166
+ const candidates = [
3167
+ join(homedir(), ".local", "bin", "claude"),
3168
+ join(homedir(), ".claude", "local", "claude"),
3169
+ "/usr/local/bin/claude",
3170
+ "/opt/homebrew/bin/claude"
3171
+ ];
3172
+ for (const candidate of candidates) try {
3173
+ if (existsSync(candidate)) return candidate;
3174
+ } catch {}
3175
+ return "claude";
3176
+ }
3177
+ /**
3178
+ * Spawn a Claude model via the CLI to generate a session summary.
3179
+ * Pipes the prompt via stdin. Tier selection (resolved to the configured
3180
+ * provider's model id, falling back to the tier alias when no provider is
3181
+ * configured — see planLlmSpawn):
3182
+ * - opus: session end (best quality for final summary, runs once)
3183
+ * - sonnet: auto-compaction (good quality for incremental checkpoints, runs often)
3184
+ * - haiku: fallback / budget mode
3185
+ * Returns the generated text, or null if spawning fails.
3186
+ */
3187
+ async function spawnSummarizer(prompt, tier = "sonnet") {
3188
+ const claudeBin = findClaudeBinary();
3189
+ if (!claudeBin) {
3190
+ process.stderr.write("[session-summary] Claude CLI not found in PATH or common locations.\n");
3191
+ return null;
3192
+ }
3193
+ let plan;
3194
+ try {
3195
+ plan = await planLlmSpawn(tier);
3196
+ } catch (e) {
3197
+ process.stderr.write(`[session-summary] could not plan ${tier} spawn: ${e}\n`);
3198
+ return null;
3199
+ }
3200
+ const { spawn } = await import("node:child_process");
3201
+ return new Promise((resolve) => {
3202
+ let timer = null;
3203
+ const child = spawn(claudeBin, plan.args, {
3204
+ env: plan.env,
3205
+ stdio: [
3206
+ "pipe",
3207
+ "pipe",
3208
+ "pipe"
3209
+ ]
3210
+ });
3211
+ let stdout = "";
3212
+ let stderr = "";
3213
+ child.stdout.on("data", (chunk) => {
3214
+ stdout += chunk.toString();
3215
+ });
3216
+ child.stderr.on("data", (chunk) => {
3217
+ stderr += chunk.toString();
3218
+ });
3219
+ child.on("error", (err) => {
3220
+ if (timer) {
3221
+ clearTimeout(timer);
3222
+ timer = null;
3223
+ }
3224
+ process.stderr.write(`[session-summary] ${plan.model} spawn error: ${err.message}\n`);
3225
+ resolve(null);
3226
+ });
3227
+ child.on("close", (code) => {
3228
+ if (timer) {
3229
+ clearTimeout(timer);
3230
+ timer = null;
3231
+ }
3232
+ if (code !== 0) {
3233
+ process.stderr.write(`[session-summary] ${plan.model} exited with code ${code}: ${stderr.slice(0, 300)}\n`);
3234
+ resolve(null);
3235
+ } else resolve(stdout.trim() || null);
3236
+ });
3237
+ timer = setTimeout(() => {
3238
+ process.stderr.write(`[session-summary] ${plan.model} timed out — killing process.\n`);
3239
+ child.kill("SIGTERM");
3240
+ resolve(null);
3241
+ }, plan.timeoutMs);
3242
+ child.stdin.write(prompt);
3243
+ child.stdin.end();
3244
+ });
3245
+ }
3246
+ /**
3247
+ * Extract the TOPIC: line from the summarizer output.
3248
+ * Returns the topic string, or null if not found.
3249
+ */
3250
+ function extractTopic(summaryText) {
3251
+ const match = summaryText.match(/^TOPIC:\s*(.+)$/m);
3252
+ if (!match) return null;
3253
+ return match[1].trim();
3254
+ }
3255
+ /**
3256
+ * Extract the topic from an existing session note.
3257
+ * First checks for a <!-- TOPIC: ... --> comment (stored by previous summaries).
3258
+ * Falls back to the H1 "# Session NNNN: Title" line.
3259
+ *
3260
+ * The HTML comment is the reliable source because the H1 gets renamed by
3261
+ * renameSessionNote, which can add/change words and cause false topic shifts.
3262
+ */
3263
+ function extractExistingNoteTitle(notePath) {
3264
+ try {
3265
+ const content = readFileSync(notePath, "utf-8");
3266
+ const topicComment = content.match(/<!-- TOPIC:\s*(.+?)\s*-->/);
3267
+ if (topicComment) return topicComment[1].trim();
3268
+ const match = content.match(/^# Session \d+:\s*(.+)$/m);
3269
+ if (match) return match[1].trim();
3270
+ } catch {}
3271
+ return null;
3272
+ }
3273
+ /**
3274
+ * Compute word overlap ratio between two topic strings.
3275
+ * Returns a value in [0, 1] — 1.0 means identical word sets.
3276
+ *
3277
+ * Uses lowercased, normalized words. Stop words and very short words
3278
+ * are excluded to avoid false positives on common terms.
3279
+ */
3280
+ function computeTopicOverlap(topicA, topicB) {
3281
+ const stopWords = new Set([
3282
+ "a",
3283
+ "an",
3284
+ "the",
3285
+ "and",
3286
+ "or",
3287
+ "but",
3288
+ "in",
3289
+ "on",
3290
+ "at",
3291
+ "to",
3292
+ "for",
3293
+ "of",
3294
+ "with",
3295
+ "by",
3296
+ "from",
3297
+ "is",
3298
+ "was",
3299
+ "are",
3300
+ "were",
3301
+ "be",
3302
+ "been",
3303
+ "being",
3304
+ "have",
3305
+ "has",
3306
+ "had",
3307
+ "do",
3308
+ "does",
3309
+ "did",
3310
+ "will",
3311
+ "would",
3312
+ "could",
3313
+ "should",
3314
+ "may",
3315
+ "might",
3316
+ "can",
3317
+ "shall",
3318
+ "this",
3319
+ "that",
3320
+ "these",
3321
+ "those",
3322
+ "it",
3323
+ "its",
3324
+ "new",
3325
+ "session",
3326
+ "work",
3327
+ "done"
3328
+ ]);
3329
+ const normalize = (text) => {
3330
+ const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
3331
+ return new Set(words);
3332
+ };
3333
+ const wordsA = normalize(topicA);
3334
+ const wordsB = normalize(topicB);
3335
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
3336
+ let intersection = 0;
3337
+ for (const w of wordsA) if (wordsB.has(w)) intersection++;
3338
+ const union = new Set([...wordsA, ...wordsB]).size;
3339
+ return union > 0 ? intersection / union : 0;
3340
+ }
3341
+ /** Threshold: below this overlap ratio, we consider topics different. */
3342
+ const TOPIC_OVERLAP_THRESHOLD = .15;
3343
+ /**
3344
+ * Write (or update) the session note with the AI-generated summary.
3345
+ *
3346
+ * Strategy:
3347
+ * - Find the current month's latest note
3348
+ * - If it's from today, compare topics:
3349
+ * - Same topic (overlap >= 30%) → update existing note
3350
+ * - Different topic (overlap < 30%) → create a NEW note
3351
+ * - If it's from a different day, create a new note
3352
+ */
3353
+ /**
3354
+ * Return true if the summarizer output has a meaningful body — i.e. content
3355
+ * beyond the TOPIC:/title/metadata/horizontal-rule scaffolding.
3356
+ *
3357
+ * A summary that is only headers (no Work Done items) must NOT create or update
3358
+ * a note: doing so births an empty-bodied scaffold that later rename/finalize
3359
+ * can strip to a footer-only stub. This is the born-stub failure mode.
3360
+ */
3361
+ function summaryHasContent(summaryText) {
3362
+ return summaryText.replace(/^TOPIC:.*$/m, "").replace(/^# Session:.*$/m, "").replace(/^\*\*(Date|Status|Completed):\*\*.*$/gm, "").replace(/^#{1,6}\s.*$/gm, "").replace(/^---$/gm, "").replace(/<!--[\s\S]*?-->/g, "").trim().length > 0;
3363
+ }
3364
+ function writeSessionNote(cwd, summaryText, filesModified, sessionId) {
3365
+ if (!summaryHasContent(summaryText)) {
3366
+ process.stderr.write(`[session-summary] Summary has no meaningful body — skipping note write.\n`);
3367
+ return null;
3368
+ }
3369
+ const notesInfo = findNotesDir(cwd);
3370
+ let notePath = sessionId && findNoteBySessionId(notesInfo.path, sessionId) || getCurrentNotePath(notesInfo.path);
3371
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3372
+ const newTopic = extractTopic(summaryText);
3373
+ if (notePath) {
3374
+ const noteFilename = basename(notePath);
3375
+ const dateMatch = noteFilename.match(/(\d{4}-\d{2}-\d{2})/);
3376
+ if ((dateMatch ? dateMatch[1] : "") === today) {
3377
+ const existingTitle = extractExistingNoteTitle(notePath);
3378
+ let topicShifted = false;
3379
+ if (newTopic && existingTitle) {
3380
+ const overlap = computeTopicOverlap(newTopic, existingTitle);
3381
+ process.stderr.write(`[session-summary] Topic overlap: ${(overlap * 100).toFixed(1)}% (new="${newTopic}", existing="${existingTitle}")\n`);
3382
+ if (overlap < TOPIC_OVERLAP_THRESHOLD) {
3383
+ topicShifted = true;
3384
+ process.stderr.write(`[session-summary] Topic shift detected (word overlap) — creating new note.\n`);
3385
+ }
3386
+ }
3387
+ if (!topicShifted) {
3388
+ const boundaryPath = join(notesInfo.path, "topic-boundary.json");
3389
+ if (existsSync(boundaryPath)) try {
3390
+ const boundary = JSON.parse(readFileSync(boundaryPath, "utf-8"));
3391
+ if (boundary.timestamp) {
3392
+ if (Date.now() - new Date(boundary.timestamp).getTime() < 1800 * 1e3) {
3393
+ topicShifted = true;
3394
+ process.stderr.write(`[session-summary] Topic shift detected (boundary marker) — ${boundary.previousProject} → ${boundary.suggestedProject}\n`);
3395
+ }
3396
+ }
3397
+ unlinkSync(boundaryPath);
3398
+ } catch {}
3399
+ }
3400
+ if (topicShifted) notePath = createNoteFromSummary(notesInfo.path, summaryText, sessionId);
3401
+ else {
3402
+ updateNoteWithSummary(notePath, summaryText);
3403
+ process.stderr.write(`[session-summary] Updated existing note: ${noteFilename}\n`);
3404
+ }
3405
+ } else notePath = createNoteFromSummary(notesInfo.path, summaryText, sessionId);
3406
+ } else notePath = createNoteFromSummary(notesInfo.path, summaryText, sessionId);
3407
+ if (notePath) {
3408
+ const titleMatch = summaryText.match(/^# Session:\s*(.+)$/m);
3409
+ if (titleMatch) {
3410
+ const title = titleMatch[1].trim();
3411
+ if (title.length > 5 && title.length < 80) {
3412
+ const newPath = renameSessionNote(notePath, title);
3413
+ if (newPath !== notePath) notePath = newPath;
3414
+ }
3415
+ }
3416
+ }
3417
+ return notePath;
3418
+ }
3419
+ /**
3420
+ * Update an existing session note's Work Done section with AI-generated content.
3421
+ */
3422
+ function updateNoteWithSummary(notePath, summaryText) {
3423
+ if (!existsSync(notePath)) return;
3424
+ let content = readFileSync(notePath, "utf-8");
3425
+ const newTopic = extractTopic(summaryText);
3426
+ if (newTopic) if (content.includes("<!-- TOPIC:")) content = content.replace(/<!-- TOPIC:.*?-->/, `<!-- TOPIC: ${newTopic} -->`);
3427
+ else content = content.replace(/^(# Session .+)$/m, `$1\n<!-- TOPIC: ${newTopic} -->`);
3428
+ const workDoneMatch = summaryText.match(/## Work Done\n\n([\s\S]*?)(?=\n## Key Decisions|\n## Known Issues|\n\*\*Tags|\n$)/);
3429
+ if (workDoneMatch) {
3430
+ const aiWorkContent = workDoneMatch[1].trim();
3431
+ const sectionHeader = `\n### AI Summary (${(/* @__PURE__ */ new Date()).toISOString().split("T")[1].split(".")[0]})\n\n${aiWorkContent}\n`;
3432
+ const nextStepsIdx = content.indexOf("## Next Steps");
3433
+ const knownIssuesIdx = content.indexOf("## Known Issues");
3434
+ const insertBefore = knownIssuesIdx !== -1 ? knownIssuesIdx : nextStepsIdx !== -1 ? nextStepsIdx : content.length;
3435
+ content = content.slice(0, insertBefore) + sectionHeader + "\n" + content.slice(insertBefore);
3436
+ }
3437
+ const decisionsMatch = summaryText.match(/## Key Decisions\n\n([\s\S]*?)(?=\n## Known Issues|\n\*\*Tags|\n$)/);
3438
+ if (decisionsMatch) {
3439
+ const decisions = decisionsMatch[1].trim();
3440
+ if (decisions && !content.includes("## Key Decisions")) {
3441
+ const nextStepsIdx = content.indexOf("## Next Steps");
3442
+ const insertAt = nextStepsIdx !== -1 ? nextStepsIdx : content.length;
3443
+ content = content.slice(0, insertAt) + `## Key Decisions\n\n${decisions}\n\n` + content.slice(insertAt);
3444
+ }
3445
+ }
3446
+ const issuesMatch = summaryText.match(/## Known Issues\n\n([\s\S]*?)(?=\n\*\*Tags|\n$)/);
3447
+ if (issuesMatch) {
3448
+ const issues = issuesMatch[1].trim();
3449
+ if (issues && !content.includes("## Known Issues")) {
3450
+ const nextStepsIdx = content.indexOf("## Next Steps");
3451
+ const insertAt = nextStepsIdx !== -1 ? nextStepsIdx : content.length;
3452
+ content = content.slice(0, insertAt) + `## Known Issues\n\n${issues}\n\n` + content.slice(insertAt);
3453
+ }
3454
+ }
3455
+ writeFileSync(notePath, content, "utf-8");
3456
+ }
3457
+ /**
3458
+ * Create a brand new session note from the AI summary.
3459
+ */
3460
+ /**
3461
+ * Find the note this session already owns, by the marker its first write left.
3462
+ *
3463
+ * The previous resolver, getCurrentNotePath, returned the numerically highest
3464
+ * note in the month directory. That is a position, not an identity, and it is
3465
+ * why one session produced many notes: whenever a session's own note was not
3466
+ * the current maximum — two sessions in one project, or a number collision, of
3467
+ * which one corpus had 110 — the lookup missed and the checkpoint created a
3468
+ * new file instead of updating. Measured consequence in that corpus: 303 of
3469
+ * 407 notes sat in 31 same-title groups, 48 of them sharing a single title.
3470
+ *
3471
+ * Matching on the session id makes the answer exact: a session either has a
3472
+ * note or it does not, regardless of what else the directory contains.
3473
+ */
3474
+ function findNoteBySessionId(notesDir, sessionId) {
3475
+ if (!sessionId || !existsSync(notesDir)) return null;
3476
+ const marker = `<!-- SESSION: ${sessionId} -->`;
3477
+ const now = /* @__PURE__ */ new Date();
3478
+ const dirs = [0, -1].map((delta) => {
3479
+ const d = new Date(now.getFullYear(), now.getMonth() + delta, 1);
3480
+ return join(notesDir, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, "0"));
3481
+ });
3482
+ for (const dir of dirs) {
3483
+ if (!existsSync(dir)) continue;
3484
+ for (const f of readdirSync(dir)) {
3485
+ if (!f.endsWith(".md")) continue;
3486
+ const p = join(dir, f);
3487
+ try {
3488
+ if (readFileSync(p, "utf-8").includes(marker)) return p;
3489
+ } catch {}
3490
+ }
3491
+ }
3492
+ return null;
3493
+ }
3494
+ function createNoteFromSummary(notesDir, summaryText, sessionId) {
3495
+ try {
3496
+ const notePath = createSessionNote(notesDir, "New Session");
3497
+ const noteFilename = basename(notePath);
3498
+ const numberMatch = noteFilename.match(/^(\d+)/);
3499
+ const noteNumber = numberMatch ? numberMatch[1] : "0000";
3500
+ const titleMatch = summaryText.match(/^# Session:\s*(.+)$/m);
3501
+ const title = titleMatch ? titleMatch[1].trim() : "New Session";
3502
+ const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3503
+ const topic = extractTopic(summaryText);
3504
+ const aiBody = summaryText.replace(/^TOPIC:.*$/m, "").replace(/^# Session:.*$/m, "").replace(/^\*\*Date:\*\*.*$/m, "").replace(/^\*\*Status:\*\*.*$/m, "").replace(/^---$/m, "").trim();
3505
+ writeFileSync(notePath, `# Session ${noteNumber}: ${title}
3506
+ ${topic ? `<!-- TOPIC: ${topic} -->` : ""}${sessionId ? `\n<!-- SESSION: ${sessionId} -->` : ""}
3507
+
3508
+ **Date:** ${date}
3509
+ **Status:** In Progress
3510
+
3511
+ ---
3512
+
3513
+ ${aiBody}
3514
+
3515
+ ---
3516
+
3517
+ ## Next Steps
3518
+
3519
+ <!-- To be filled at session end -->
3520
+
3521
+ ---
3522
+
3523
+ **Tags:** #Session
3524
+ `, "utf-8");
3525
+ process.stderr.write(`[session-summary] Created AI-powered note: ${noteFilename}\n`);
3526
+ return notePath;
3527
+ } catch (e) {
3528
+ process.stderr.write(`[session-summary] Failed to create note: ${e}\n`);
3529
+ return null;
3530
+ }
3531
+ }
3532
+ /**
3533
+ * Look up the integer project_id from the registry backend for a given slug.
3534
+ * Returns null if not found.
3535
+ */
3536
+ async function lookupProjectId(slug) {
3537
+ try {
3538
+ return (await (await getRegistryBackend()).getProjectBySlug(slug))?.id ?? null;
3539
+ } catch {
3540
+ return null;
3541
+ }
3542
+ }
3543
+ /**
3544
+ * Extract structured KG triples from a session summary and store them.
3545
+ *
3546
+ * This is best-effort: any error is logged but never propagated.
3547
+ * Requires Postgres backend — silently no-ops on SQLite.
3548
+ */
3549
+ async function extractAndStoreTriples(params) {
3550
+ try {
3551
+ if (!storageBackend?.supportsPostgresFeatures) return;
3552
+ if (deferIfQuiet("triple-extraction")) return;
3553
+ const cfg = daemonConfig;
3554
+ if (cfg && cfg.kg_extraction_enabled === false) {
3555
+ process.stderr.write("[session-summary] Triple extraction disabled via kg_extraction_enabled=false.\n");
3556
+ return;
3557
+ }
3558
+ const result = await extractAndStoreTriples$1(storageBackend, {
3559
+ summaryText: params.summaryText,
3560
+ projectSlug: params.projectSlug,
3561
+ projectId: params.projectId,
3562
+ sessionId: params.sessionId,
3563
+ gitLog: params.gitLog,
3564
+ model: "sonnet"
3565
+ });
3566
+ process.stderr.write(`[session-summary] Triple extraction complete: ${result.extracted} extracted, ${result.added} added, ${result.superseded} superseded.\n`);
3567
+ } catch (err) {
3568
+ process.stderr.write(`[session-summary] Triple extraction failed: ${err}\n`);
3569
+ }
3570
+ }
3571
+ /**
3572
+ * Process a `session-summary` work item.
3573
+ *
3574
+ * This is the main function called by work-queue-worker.ts.
3575
+ * Throws on fatal errors (work queue will retry with backoff).
3576
+ */
3577
+ async function handleSessionSummary(payload) {
3578
+ const { cwd, sessionId, projectSlug, transcriptPath, force } = payload;
3579
+ if (!cwd) throw new Error("session-summary payload missing cwd");
3580
+ process.stderr.write(`[session-summary] Starting for ${cwd}${sessionId ? ` (session=${sessionId})` : ""}${force ? " (force=true)" : ""}\n`);
3581
+ if (!force && isOnCooldown(cwd)) {
3582
+ process.stderr.write("[session-summary] Skipping — last summary was less than 30 minutes ago.\n");
3583
+ return;
3584
+ }
3585
+ let jsonlPath = transcriptPath || null;
3586
+ if (jsonlPath && !existsSync(jsonlPath)) {
3587
+ process.stderr.write(`[session-summary] Provided transcript path not found: ${jsonlPath}\n`);
3588
+ jsonlPath = null;
3589
+ }
3590
+ if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);
3591
+ if (!jsonlPath) {
3592
+ process.stderr.write("[session-summary] No JSONL transcript found — skipping.\n");
3593
+ return;
3594
+ }
3595
+ process.stderr.write(`[session-summary] Using transcript: ${jsonlPath}\n`);
3596
+ const selectedModel = payload.model ?? (force ? "haiku" : "sonnet");
3597
+ const extracted = extractFromJsonl(jsonlPath, selectedModel);
3598
+ if (extracted.userMessages.length === 0) {
3599
+ process.stderr.write("[session-summary] No user messages found in transcript — skipping.\n");
3600
+ return;
3601
+ }
3602
+ process.stderr.write(`[session-summary] Extracted ${extracted.userMessages.length} user messages, ${extracted.filesModified.length} modified files.\n`);
3603
+ const gitLog = await getGitContext(cwd, extracted.sessionStartTime);
3604
+ if (gitLog) process.stderr.write(`[session-summary] Got git context (${gitLog.split("\n").length} lines).\n`);
3605
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3606
+ const existingNotePath = getCurrentNotePath(findNotesDir(cwd).path);
3607
+ let existingNote;
3608
+ if (existingNotePath) {
3609
+ const dateMatch = basename(existingNotePath).match(/(\d{4}-\d{2}-\d{2})/);
3610
+ if (dateMatch && dateMatch[1] === today) try {
3611
+ existingNote = readFileSync(existingNotePath, "utf-8");
3612
+ } catch {}
3613
+ }
3614
+ const prompt = buildSessionSummaryPrompt({
3615
+ userMessages: extracted.userMessages,
3616
+ gitLog,
3617
+ cwd,
3618
+ date: today,
3619
+ filesModified: extracted.filesModified,
3620
+ existingNote
3621
+ });
3622
+ process.stderr.write(`[session-summary] Sending ${prompt.length} char prompt to ${selectedModel}...\n`);
3623
+ const summaryText = await spawnSummarizer(prompt, selectedModel);
3624
+ if (!summaryText) {
3625
+ process.stderr.write(`[session-summary] ${selectedModel} did not produce output — falling back to mechanical checkpoint.\n`);
3626
+ markCooldown(cwd);
3627
+ return;
3628
+ }
3629
+ process.stderr.write(`[session-summary] ${selectedModel} produced ${summaryText.length} char summary.\n`);
3630
+ const notePath = writeSessionNote(cwd, summaryText, extracted.filesModified, sessionId);
3631
+ if (notePath) process.stderr.write(`[session-summary] Session note written: ${basename(notePath)}\n`);
3632
+ await extractAndStoreTriples({
3633
+ summaryText,
3634
+ projectSlug: projectSlug ?? basename(cwd),
3635
+ projectId: projectSlug ? await lookupProjectId(projectSlug) : null,
3636
+ sessionId: sessionId ?? cwd,
3637
+ gitLog,
3638
+ model: selectedModel
3639
+ });
3640
+ markCooldown(cwd);
3641
+ process.stderr.write("[session-summary] Done.\n");
3642
+ }
3643
+
3644
+ //#endregion
3645
+ //#region src/daemon/work-queue.ts
3646
+ /**
3647
+ * work-queue.ts — Persistent work queue for the PAI Daemon
3648
+ *
3649
+ * Provides a durable, file-backed queue that survives daemon restarts.
3650
+ * Items are processed sequentially to avoid concurrent writes to the same
3651
+ * session note. Failed items are retried with exponential backoff.
3652
+ *
3653
+ * Queue file: under PAI_HOME (~/.pai/work-queue.json), falling back
3654
+ * to the pre-2026-09-19 ~/.config/pai/work-queue.json location.
3655
+ * Written atomically (write temp → rename) to prevent corruption.
3656
+ */
3657
+ /** Thrown by a handler when a retry cannot help: markFailed() then fails the item at once. */
3658
+ var NonRetryableError = class extends Error {};
3659
+ function oldQueueFile() {
3660
+ return join(homedir(), ".config", "pai", "work-queue.json");
3661
+ }
3662
+ const QUEUE_FILE = resolvePaiFile(paiHomePath("work-queue.json"), [oldQueueFile()], "pai config migrate");
3663
+ function migrateWorkQueue(opts = {}) {
3664
+ return migratePaiFile(paiHomePath("work-queue.json"), [oldQueueFile()], opts);
3665
+ }
3666
+ const MAX_QUEUE_SIZE = 1e3;
3667
+ const MAX_QUEUE_FILE_BYTES = 1024 * 1024;
3668
+ const COMPLETED_TTL_MS = 3600 * 1e3;
3669
+ const FAILED_TTL_MS = 1440 * 60 * 1e3;
3670
+ /** Backoff delays in ms by attempt number (0-indexed). */
3671
+ const BACKOFF_MS = [
3672
+ 5e3,
3673
+ 3e4,
3674
+ 3e5
3675
+ ];
3676
+ let _queue = [];
3677
+ let _dirty = false;
3678
+ /** Load queue from disk. Call once at daemon startup. */
3679
+ function loadQueue() {
3680
+ if (!existsSync(QUEUE_FILE)) {
3681
+ _queue = [];
3682
+ return;
3683
+ }
3684
+ try {
3685
+ const raw = readFileSync(QUEUE_FILE, "utf-8");
3686
+ const parsed = JSON.parse(raw);
3687
+ if (!Array.isArray(parsed)) {
3688
+ process.stderr.write("[work-queue] Invalid queue file format — starting empty.\n");
3689
+ _queue = [];
3690
+ return;
3691
+ }
3692
+ _queue = parsed.map((item) => {
3693
+ if (item.status === "processing") return {
3694
+ ...item,
3695
+ status: "pending"
3696
+ };
3697
+ return item;
3698
+ });
3699
+ const stats = getStats();
3700
+ process.stderr.write(`[work-queue] Loaded ${_queue.length} items from disk (pending=${stats.pending}, failed=${stats.failed}).\n`);
3701
+ } catch (e) {
3702
+ process.stderr.write(`[work-queue] Could not load queue file: ${e}\n`);
3703
+ _queue = [];
3704
+ }
3705
+ }
3706
+ /** Persist queue to disk atomically. */
3707
+ function saveQueue() {
3708
+ const dir = dirname(QUEUE_FILE);
3709
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3710
+ const tmpFile = QUEUE_FILE + ".tmp";
3711
+ try {
3712
+ writeFileSync(tmpFile, JSON.stringify(_queue, null, 2), "utf-8");
3713
+ renameSync(tmpFile, QUEUE_FILE);
3714
+ _dirty = false;
3715
+ } catch (e) {
3716
+ process.stderr.write(`[work-queue] Could not persist queue: ${e}\n`);
3717
+ }
3718
+ }
3719
+ /** Persist only if there are unsaved changes. */
3720
+ function saveIfDirty() {
3721
+ if (_dirty) saveQueue();
3722
+ }
3723
+ /**
3724
+ * Enforce the maximum queue size cap.
3725
+ * Strategy: first drop oldest completed, then oldest low-priority pending.
3726
+ */
3727
+ function enforceMaxSize() {
3728
+ if (_queue.length <= MAX_QUEUE_SIZE) return;
3729
+ const excess = _queue.length - MAX_QUEUE_SIZE;
3730
+ const toDropCompleted = _queue.filter((i) => i.status === "completed").sort((a, b) => a.createdAt.localeCompare(b.createdAt)).slice(0, excess);
3731
+ const dropIds = new Set(toDropCompleted.map((i) => i.id));
3732
+ _queue = _queue.filter((i) => !dropIds.has(i.id));
3733
+ if (_queue.length <= MAX_QUEUE_SIZE) return;
3734
+ const remainingExcess = _queue.length - MAX_QUEUE_SIZE;
3735
+ const toDropLow = _queue.filter((i) => i.status === "pending" && i.priority >= 4).sort((a, b) => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt)).slice(0, remainingExcess);
3736
+ const dropLowIds = new Set(toDropLow.map((i) => i.id));
3737
+ _queue = _queue.filter((i) => !dropLowIds.has(i.id));
3738
+ process.stderr.write(`[work-queue] Pruned queue to ${_queue.length} items (cap=${MAX_QUEUE_SIZE}).\n`);
3739
+ }
3740
+ /**
3741
+ * Add a new work item to the queue.
3742
+ * Returns the created WorkItem.
3743
+ */
3744
+ function enqueue(params) {
3745
+ const item = {
3746
+ id: randomUUID(),
3747
+ type: params.type,
3748
+ priority: params.priority ?? 3,
3749
+ payload: params.payload,
3750
+ status: "pending",
3751
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3752
+ attempts: 0,
3753
+ maxAttempts: params.maxAttempts ?? 3
3754
+ };
3755
+ _queue.push(item);
3756
+ enforceMaxSize();
3757
+ _dirty = true;
3758
+ saveIfDirty();
3759
+ process.stderr.write(`[work-queue] Enqueued ${item.type} (id=${item.id}, priority=${item.priority}).\n`);
3760
+ return item;
3761
+ }
3762
+ /**
3763
+ * Pick the next pending item that is ready to process (respects nextRetryAt).
3764
+ * Returns null if no eligible item exists.
3765
+ * Highest priority (lowest number) is processed first; ties broken by createdAt.
3766
+ * Types in `skip` stay pending (quiet mode defers them without dropping them).
3767
+ */
3768
+ function dequeue(skip) {
3769
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3770
+ const eligible = _queue.filter((i) => {
3771
+ if (i.status !== "pending") return false;
3772
+ if (skip?.has(i.type)) return false;
3773
+ if (i.nextRetryAt && i.nextRetryAt > now) return false;
3774
+ return true;
3775
+ }).sort((a, b) => {
3776
+ if (a.priority !== b.priority) return a.priority - b.priority;
3777
+ return a.createdAt.localeCompare(b.createdAt);
3778
+ });
3779
+ if (eligible.length === 0) return null;
3780
+ const item = eligible[0];
3781
+ item.status = "processing";
3782
+ item.attempts += 1;
3783
+ _dirty = true;
3784
+ saveIfDirty();
3785
+ return item;
3786
+ }
3787
+ /**
3788
+ * Mark an item as completed.
3789
+ */
3790
+ function markCompleted(id) {
3791
+ const item = _queue.find((i) => i.id === id);
3792
+ if (!item) return;
3793
+ item.status = "completed";
3794
+ item.completedAt = (/* @__PURE__ */ new Date()).toISOString();
3795
+ item.error = void 0;
3796
+ _dirty = true;
3797
+ saveIfDirty();
3798
+ }
3799
+ /**
3800
+ * Mark an item as failed.
3801
+ * If attempts < maxAttempts, schedules a retry with exponential backoff.
3802
+ * Otherwise, leaves status as 'failed'.
3803
+ */
3804
+ function markFailed(id, errorMsg, retryable = true) {
3805
+ const item = _queue.find((i) => i.id === id);
3806
+ if (!item) return;
3807
+ item.error = errorMsg;
3808
+ if (retryable && item.attempts < item.maxAttempts) {
3809
+ const backoffMs = BACKOFF_MS[item.attempts - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
3810
+ item.status = "pending";
3811
+ item.nextRetryAt = new Date(Date.now() + backoffMs).toISOString();
3812
+ process.stderr.write(`[work-queue] Item ${id} failed (attempt ${item.attempts}/${item.maxAttempts}), retry in ${backoffMs / 1e3}s: ${errorMsg}\n`);
3813
+ } else {
3814
+ item.status = "failed";
3815
+ process.stderr.write(retryable ? `[work-queue] Item ${id} exhausted retries (${item.maxAttempts} attempts): ${errorMsg}\n` : `[work-queue] Item ${id} failed, not retryable: ${errorMsg}\n`);
3816
+ }
3817
+ _dirty = true;
3818
+ saveIfDirty();
3819
+ }
3820
+ function getStats() {
3821
+ const stats = {
3822
+ pending: 0,
3823
+ processing: 0,
3824
+ completed: 0,
3825
+ failed: 0,
3826
+ total: _queue.length
3827
+ };
3828
+ for (const item of _queue) stats[item.status]++;
3829
+ return stats;
3830
+ }
3831
+ /**
3832
+ * Returns true if any pending or processing item of the given type exists.
3833
+ * Used for debouncing work items that are expensive to run concurrently.
3834
+ */
3835
+ function hasPendingOrProcessingOfType(type) {
3836
+ return _queue.some((i) => i.type === type && (i.status === "pending" || i.status === "processing"));
3837
+ }
3838
+ /**
3839
+ * Remove completed and permanently-failed items older than their TTL.
3840
+ * Also force-cleans all completed items if the queue file exceeds 1 MB.
3841
+ */
3842
+ function cleanup() {
3843
+ const now = Date.now();
3844
+ const before = _queue.length;
3845
+ let forceCleanCompleted = false;
3846
+ try {
3847
+ if (existsSync(QUEUE_FILE)) {
3848
+ const { size } = statSync(QUEUE_FILE);
3849
+ if (size > MAX_QUEUE_FILE_BYTES) {
3850
+ forceCleanCompleted = true;
3851
+ process.stderr.write(`[work-queue] Queue file exceeds 1 MB (${size} bytes) — force-cleaning completed items.\n`);
3852
+ }
3853
+ }
3854
+ } catch {}
3855
+ _queue = _queue.filter((item) => {
3856
+ if (item.status === "completed") {
3857
+ if (forceCleanCompleted) return false;
3858
+ return now - (item.completedAt ? new Date(item.completedAt).getTime() : 0) < COMPLETED_TTL_MS;
3859
+ }
3860
+ if (item.status === "failed") return now - new Date(item.createdAt).getTime() < FAILED_TTL_MS;
3861
+ return true;
3862
+ });
3863
+ const removed = before - _queue.length;
3864
+ const stats = getStats();
3865
+ if (removed > 0 || before === 0) process.stderr.write(`[work-queue] Cleanup: removed ${removed} items. Queue stats: pending=${stats.pending}, processing=${stats.processing}, completed=${stats.completed}, failed=${stats.failed}.\n`);
3866
+ _dirty = removed > 0;
3867
+ saveIfDirty();
3868
+ }
3869
+ /**
3870
+ * Enqueue unless an item of this type is already queued or running, or one
3871
+ * started/finished within `windowMs`. Repeat triggers collapse into the one
3872
+ * already queued or recently run. Returns the new item, or null when coalesced.
3873
+ */
3874
+ function enqueueCoalesced(params, windowMs, now = Date.now()) {
3875
+ if (hasPendingOrProcessingOfType(params.type)) return null;
3876
+ return _queue.some((i) => {
3877
+ if (i.type !== params.type || i.status === "failed") return false;
3878
+ return now - Date.parse(i.completedAt ?? i.createdAt) < windowMs;
3879
+ }) ? null : enqueue(params);
3880
+ }
3881
+
3882
+ //#endregion
3883
+ //#region src/memory/kg-backfill.ts
3884
+ /**
3885
+ * kg-backfill.ts — Populate the temporal knowledge graph from existing
3886
+ * session notes.
3887
+ *
3888
+ * Walks `Notes/YYYY/MM/*.md` for each registered project, runs the same
3889
+ * extractor that the session-summary-worker uses on every NEW summary, and
3890
+ * stores triples in Postgres. Idempotent: a state file at
3891
+ * ~/.config/pai/kg-backfill-state.json records which note paths have been
3892
+ * processed so re-runs skip them.
3893
+ *
3894
+ * Even without the state file the operation is safe — extractAndStoreTriples
3895
+ * uses supersession logic so re-extracting a note never produces duplicates.
3896
+ */
3897
+ function oldStateFile() {
3898
+ return join(homedir(), ".config", "pai", "kg-backfill-state.json");
3899
+ }
3900
+ function statePath() {
3901
+ return resolvePaiFile(paiHomePath("kg-backfill-state.json"), [oldStateFile()], "pai config migrate");
3902
+ }
3903
+ function migrateKgBackfillState(opts = {}) {
3904
+ return migratePaiFile(paiHomePath("kg-backfill-state.json"), [oldStateFile()], opts);
3905
+ }
3906
+ function loadState() {
3907
+ try {
3908
+ const file = statePath();
3909
+ if (existsSync(file)) {
3910
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
3911
+ if (raw && typeof raw === "object" && raw.processed) return raw;
3912
+ }
3913
+ } catch {}
3914
+ return { processed: {} };
3915
+ }
3916
+ function saveState(state) {
3917
+ try {
3918
+ const file = paiHomePath("kg-backfill-state.json");
3919
+ const dir = dirname(file);
3920
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3921
+ writeFileSync(file, JSON.stringify(state, null, 2), "utf-8");
3922
+ } catch (e) {
3923
+ process.stderr.write(`[kg-backfill] failed to save state: ${e}\n`);
3924
+ }
3925
+ }
3926
+ /**
3927
+ * Find all session notes under <project_root>/Notes/YYYY/MM/*.md.
3928
+ * Falls back to <project_root>/Notes/*.md (flat layout) when no year/month
3929
+ * subdirectories are present.
3930
+ */
3931
+ function findProjectNotes(project) {
3932
+ const notesRoot = join(project.root_path, "Notes");
3933
+ if (!existsSync(notesRoot)) return [];
3934
+ const found = [];
3935
+ function walk(dir, depth) {
3936
+ if (depth > 4) return;
3937
+ let entries;
3938
+ try {
3939
+ entries = readdirSync(dir);
3940
+ } catch {
3941
+ return;
3942
+ }
3943
+ for (const name of entries) {
3944
+ const full = join(dir, name);
3945
+ let st;
3946
+ try {
3947
+ st = statSync(full);
3948
+ } catch {
3949
+ continue;
3950
+ }
3951
+ if (st.isDirectory()) walk(full, depth + 1);
3952
+ else if (st.isFile() && name.endsWith(".md") && /^\d{3,4}/.test(name)) found.push({
3953
+ path: full,
3954
+ mtime: st.mtimeMs
3955
+ });
3956
+ }
3957
+ }
3958
+ walk(notesRoot, 0);
3959
+ found.sort((a, b) => a.mtime - b.mtime);
3960
+ return found.map((f) => f.path);
3961
+ }
3962
+ /**
3963
+ * Backfill the knowledge graph from existing session notes.
3964
+ *
3965
+ * Requires the Postgres backend (KG tables live there). Throws if Postgres
3966
+ * is unavailable, since this is an explicit user action.
3967
+ */
3968
+ async function backfillKgFromNotes(options = {}) {
3969
+ const result = {
3970
+ notes_processed: 0,
3971
+ triples_extracted: 0,
3972
+ triples_added: 0,
3973
+ triples_superseded: 0,
3974
+ errors: 0
3975
+ };
3976
+ const config = loadConfig();
3977
+ if (config.storageBackend !== "postgres") throw new Error(`kg backfill requires the Postgres backend. Set "storageBackend": "postgres" in ${CONFIG_FILE}.`);
3978
+ const backend = await createStorageBackend(config);
3979
+ if (!backend.supportsPostgresFeatures) throw new Error("Postgres backend unavailable — fell back to SQLite. Cannot backfill KG.");
3980
+ const registry = await getRegistryBackend();
3981
+ let projects;
3982
+ if (options.projectSlug) {
3983
+ const row = await registry.getProjectBySlug(options.projectSlug);
3984
+ if (!row) throw new Error(`Project not found: ${options.projectSlug}`);
3985
+ projects = [row];
3986
+ } else projects = await registry.listProjects({
3987
+ status: "active",
3988
+ orderBy: "slug"
3989
+ });
3990
+ const state = loadState();
3991
+ const work = [];
3992
+ for (const project of projects) {
3993
+ const notes = findProjectNotes(project);
3994
+ for (const notePath of notes) {
3995
+ if (state.processed[notePath]) continue;
3996
+ work.push({
3997
+ project,
3998
+ notePath
3999
+ });
4000
+ }
4001
+ }
4002
+ const total = options.limit ? Math.min(options.limit, work.length) : work.length;
4003
+ for (let i = 0; i < total; i++) {
4004
+ const { project, notePath } = work[i];
4005
+ options.onProgress?.(i + 1, total, notePath);
4006
+ if (options.dryRun) {
4007
+ result.notes_processed++;
4008
+ continue;
4009
+ }
4010
+ let noteContent;
4011
+ try {
4012
+ noteContent = readFileSync(notePath, "utf-8");
4013
+ } catch (e) {
4014
+ process.stderr.write(`[kg-backfill] read failed ${notePath}: ${e}\n`);
4015
+ result.errors++;
4016
+ continue;
4017
+ }
4018
+ try {
4019
+ const stats = await extractAndStoreTriples$1(backend, {
4020
+ summaryText: noteContent,
4021
+ projectSlug: project.slug,
4022
+ projectId: project.id,
4023
+ sessionId: `backfill:${notePath}`,
4024
+ gitLog: "",
4025
+ model: "sonnet"
4026
+ });
4027
+ result.notes_processed++;
4028
+ result.triples_extracted += stats.extracted;
4029
+ result.triples_added += stats.added;
4030
+ result.triples_superseded += stats.superseded;
4031
+ state.processed[notePath] = (/* @__PURE__ */ new Date()).toISOString();
4032
+ if ((i + 1) % 5 === 0) saveState(state);
4033
+ } catch (e) {
4034
+ process.stderr.write(`[kg-backfill] extract failed ${notePath}: ${e}\n`);
4035
+ result.errors++;
4036
+ }
4037
+ }
4038
+ if (!options.dryRun) saveState(state);
4039
+ await backend.close();
4040
+ return result;
4041
+ }
4042
+
4043
+ //#endregion
4044
+ //#region src/daemon/pass-priority.ts
4045
+ /**
4046
+ * pass-priority.ts — run heavy index/embed pass work at background priority.
4047
+ *
4048
+ * The daemon also serves MCP, so its own priority must stay normal. The heavy
4049
+ * work runs in a child (`pai memory pass <name>`) spawned under
4050
+ * `taskpolicy -b nice -n 19` (darwin) or `nice -n 19 ionice -c3` (linux);
4051
+ * onnxruntime threads inherit the child's priority. The child reports over
4052
+ * stdout as one JSON line: {ok:true,result} or {ok:false,error}.
4053
+ */
4054
+ function onPath(bin) {
4055
+ return (process.env.PATH ?? "").split(delimiter).some((d) => d && existsSync(join(d, bin)));
4056
+ }
4057
+ /**
4058
+ * Wrap `node <entry> <passArgs>` in the platform's low-priority incantation.
4059
+ * Darwin uses nice 19, not 20: measured on macOS, node started with nice 20
4060
+ * reverts to nice 0 within a second; 19 sticks.
4061
+ */
4062
+ function buildPassCommand(platform, node, entry, passArgs, have = onPath) {
4063
+ const run = [
4064
+ node,
4065
+ entry,
4066
+ ...passArgs
4067
+ ];
4068
+ let wrap;
4069
+ if (platform === "darwin") wrap = have("taskpolicy") ? [
4070
+ "taskpolicy",
4071
+ "-b",
4072
+ "nice",
4073
+ "-n",
4074
+ "19"
4075
+ ] : [
4076
+ "nice",
4077
+ "-n",
4078
+ "19"
4079
+ ];
4080
+ else if (platform === "win32") wrap = [];
4081
+ else wrap = have("ionice") ? [
4082
+ "nice",
4083
+ "-n",
4084
+ "19",
4085
+ "ionice",
4086
+ "-c3"
4087
+ ] : [
4088
+ "nice",
4089
+ "-n",
4090
+ "19"
4091
+ ];
4092
+ const [cmd, ...rest] = [...wrap, ...run];
4093
+ return {
4094
+ cmd,
4095
+ args: rest
4096
+ };
4097
+ }
4098
+ /** The CLI entry the child runs: dist/cli/index.mjs next to the daemon's dist. */
4099
+ function cliEntry() {
4100
+ return join(dirname(dirname(process.argv[1] ?? "")), "cli", "index.mjs");
4101
+ }
4102
+ /** Spawning itself failed (binary missing): the caller may fall back in-process. */
4103
+ var PassSpawnError = class extends Error {};
4104
+ const children = /* @__PURE__ */ new Set();
4105
+ process.on("exit", () => children.forEach((c) => c.kill()));
4106
+ /**
4107
+ * Run one pass in a low-priority child. Resolves with the child's result;
4108
+ * rejects with the child's error (pass failure) or PassSpawnError (no child).
4109
+ */
4110
+ function runPassChild(name, extraArgs = []) {
4111
+ const { cmd, args } = buildPassCommand(process.platform, process.execPath, cliEntry(), [
4112
+ "memory",
4113
+ "pass",
4114
+ name,
4115
+ ...extraArgs
4116
+ ]);
4117
+ return new Promise((resolve, reject) => {
4118
+ const child = spawn(cmd, args, {
4119
+ stdio: [
4120
+ "ignore",
4121
+ "pipe",
4122
+ "pipe"
4123
+ ],
4124
+ env: {
4125
+ ...process.env,
4126
+ PAI_PASS_BACKGROUND: "1"
4127
+ }
4128
+ });
4129
+ children.add(child);
4130
+ let out = "";
4131
+ let err = "";
4132
+ child.stdout.on("data", (d) => out += d);
4133
+ child.stderr.on("data", (d) => err = (err + d).slice(-4e3));
4134
+ child.on("error", (e) => {
4135
+ children.delete(child);
4136
+ reject(new PassSpawnError(`cannot spawn ${cmd}: ${e.message}`));
4137
+ });
4138
+ child.on("close", (code, signal) => {
4139
+ children.delete(child);
4140
+ const line = out.trim().split("\n").pop() ?? "";
4141
+ let msg;
4142
+ try {
4143
+ msg = JSON.parse(line);
4144
+ } catch {}
4145
+ if (code === 0 && msg?.ok) return resolve(msg.result);
4146
+ reject(new Error(msg?.error ?? `pass child ${name} exited ${code ?? signal}: ${err.trim().split("\n").pop() ?? ""}`));
4147
+ });
4148
+ });
4149
+ }
4150
+ /**
4151
+ * Inside the wrapper, keep nice at 19: measured on macOS, node's nice resets
4152
+ * to 0 about a second after start (5s re-apply interval holds it; the
4153
+ * taskpolicy -b QoS is unaffected).
4154
+ */
4155
+ function holdNice() {
4156
+ const apply = () => {
4157
+ try {
4158
+ setPriority(process.pid, 19);
4159
+ } catch {}
4160
+ };
4161
+ apply();
4162
+ setInterval(apply, 5e3).unref();
4163
+ }
4164
+ /**
4165
+ * `--background` for CLI commands: re-exec this very command under the
4166
+ * low-priority wrapper and exit with its status. Inside the re-exec (or a
4167
+ * daemon-spawned pass child, which sets the same env) it only holds nice.
4168
+ */
4169
+ function reexecBackground() {
4170
+ if (process.env.PAI_PASS_BACKGROUND === "1") return holdNice();
4171
+ const { cmd, args } = buildPassCommand(process.platform, process.execPath, process.argv[1], process.argv.slice(2));
4172
+ const r = spawnSync(cmd, args, {
4173
+ stdio: "inherit",
4174
+ env: {
4175
+ ...process.env,
4176
+ PAI_PASS_BACKGROUND: "1"
4177
+ }
4178
+ });
4179
+ if (r.error) {
4180
+ process.stderr.write(`--background: cannot spawn ${cmd} (${r.error.message}); running at normal priority\n`);
4181
+ return;
4182
+ }
4183
+ process.exit(r.status ?? 1);
4184
+ }
4185
+
4186
+ //#endregion
4187
+ export { setVaultIndexInProgress as $, indexSchedulerTimer as A, DEFAULT_EMBED_MAX_MINUTES as At, setEmbedInProgress as B, applyContinue as Bt, migrateSummaryCooldowns as C, describeQuiet as Ct, embedInProgress as D, startQuietWatcher as Dt, daemonConfig as E, registerQuietRunner as Et, notificationConfig as F, resolveDaemonPid as Ft, setLastEmbedTime as G, setIndexInProgress as H, findNotesDir$1 as Ht, passFailures as I, writeDaemonPidfile as It, setNotificationConfig as J, setLastIndexTime as K, registryBackend as L, readContextHandoverCache as Lt, lastEmbedTime as M, formatEmbedStatus as Mt, lastIndexTime as N, liveYieldCheck as Nt, embedSchedulerTimer as O, writeQuiet as Ot, lastVaultIndexTime as P, removeDaemonPidfile as Pt, setStorageBackend as Q, setCacheKeepaliveTimer as R, writeContextHandoverCache as Rt, handleSessionSummary as S, deferIfQuiet as St, cacheKeepaliveTimer as T, readQuiet as Tt, setIndexSchedulerTimer as U, readBodyFile as Ut, setEmbedSchedulerTimer as V, findLatestNote as Vt, setLastEmbedPass as W, setShutdownRequested as X, setRegistryBackend as Y, setStartTime as Z, markFailed as _, loadSessionKeepaliveState as _t, runPassChild as a, updateTodoContinue as at, findLatestJsonl as b, parseSessionUsage as bt, NonRetryableError as c, finalizeSessionNote as ct, enqueue as d, findNotesDir as dt, shutdownRequested as et, enqueueCoalesced as f, OBSIDIAN_DISABLED_MESSAGE as ft, markCompleted as g, migrateIdentityFile as gt, loadQueue as h, buildWakeupContext as ht, reexecBackground as i, sessionIdFromTranscript as it, lastEmbedPass as j, EMBED_PASS_BATCH_SIZE as jt, indexInProgress as k, DEFAULT_EMBED_MAX_LOAD as kt, cleanup as l, getCurrentNotePath as lt, hasPendingOrProcessingOfType as m, vaultIndexEnabled as mt, buildPassCommand as n, storageBackend as nt, backfillKgFromNotes as o, addWorkToSessionNote as ot, getStats as p, obsidianEnabled as pt, setLastVaultIndexTime as q, cliEntry as r, vaultIndexInProgress as rt, migrateKgBackfillState as s, appendCheckpoint as st, PassSpawnError as t, startTime as tt, dequeue as u, archiveSessionFilesToSessionsDir as ut, migrateWorkQueue as v, runSessionKeepaliveTick as vt, spawnSummarizer as w, nextOccurrence as wt, getGitContext as x, totalUsageTokens as xt, contentToText as y, sessionKeepaliveLedgerSummary as yt, setDaemonConfig as z, appendCheckpointToNote as zt };
4188
+ //# sourceMappingURL=indexer-backend-1tkR2y1-.mjs.map