@tekmidian/pai 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +11 -3
  2. package/dist/auto-route-DkIRampF.mjs +86 -0
  3. package/dist/auto-route-DkIRampF.mjs.map +1 -0
  4. package/dist/checkpoint-block-Bl0HD3U2.mjs.map +1 -1
  5. package/dist/checkpoint-block-C05sK_HK.mjs +1056 -0
  6. package/dist/checkpoint-block-C05sK_HK.mjs.map +1 -0
  7. package/dist/checkpoint-block-CUCG10Uh.mjs +1022 -0
  8. package/dist/checkpoint-block-CUCG10Uh.mjs.map +1 -0
  9. package/dist/checkpoint-block-D5IyLLTr.mjs +1062 -0
  10. package/dist/checkpoint-block-D5IyLLTr.mjs.map +1 -0
  11. package/dist/cli/index.mjs +6 -6
  12. package/dist/cli/program.mjs +7 -7
  13. package/dist/cli/program.mjs.map +1 -1
  14. package/dist/daemon/index.mjs +9 -9
  15. package/dist/daemon-9AL6Pwh-.mjs +1581 -0
  16. package/dist/daemon-9AL6Pwh-.mjs.map +1 -0
  17. package/dist/daemon-Bbk-_rV6.mjs +1581 -0
  18. package/dist/daemon-Bbk-_rV6.mjs.map +1 -0
  19. package/dist/daemon-BvcaMk4g.mjs +1581 -0
  20. package/dist/daemon-BvcaMk4g.mjs.map +1 -0
  21. package/dist/daemon-CTDEfwcq.mjs +1576 -0
  22. package/dist/daemon-CTDEfwcq.mjs.map +1 -0
  23. package/dist/daemon-ClbfaF60.mjs +1576 -0
  24. package/dist/daemon-ClbfaF60.mjs.map +1 -0
  25. package/dist/daemon-DDQNDgYF.mjs +1576 -0
  26. package/dist/daemon-DDQNDgYF.mjs.map +1 -0
  27. package/dist/daemon-DU8yDWlQ.mjs +1576 -0
  28. package/dist/daemon-DU8yDWlQ.mjs.map +1 -0
  29. package/dist/daemon-NJr4iSW3.mjs +1576 -0
  30. package/dist/daemon-NJr4iSW3.mjs.map +1 -0
  31. package/dist/detector-Bwzm3E_x.mjs +74 -0
  32. package/dist/detector-Bwzm3E_x.mjs.map +1 -0
  33. package/dist/embeddings-Bn86ssxR.mjs +119 -0
  34. package/dist/embeddings-Bn86ssxR.mjs.map +1 -0
  35. package/dist/factory-0-57Ac-7.mjs +72 -0
  36. package/dist/factory-0-57Ac-7.mjs.map +1 -0
  37. package/dist/factory-PDXQdkLO.mjs +72 -0
  38. package/dist/factory-PDXQdkLO.mjs.map +1 -0
  39. package/dist/hooks/context-compression-hook.mjs +36 -4
  40. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  41. package/dist/hooks/load-project-context.mjs +188 -6
  42. package/dist/hooks/load-project-context.mjs.map +4 -4
  43. package/dist/hooks/stop-hook.mjs +36 -4
  44. package/dist/hooks/stop-hook.mjs.map +3 -3
  45. package/dist/index.mjs +3 -3
  46. package/dist/indexer-backend-BmaS2VcD.mjs +299 -0
  47. package/dist/indexer-backend-BmaS2VcD.mjs.map +1 -0
  48. package/dist/indexer-backend-COrJ5rjS.mjs +299 -0
  49. package/dist/indexer-backend-COrJ5rjS.mjs.map +1 -0
  50. package/dist/indexer-backend-NwD7TBGh.mjs +301 -0
  51. package/dist/indexer-backend-NwD7TBGh.mjs.map +1 -0
  52. package/dist/latent-ideas-BwM3WdBp.mjs +191 -0
  53. package/dist/latent-ideas-BwM3WdBp.mjs.map +1 -0
  54. package/dist/neighborhood-DZY0wkpE.mjs +135 -0
  55. package/dist/neighborhood-DZY0wkpE.mjs.map +1 -0
  56. package/dist/pick-B0q5cy79.mjs +11785 -0
  57. package/dist/pick-B0q5cy79.mjs.map +1 -0
  58. package/dist/pick-BHE9_iA0.mjs +11947 -0
  59. package/dist/pick-BHE9_iA0.mjs.map +1 -0
  60. package/dist/pick-BaTjoNqM.mjs +11544 -0
  61. package/dist/pick-BaTjoNqM.mjs.map +1 -0
  62. package/dist/pick-C6JbZfN4.mjs +11893 -0
  63. package/dist/pick-C6JbZfN4.mjs.map +1 -0
  64. package/dist/pick-CK-bJyjp.mjs +11883 -0
  65. package/dist/pick-CK-bJyjp.mjs.map +1 -0
  66. package/dist/pick-CLBkGHyI.mjs +11826 -0
  67. package/dist/pick-CLBkGHyI.mjs.map +1 -0
  68. package/dist/pick-CTOif21h.mjs +11544 -0
  69. package/dist/pick-CTOif21h.mjs.map +1 -0
  70. package/dist/pick-Ck2cdQgD.mjs +11893 -0
  71. package/dist/pick-Ck2cdQgD.mjs.map +1 -0
  72. package/dist/pick-Cr4nJzHA.mjs +11914 -0
  73. package/dist/pick-Cr4nJzHA.mjs.map +1 -0
  74. package/dist/pick-D4dFfKAu.mjs +11893 -0
  75. package/dist/pick-D4dFfKAu.mjs.map +1 -0
  76. package/dist/pick-D7-lYiGX.mjs +11543 -0
  77. package/dist/pick-D7-lYiGX.mjs.map +1 -0
  78. package/dist/pick-DMjCY0np.mjs +11875 -0
  79. package/dist/pick-DMjCY0np.mjs.map +1 -0
  80. package/dist/pick-DbX4OrCP.mjs +11543 -0
  81. package/dist/pick-DbX4OrCP.mjs.map +1 -0
  82. package/dist/pick-iCiPjlnC.mjs +11893 -0
  83. package/dist/pick-iCiPjlnC.mjs.map +1 -0
  84. package/dist/pick-sF9GnQUS.mjs +11893 -0
  85. package/dist/pick-sF9GnQUS.mjs.map +1 -0
  86. package/dist/pick-yHKUrnfG.mjs +11793 -0
  87. package/dist/pick-yHKUrnfG.mjs.map +1 -0
  88. package/dist/postgres-BJxiqtqg.mjs +891 -0
  89. package/dist/postgres-BJxiqtqg.mjs.map +1 -0
  90. package/dist/search-CpTv1I24.mjs +298 -0
  91. package/dist/search-CpTv1I24.mjs.map +1 -0
  92. package/dist/skills/Pause/SKILL.md +11 -4
  93. package/dist/sqlite-CZ0LFili.mjs +271 -0
  94. package/dist/sqlite-CZ0LFili.mjs.map +1 -0
  95. package/dist/sqlite-Nw8ZW5Rj.mjs +271 -0
  96. package/dist/sqlite-Nw8ZW5Rj.mjs.map +1 -0
  97. package/dist/sync--BoxBBok.mjs +310 -0
  98. package/dist/sync--BoxBBok.mjs.map +1 -0
  99. package/dist/themes-BBOlGXAg.mjs +148 -0
  100. package/dist/themes-BBOlGXAg.mjs.map +1 -0
  101. package/dist/tools-B6BmaAvz.mjs +1939 -0
  102. package/dist/tools-B6BmaAvz.mjs.map +1 -0
  103. package/dist/tools-CAIY8TAb.mjs +1939 -0
  104. package/dist/tools-CAIY8TAb.mjs.map +1 -0
  105. package/dist/work-queue-worker-BoHIH70K.mjs +1856 -0
  106. package/dist/work-queue-worker-BoHIH70K.mjs.map +1 -0
  107. package/dist/work-queue-worker-CZGcQoLw.mjs +1856 -0
  108. package/dist/work-queue-worker-CZGcQoLw.mjs.map +1 -0
  109. package/dist/work-queue-worker-D05Ze9e9.mjs +1856 -0
  110. package/dist/work-queue-worker-D05Ze9e9.mjs.map +1 -0
  111. package/dist/work-queue-worker-DdXFINfH.mjs +1856 -0
  112. package/dist/work-queue-worker-DdXFINfH.mjs.map +1 -0
  113. package/dist/zettelkasten-D0N_A_OE.mjs +1063 -0
  114. package/dist/zettelkasten-D0N_A_OE.mjs.map +1 -0
  115. package/docs/commands/README.md +2 -1
  116. package/docs/commands/pause.md +1 -1
  117. package/docs/commands/session.md +27 -3
  118. package/package.json +1 -1
  119. package/plugins/context-preservation/hooks/hooks.json +12 -1
  120. package/scripts/build-hooks.mjs +1 -0
  121. package/src/hooks/session-autosave.sh +69 -0
  122. package/src/hooks/session-stop.sh +32 -1
  123. package/src/hooks/ts/session-start/load-project-context.ts +44 -0
@@ -0,0 +1,1022 @@
1
+ import { _ as warn, c as ok, i as err, n as dim, r as encodeDir, s as now, t as bold } from "./utils-BAxjW3j8.mjs";
2
+ import { a as slugify, i as parseSessionFilename, n as decodeEncodedDir, t as buildEncodedDirMap } from "./migrate-fLD6rAdO.mjs";
3
+ import { n as ensurePaiMarker, t as discoverPaiMarkers } from "./pai-marker-B20KqhA8.mjs";
4
+ import { i as kgAdd, o as kgInvalidate, r as upsertKgEntity, s as kgQuery } from "./kg-entity-DbOMPdF9.mjs";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { basename, join, resolve } from "node:path";
8
+
9
+ //#region src/cli/commands/registry/utils.ts
10
+ /**
11
+ * Upsert a project row. Returns { id, isNew }.
12
+ *
13
+ * Matching priority:
14
+ * 1. root_path — most reliable; handles slug collisions
15
+ * 2. encoded_dir — Claude project dirs are canonical
16
+ * 3. Insert with suffix-deduplication on slug collision
17
+ *
18
+ * display_name is set to basename(rootPath) on INSERT so that the unified
19
+ * listing always shows a human-readable name rather than the kebab-case slug.
20
+ */
21
+ function upsertProject(db, slug, rootPath, encodedDir) {
22
+ const ts = now();
23
+ const byPath = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
24
+ if (byPath) {
25
+ const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
26
+ if (!encodedOwner || encodedOwner.id === byPath.id) db.prepare("UPDATE projects SET encoded_dir = ?, updated_at = ? WHERE id = ?").run(encodedDir, ts, byPath.id);
27
+ return {
28
+ id: byPath.id,
29
+ isNew: false
30
+ };
31
+ }
32
+ const byEncoded = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
33
+ if (byEncoded) {
34
+ const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
35
+ if (!pathOwner || pathOwner.id === byEncoded.id) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(rootPath, ts, byEncoded.id);
36
+ return {
37
+ id: byEncoded.id,
38
+ isNew: false
39
+ };
40
+ }
41
+ let finalSlug = slug;
42
+ let attempt = 0;
43
+ while (true) {
44
+ if (!db.prepare("SELECT id FROM projects WHERE slug = ?").get(finalSlug)) break;
45
+ attempt++;
46
+ finalSlug = `${slug}-${attempt}`;
47
+ }
48
+ const displayName = basename(rootPath) || finalSlug;
49
+ const result = db.prepare(`INSERT OR IGNORE INTO projects
50
+ (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
51
+ VALUES (?, ?, ?, ?, 'local', 'active', ?, ?)`).run(finalSlug, displayName, rootPath, encodedDir, ts, ts);
52
+ if (result.changes === 0) {
53
+ const fallback = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir) ?? db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
54
+ if (fallback) return {
55
+ id: fallback.id,
56
+ isNew: false
57
+ };
58
+ throw new Error(`upsertProject: INSERT OR IGNORE was suppressed but no matching row found for root_path=${rootPath} encoded_dir=${encodedDir}`);
59
+ }
60
+ return {
61
+ id: result.lastInsertRowid,
62
+ isNew: true
63
+ };
64
+ }
65
+ /** Upsert a session note. Returns true if newly inserted. */
66
+ function upsertSession(db, projectId, number, date, slug, title, filename) {
67
+ if (db.prepare("SELECT id FROM sessions WHERE project_id = ? AND number = ?").get(projectId, number)) return false;
68
+ const ts = now();
69
+ db.prepare(`INSERT INTO sessions
70
+ (project_id, number, date, slug, title, filename, status, created_at)
71
+ VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`).run(projectId, number, date, slug, title, filename, ts);
72
+ return true;
73
+ }
74
+
75
+ //#endregion
76
+ //#region src/cli/commands/registry/scan.ts
77
+ /** Registry scan command: walk ~/.claude/projects/ and populate the registry. */
78
+ /**
79
+ * Build a reverse map from encoded-dir → real path using the clc session
80
+ * registry (~/.claude/session.json).
81
+ *
82
+ * The clc registry stores { sessions: [{ directory, ... }, ...] }. For each
83
+ * entry with a resolvable directory, we encode the realpath and add it to
84
+ * the map. This lets the scanner recover project paths that are not in
85
+ * Claude's session-registry.json but were registered by clc.
86
+ */
87
+ function buildClcDirMap() {
88
+ const map = /* @__PURE__ */ new Map();
89
+ const sessionFile = join(homedir(), ".claude", "session.json");
90
+ if (!existsSync(sessionFile)) return map;
91
+ try {
92
+ const raw = readFileSync(sessionFile, "utf8");
93
+ const parsed = JSON.parse(raw);
94
+ for (const entry of parsed.sessions ?? []) {
95
+ const dir = entry.directory;
96
+ if (!dir) continue;
97
+ try {
98
+ const real = realpathSync(dir);
99
+ if (!existsSync(real)) continue;
100
+ const encoded = encodeDir(real);
101
+ map.set(encoded, real);
102
+ } catch {}
103
+ }
104
+ } catch {}
105
+ return map;
106
+ }
107
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
108
+ const PAI_CONFIG_DIR = join(homedir(), ".pai");
109
+ const PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, "config.json");
110
+ function loadScanConfig() {
111
+ if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };
112
+ try {
113
+ return JSON.parse(readFileSync(PAI_CONFIG_FILE, "utf8"));
114
+ } catch {
115
+ return { scan_dirs: [] };
116
+ }
117
+ }
118
+ function saveScanConfig(config) {
119
+ mkdirSync(PAI_CONFIG_DIR, { recursive: true });
120
+ writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
121
+ }
122
+ /**
123
+ * Resolve a path to its canonical form, falling back to the input.
124
+ *
125
+ * Every path that becomes a `projects.root_path` must go through this. That
126
+ * column is UNIQUE, so two spellings of one directory create two projects and
127
+ * split session history between them. The spellings are not hypothetical: a
128
+ * configured scan_dir of `~/dev/ai` (where `~/dev` is a symlink) makes every
129
+ * project under it register under the symlinked path, while a session started
130
+ * from the resolved path registers a rival row.
131
+ */
132
+ function canonicalPath(p) {
133
+ try {
134
+ return realpathSync(p);
135
+ } catch {
136
+ return p;
137
+ }
138
+ }
139
+ function resolveHome(p) {
140
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
141
+ return resolve(p);
142
+ }
143
+ /**
144
+ * Recursively find all .md files in a directory, including YYYY/MM subdirectories.
145
+ * Returns filenames (basename only).
146
+ */
147
+ function findNoteFiles(dir) {
148
+ const results = [];
149
+ if (!existsSync(dir)) return results;
150
+ for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".md")) results.push(entry.name);
151
+ else if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
152
+ const yearDir = join(dir, entry.name);
153
+ for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) if (monthEntry.isDirectory() && /^\d{2}$/.test(monthEntry.name)) {
154
+ const monthDir = join(yearDir, monthEntry.name);
155
+ for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) if (noteEntry.isFile() && noteEntry.name.endsWith(".md")) results.push(noteEntry.name);
156
+ }
157
+ }
158
+ return results;
159
+ }
160
+ function performScan(db) {
161
+ const result = {
162
+ projectsScanned: 0,
163
+ projectsNew: 0,
164
+ projectsUpdated: 0,
165
+ sessionsScanned: 0,
166
+ sessionsNew: 0,
167
+ skipped: []
168
+ };
169
+ if (!existsSync(CLAUDE_PROJECTS_DIR)) throw new Error(`Claude projects directory not found: ${CLAUDE_PROJECTS_DIR}`);
170
+ const entries = readdirSync(CLAUDE_PROJECTS_DIR).filter((name) => {
171
+ return statSync(join(CLAUDE_PROJECTS_DIR, name)).isDirectory();
172
+ });
173
+ const lookupMap = buildEncodedDirMap();
174
+ const clcMap = buildClcDirMap();
175
+ for (const encodedDir of entries) {
176
+ let rootPath = decodeEncodedDir(encodedDir, lookupMap);
177
+ if (!existsSync(rootPath) && clcMap.has(encodedDir)) {
178
+ const clcPath = clcMap.get(encodedDir);
179
+ if (existsSync(clcPath)) rootPath = clcPath;
180
+ }
181
+ if (!existsSync(rootPath)) {
182
+ result.skipped.push(`${encodedDir} (decoded: ${rootPath} — path not found on disk)`);
183
+ result.projectsScanned++;
184
+ continue;
185
+ }
186
+ rootPath = canonicalPath(rootPath);
187
+ const slug = slugify(basename(rootPath) || encodedDir);
188
+ const { id, isNew } = upsertProject(db, slug, rootPath, encodedDir);
189
+ result.projectsScanned++;
190
+ if (isNew) result.projectsNew++;
191
+ else result.projectsUpdated++;
192
+ try {
193
+ ensurePaiMarker(rootPath, slug);
194
+ } catch {}
195
+ const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, "Notes");
196
+ if (existsSync(claudeNotesDir)) {
197
+ if (claudeNotesDir !== join(rootPath, "Notes")) db.prepare("UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?").run(claudeNotesDir, Date.now(), id);
198
+ }
199
+ if (!existsSync(claudeNotesDir)) continue;
200
+ const noteFiles = findNoteFiles(claudeNotesDir);
201
+ for (const filename of noteFiles) {
202
+ const parsed = parseSessionFilename(filename);
203
+ if (!parsed) continue;
204
+ result.sessionsScanned++;
205
+ if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
206
+ }
207
+ }
208
+ {
209
+ const activeProjects = db.prepare("SELECT id, slug, root_path FROM projects WHERE status = 'active'").all();
210
+ for (const project of activeProjects) {
211
+ const notesDir = join(project.root_path, "Notes");
212
+ if (!existsSync(notesDir)) continue;
213
+ let files;
214
+ try {
215
+ files = findNoteFiles(notesDir);
216
+ } catch {
217
+ continue;
218
+ }
219
+ for (const filename of files) {
220
+ const parsed = parseSessionFilename(filename);
221
+ if (!parsed) continue;
222
+ result.sessionsScanned++;
223
+ if (upsertSession(db, project.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
224
+ }
225
+ }
226
+ }
227
+ const config = loadScanConfig();
228
+ if (config.scan_dirs.length) for (const rawDir of config.scan_dirs) {
229
+ const scanDir = resolveHome(rawDir);
230
+ if (!existsSync(scanDir)) {
231
+ result.skipped.push(`${rawDir} (configured scan_dir not found)`);
232
+ continue;
233
+ }
234
+ const children = readdirSync(scanDir).filter((name) => {
235
+ if (name.startsWith(".")) return false;
236
+ const full = join(scanDir, name);
237
+ try {
238
+ return statSync(full).isDirectory();
239
+ } catch {
240
+ return false;
241
+ }
242
+ });
243
+ for (const child of children) {
244
+ const childPath = canonicalPath(join(scanDir, child));
245
+ const childSlug = slugify(child);
246
+ const childEncoded = encodeDir(childPath);
247
+ const existing = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(childPath);
248
+ if (existing) {
249
+ result.projectsScanned++;
250
+ result.projectsUpdated++;
251
+ try {
252
+ ensurePaiMarker(childPath, childSlug);
253
+ } catch {}
254
+ const notesDir = join(childPath, "Notes");
255
+ if (existsSync(notesDir)) {
256
+ const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
257
+ for (const filename of noteFiles) {
258
+ const parsed = parseSessionFilename(filename);
259
+ if (!parsed) continue;
260
+ result.sessionsScanned++;
261
+ if (upsertSession(db, existing.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
262
+ }
263
+ }
264
+ continue;
265
+ }
266
+ const { id, isNew } = upsertProject(db, childSlug, childPath, childEncoded);
267
+ result.projectsScanned++;
268
+ if (isNew) result.projectsNew++;
269
+ else result.projectsUpdated++;
270
+ try {
271
+ ensurePaiMarker(childPath, childSlug);
272
+ } catch {}
273
+ const notesDir = join(childPath, "Notes");
274
+ if (existsSync(notesDir)) {
275
+ const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
276
+ for (const filename of noteFiles) {
277
+ const parsed = parseSessionFilename(filename);
278
+ if (!parsed) continue;
279
+ result.sessionsScanned++;
280
+ if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
281
+ }
282
+ }
283
+ }
284
+ }
285
+ if (config.scan_dirs.length) {
286
+ const markers = discoverPaiMarkers(config.scan_dirs.map(resolveHome).filter(existsSync));
287
+ for (const marker of markers) {
288
+ const registeredRow = db.prepare("SELECT id, root_path, slug FROM projects WHERE slug = ?").get(marker.slug);
289
+ if (!registeredRow) continue;
290
+ const markerRoot = canonicalPath(marker.projectRoot);
291
+ if (registeredRow.root_path !== markerRoot) {
292
+ const newEncoded = encodeDir(markerRoot);
293
+ const now4 = Date.now();
294
+ const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(newEncoded);
295
+ const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(markerRoot);
296
+ const encodedSafe = !encodedOwner || encodedOwner.id === registeredRow.id;
297
+ const pathSafe = !pathOwner || pathOwner.id === registeredRow.id;
298
+ if (encodedSafe && pathSafe) db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(markerRoot, newEncoded, now4, registeredRow.id);
299
+ else if (pathSafe) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(markerRoot, now4, registeredRow.id);
300
+ }
301
+ }
302
+ }
303
+ {
304
+ const stale = db.prepare("SELECT id, slug, root_path FROM projects WHERE display_name = slug AND root_path IS NOT NULL AND root_path != ''").all();
305
+ for (const row of stale) {
306
+ const name = basename(row.root_path);
307
+ if (name && name !== row.slug) db.prepare("UPDATE projects SET display_name = ?, updated_at = ? WHERE id = ?").run(name, Date.now(), row.id);
308
+ }
309
+ }
310
+ return result;
311
+ }
312
+ /**
313
+ * Run the registry scan CLI command.
314
+ *
315
+ * @param opts.quick When true, skips verbose output (same scan, less noise).
316
+ * The underlying scan is always incremental via upsert —
317
+ * this flag exists for hook/daemon-triggered invocations that
318
+ * want minimal log output.
319
+ */
320
+ function cmdScan(db, opts = {}) {
321
+ const config = loadScanConfig();
322
+ if (!opts.quick) {
323
+ console.log(dim("Scanning ~/.claude/projects/ ..."));
324
+ if (config.scan_dirs.length) console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(", ")}`));
325
+ console.log(dim("Scanning project-root Notes/ directories ..."));
326
+ }
327
+ let result;
328
+ try {
329
+ result = performScan(db);
330
+ } catch (e) {
331
+ console.error(err(String(e)));
332
+ process.exit(1);
333
+ }
334
+ if (!opts.quick) {
335
+ console.log(ok(`Scanned ${bold(String(result.projectsScanned))} projects, ${bold(String(result.sessionsScanned))} session notes.`));
336
+ console.log(dim(` Projects: ${result.projectsNew} new, ${result.projectsUpdated} updated`));
337
+ console.log(dim(` Sessions: ${result.sessionsNew} new`));
338
+ if (result.skipped.length) {
339
+ console.log();
340
+ console.log(warn(` ${result.skipped.length} project(s) skipped (path not found on disk):`));
341
+ for (const s of result.skipped.slice(0, 10)) console.log(dim(` ${s}`));
342
+ if (result.skipped.length > 10) console.log(dim(` ... and ${result.skipped.length - 10} more`));
343
+ }
344
+ } else process.stderr.write(`[registry-scan] ${result.projectsScanned} projects, ${result.sessionsScanned} sessions (${result.projectsNew}+${result.sessionsNew} new).\n`);
345
+ }
346
+
347
+ //#endregion
348
+ //#region src/daemon/templates/triple-extraction-prompt.ts
349
+ /**
350
+ * triple-extraction-prompt.ts — Prompt template for KG triple extraction.
351
+ *
352
+ * Used by the session-summary-worker to extract structured facts from
353
+ * a completed session summary and store them in the temporal knowledge graph.
354
+ */
355
+ function buildTripleExtractionPrompt(params) {
356
+ return `Extract structured entities and relations from this coding session.
357
+
358
+ Output a single JSON object with two arrays: "entities" and "relations".
359
+
360
+ Entity types: project | person | concept | tool | file | version | decision | technology | organization
361
+
362
+ Rules:
363
+ - Be SPECIFIC: entity names must be concrete (e.g., "FSRS", "Glidr", "Matthias")
364
+ - Use snake_case relation verb phrases (e.g., "uses_algorithm", "decided_to", "shipped_version")
365
+ - Skip opinions, speculation, and "we should" statements
366
+ - Skip entities obvious from project metadata unless they have a meaningful relation
367
+ - Maximum 15 relations per session — pick the most important
368
+ - Each entity should have a brief description (1 sentence, what it is in this context)
369
+ - Each relation must reference entity names that appear in the entities array
370
+
371
+ Example output:
372
+ {
373
+ "entities": [
374
+ {"name": "Glidr", "type": "project", "description": "Flashcard app using FSRS spaced repetition"},
375
+ {"name": "FSRS", "type": "concept", "description": "Free Spaced Repetition Scheduler algorithm"},
376
+ {"name": "Matthias", "type": "person", "description": "Developer of Glidr and Quassl"},
377
+ {"name": "Quassl", "type": "project", "description": "iOS app being rewritten in Flutter"},
378
+ {"name": "Flutter", "type": "technology", "description": "Cross-platform mobile framework"}
379
+ ],
380
+ "relations": [
381
+ {"source": "Glidr", "relation": "uses_algorithm", "target": "FSRS"},
382
+ {"source": "Glidr", "relation": "shipped_version", "target": "1.0.5"},
383
+ {"source": "Matthias", "relation": "decided_to_rewrite", "target": "Quassl"},
384
+ {"source": "Quassl", "relation": "migrating_to", "target": "Flutter"}
385
+ ]
386
+ }
387
+
388
+ PROJECT: ${params.projectSlug}
389
+
390
+ SESSION CONTENT:
391
+ ${params.sessionContent}
392
+
393
+ GIT COMMITS:
394
+ ${params.gitLog}
395
+
396
+ JSON object (entities + relations):`;
397
+ }
398
+
399
+ //#endregion
400
+ //#region src/memory/kg-extraction.ts
401
+ /**
402
+ * kg-extraction.ts — Shared KG triple extraction logic.
403
+ *
404
+ * Extracted from session-summary-worker.ts so both the worker and the
405
+ * CLI backfill (`pai kg backfill`) can use the same code path.
406
+ *
407
+ * Provides:
408
+ * - findClaudeBinary() — locate the claude CLI
409
+ * - spawnClaude() — generic prompt -> response runner (strips ANTHROPIC_API_KEY)
410
+ * - extractAndStoreTriples() — run the extractor prompt and persist triples to Postgres
411
+ */
412
+ /**
413
+ * Find the `claude` CLI binary. Checks common installation locations first
414
+ * (launchd PATH is minimal so bare "claude" often won't resolve).
415
+ */
416
+ function findClaudeBinary() {
417
+ const candidates = [
418
+ join(homedir(), ".local", "bin", "claude"),
419
+ join(homedir(), ".claude", "local", "claude"),
420
+ "/usr/local/bin/claude",
421
+ "/opt/homebrew/bin/claude"
422
+ ];
423
+ for (const candidate of candidates) try {
424
+ if (existsSync(candidate)) return candidate;
425
+ } catch {}
426
+ return "claude";
427
+ }
428
+ const CLAUDE_TIMEOUT_MS = {
429
+ haiku: 6e4,
430
+ sonnet: 12e4,
431
+ opus: 3e5
432
+ };
433
+ /**
434
+ * Spawn the claude CLI with a prompt on stdin and return stdout.
435
+ *
436
+ * IMPORTANT: ANTHROPIC_API_KEY is stripped from the spawned environment so
437
+ * the CLI uses the user's Max plan (free) instead of billing the API key.
438
+ */
439
+ async function spawnClaude(prompt, model = "sonnet") {
440
+ const claudeBin = findClaudeBinary();
441
+ if (!claudeBin) {
442
+ process.stderr.write("[kg-extraction] claude CLI not found.\n");
443
+ return null;
444
+ }
445
+ const { spawn } = await import("node:child_process");
446
+ return new Promise((resolve) => {
447
+ let timer = null;
448
+ const { ANTHROPIC_API_KEY: _drop, ...envWithoutApiKey } = process.env;
449
+ const child = spawn(claudeBin, [
450
+ "--model",
451
+ model,
452
+ "-p",
453
+ "--no-session-persistence"
454
+ ], {
455
+ env: envWithoutApiKey,
456
+ stdio: [
457
+ "pipe",
458
+ "pipe",
459
+ "pipe"
460
+ ]
461
+ });
462
+ let stdout = "";
463
+ let stderr = "";
464
+ child.stdout.on("data", (chunk) => {
465
+ stdout += chunk.toString();
466
+ });
467
+ child.stderr.on("data", (chunk) => {
468
+ stderr += chunk.toString();
469
+ });
470
+ child.on("error", (err) => {
471
+ if (timer) {
472
+ clearTimeout(timer);
473
+ timer = null;
474
+ }
475
+ process.stderr.write(`[kg-extraction] ${model} spawn error: ${err.message}\n`);
476
+ resolve(null);
477
+ });
478
+ child.on("close", (code) => {
479
+ if (timer) {
480
+ clearTimeout(timer);
481
+ timer = null;
482
+ }
483
+ if (code !== 0) {
484
+ process.stderr.write(`[kg-extraction] ${model} exited ${code}: ${stderr.slice(0, 300)}\n`);
485
+ resolve(null);
486
+ } else resolve(stdout.trim() || null);
487
+ });
488
+ timer = setTimeout(() => {
489
+ process.stderr.write(`[kg-extraction] ${model} timed out — killing process.\n`);
490
+ child.kill("SIGTERM");
491
+ resolve(null);
492
+ }, CLAUDE_TIMEOUT_MS[model] ?? 12e4);
493
+ child.stdin.write(prompt);
494
+ child.stdin.end();
495
+ });
496
+ }
497
+ /**
498
+ * Extract structured KG triples from a session summary and store them in
499
+ * Postgres. Idempotent: if a (subject, predicate) pair already has the same
500
+ * object, no new row is added; if the object differs, the old triple is
501
+ * invalidated (valid_to = NOW()) and a new one is inserted.
502
+ *
503
+ * Best-effort: per-triple errors are caught and logged but never thrown.
504
+ * Returns a small stats object so callers can report progress.
505
+ */
506
+ async function extractAndStoreTriples(pool, params) {
507
+ const stats = {
508
+ extracted: 0,
509
+ added: 0,
510
+ superseded: 0
511
+ };
512
+ const jsonOutput = await spawnClaude(buildTripleExtractionPrompt({
513
+ sessionContent: params.summaryText,
514
+ projectSlug: params.projectSlug,
515
+ gitLog: params.gitLog ?? ""
516
+ }), params.model ?? "sonnet");
517
+ if (!jsonOutput) return stats;
518
+ const cleaned = jsonOutput.replace(/^```json\s*/m, "").replace(/^```\s*/m, "").replace(/\s*```$/m, "").trim();
519
+ let triples;
520
+ try {
521
+ const parsed = JSON.parse(cleaned);
522
+ if (Array.isArray(parsed)) triples = parsed;
523
+ else if (parsed && typeof parsed === "object" && Array.isArray(parsed.relations)) {
524
+ const newFmt = parsed;
525
+ if (params.federationDb && Array.isArray(newFmt.entities)) {
526
+ const tenantId = params.tenantId ?? "default";
527
+ for (const entity of newFmt.entities) {
528
+ if (!entity.name) continue;
529
+ try {
530
+ upsertKgEntity(params.federationDb, {
531
+ name: entity.name,
532
+ type: entity.type ?? "unknown",
533
+ description: entity.description,
534
+ tenantId
535
+ });
536
+ } catch (entityErr) {
537
+ process.stderr.write(`[kg-extraction] entity upsert error (${entity.name}): ${entityErr}\n`);
538
+ }
539
+ }
540
+ }
541
+ triples = newFmt.relations.map((r) => ({
542
+ subject: r.source,
543
+ predicate: r.relation,
544
+ object: r.target
545
+ }));
546
+ } else {
547
+ process.stderr.write(`[kg-extraction] Unexpected JSON shape — neither array nor {entities,relations}\n`);
548
+ return stats;
549
+ }
550
+ } catch (e) {
551
+ process.stderr.write(`[kg-extraction] JSON parse failed: ${e}\n`);
552
+ return stats;
553
+ }
554
+ if (!Array.isArray(triples)) return stats;
555
+ stats.extracted = triples.length;
556
+ for (const t of triples) {
557
+ if (!t.subject || !t.predicate || !t.object) continue;
558
+ try {
559
+ const existing = await kgQuery(pool, {
560
+ subject: t.subject,
561
+ predicate: t.predicate,
562
+ project_id: params.projectId ?? void 0
563
+ });
564
+ if (existing.find((e) => e.object === t.object && !e.valid_to)) continue;
565
+ const supersedes = existing.find((e) => e.object !== t.object && !e.valid_to);
566
+ if (supersedes) {
567
+ await kgInvalidate(pool, supersedes.id);
568
+ stats.superseded++;
569
+ }
570
+ await kgAdd(pool, {
571
+ subject: t.subject,
572
+ predicate: t.predicate,
573
+ object: t.object,
574
+ project_id: params.projectId ?? void 0,
575
+ source_session: params.sessionId,
576
+ confidence: "EXTRACTED"
577
+ });
578
+ stats.added++;
579
+ } catch (tripleErr) {
580
+ process.stderr.write(`[kg-extraction] store error (${t.subject}): ${tripleErr}\n`);
581
+ }
582
+ }
583
+ return stats;
584
+ }
585
+
586
+ //#endregion
587
+ //#region src/session/checkpoint-block.ts
588
+ /**
589
+ * Shared "## Continue" checkpoint logic for a project's TODO.md.
590
+ *
591
+ * WHY THIS MODULE EXISTS
592
+ * ----------------------
593
+ * Before this, `pause.ts` and `handover.ts` each carried their own copy of
594
+ * findProjectTodo / stripContinueSection / block-builder. Both wrote the same
595
+ * fixed four-line block, and both stripped any existing ## Continue section
596
+ * unconditionally. The consequence was that a rich, model-authored checkpoint
597
+ * could never survive:
598
+ *
599
+ * 1. `pai pause` had no way to accept a body, so the model printed its
600
+ * checkpoint to the terminal and it was lost.
601
+ * 2. Even if a body had been written by hand, the session-stop hook runs
602
+ * `pai session handover` on every clean exit, which regenerated the
603
+ * generic block and erased it.
604
+ *
605
+ * So there are two jobs here:
606
+ *
607
+ * - AUTHORED writes (`pai pause --body-file`) carry the model's markdown
608
+ * verbatim, wrapped in explicit start/end markers.
609
+ * - AUTO writes (hooks) must never destroy an authored checkpoint belonging
610
+ * to the *same* session. They may replace a stale one left by an earlier
611
+ * session, otherwise TODO.md would show a checkpoint that no longer
612
+ * describes where the work stands.
613
+ *
614
+ * PARSING
615
+ * -------
616
+ * A rich body can legitimately contain `---` rules and `##` headings, which the
617
+ * old heuristic scanner treated as section terminators. Authored blocks are
618
+ * therefore delimited by an explicit HTML-comment pair; the legacy heuristic is
619
+ * kept only as a fallback for blocks written before this change.
620
+ */
621
+ /** Locations searched for a project TODO.md, in priority order. */
622
+ const TODO_LOCATIONS = [
623
+ "Notes/TODO.md",
624
+ ".claude/Notes/TODO.md",
625
+ "tasks/todo.md",
626
+ "TODO.md"
627
+ ];
628
+ const MARKER_OPEN = "<!-- pai:checkpoint";
629
+ const MARKER_CLOSE = "<!-- /pai:checkpoint -->";
630
+ const CONTINUE_HEADING = "## Continue";
631
+ function findProjectTodo(rootPath) {
632
+ for (const rel of TODO_LOCATIONS) {
633
+ const full = join(rootPath, rel);
634
+ if (existsSync(full)) try {
635
+ return {
636
+ path: full,
637
+ content: readFileSync(full, "utf8")
638
+ };
639
+ } catch {}
640
+ }
641
+ return null;
642
+ }
643
+ /**
644
+ * Resolve the TODO.md to write to, creating Notes/ if nothing exists yet.
645
+ * Returns null only when the directory could not be created.
646
+ */
647
+ function resolveTodoTarget(rootPath, opts = {}) {
648
+ const found = findProjectTodo(rootPath);
649
+ if (found) return found;
650
+ const notesDir = join(rootPath, "Notes");
651
+ if (opts.create !== false) try {
652
+ if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
653
+ } catch {
654
+ return null;
655
+ }
656
+ return {
657
+ path: join(notesDir, "TODO.md"),
658
+ content: ""
659
+ };
660
+ }
661
+ /**
662
+ * Parse a `<!-- pai:checkpoint key="value" ... -->` marker line.
663
+ * Returns null when the line is not a marker.
664
+ */
665
+ function parseMarker(line) {
666
+ const trimmed = line.trim();
667
+ if (!trimmed.startsWith(MARKER_OPEN)) return null;
668
+ const attrs = {};
669
+ for (const m of trimmed.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) attrs[m[1]] = m[2];
670
+ return {
671
+ authored: attrs.authored === "model" ? "model" : "auto",
672
+ session: attrs.session || void 0,
673
+ sessionId: attrs["session-id"] || void 0,
674
+ ts: attrs.ts || void 0
675
+ };
676
+ }
677
+ /**
678
+ * Lines an auto-generated block is made of. Anything else in a section is
679
+ * content somebody put there deliberately.
680
+ */
681
+ const BOILERPLATE_PATTERNS = [
682
+ /^##\s+Continue$/,
683
+ /^<!--\s*\/?pai:checkpoint/,
684
+ /^>\s*\*\*Last session:\*\*/,
685
+ /^>\s*\*\*Paused at:\*\*/,
686
+ /^>\s*Working directory:/,
687
+ /^>\s*Resume with:/,
688
+ /^>\s*_No checkpoint body was recorded/,
689
+ /^>?\s*$/,
690
+ /^-{3,}$/
691
+ ];
692
+ /**
693
+ * True when a section contains nothing but generated header lines.
694
+ *
695
+ * This is the guard that stops an auto write from destroying content it did
696
+ * not author. A session that hit the old clobbering bug may have worked around
697
+ * it by hand — writing its state into a subsection underneath the generated
698
+ * header lines, in an unmarked block. Those blocks predate the marker, so
699
+ * authorship cannot be read off them; the only safe signal is whether anything
700
+ * beyond boilerplate is present.
701
+ */
702
+ function isBoilerplateOnly(lines) {
703
+ return lines.every((line) => {
704
+ const t = line.trim();
705
+ return BOILERPLATE_PATTERNS.some((re) => re.test(t));
706
+ });
707
+ }
708
+ /** Extract the non-boilerplate content of a section, or "" if there is none. */
709
+ function extractSectionContent(lines) {
710
+ const kept = [];
711
+ for (const line of lines) {
712
+ const t = line.trim();
713
+ if (BOILERPLATE_PATTERNS.some((re) => re.test(t))) continue;
714
+ kept.push(line);
715
+ }
716
+ return kept.join("\n").trim();
717
+ }
718
+ /**
719
+ * Locate the existing ## Continue section.
720
+ *
721
+ * When the section carries an explicit marker pair, the close marker defines
722
+ * the end — this is what lets a rich body contain `---` and `##` safely.
723
+ * Otherwise the legacy heuristic applies: stop at the first `---` or the next
724
+ * `##` heading.
725
+ */
726
+ function locateContinue(content) {
727
+ const lines = content.split("\n");
728
+ const startIdx = lines.findIndex((l) => l.trim() === CONTINUE_HEADING);
729
+ if (startIdx === -1) return null;
730
+ let meta = null;
731
+ let markerIdx = -1;
732
+ for (let i = startIdx + 1; i < Math.min(startIdx + 6, lines.length); i++) {
733
+ const parsed = parseMarker(lines[i]);
734
+ if (parsed) {
735
+ meta = parsed;
736
+ markerIdx = i;
737
+ break;
738
+ }
739
+ if (lines[i].trim() !== "") break;
740
+ }
741
+ let endIdx = lines.length;
742
+ if (markerIdx !== -1) {
743
+ const closeIdx = lines.findIndex((l, i) => i > markerIdx && l.trim() === MARKER_CLOSE);
744
+ if (closeIdx !== -1) endIdx = closeIdx + 1;
745
+ else endIdx = heuristicEnd(lines, startIdx);
746
+ } else endIdx = heuristicEnd(lines, startIdx);
747
+ let trailingEnd = endIdx;
748
+ while (trailingEnd < lines.length && lines[trailingEnd].trim() === "") trailingEnd += 1;
749
+ if (trailingEnd < lines.length && lines[trailingEnd].trim() === "---") trailingEnd += 1;
750
+ else trailingEnd = endIdx;
751
+ return {
752
+ startIdx,
753
+ endIdx: trailingEnd,
754
+ meta,
755
+ lines: lines.slice(startIdx, trailingEnd)
756
+ };
757
+ }
758
+ /**
759
+ * Legacy scanner for blocks written before checkpoint markers existed.
760
+ *
761
+ * Terminates on a horizontal rule or the next level-2 heading. Note the
762
+ * `(?!#)` — `###` is a *subsection* of `## Continue`, not a terminator. The
763
+ * original scanner stopped at any run of `#`, which meant a `### Restored
764
+ * state` subsection fell outside the section entirely and could not be seen,
765
+ * let alone carried forward.
766
+ */
767
+ function heuristicEnd(lines, startIdx) {
768
+ for (let i = startIdx + 1; i < lines.length; i++) {
769
+ const trimmed = lines[i].trim();
770
+ if (trimmed === "---" || /^##(?!#)/.test(trimmed) && trimmed !== CONTINUE_HEADING) return i;
771
+ }
772
+ return lines.length;
773
+ }
774
+ /** Remove the ## Continue section, returning the remainder of the document. */
775
+ function stripContinue(content) {
776
+ const found = locateContinue(content);
777
+ if (!found) return content;
778
+ const lines = content.split("\n");
779
+ const before = lines.slice(0, found.startIdx);
780
+ const after = lines.slice(found.endIdx);
781
+ while (after.length > 0 && after[0].trim() === "") after.shift();
782
+ return [...before, ...after].join("\n");
783
+ }
784
+ function escapeAttr(value) {
785
+ return value.replace(/"/g, "'");
786
+ }
787
+ function buildContinueBlock(opts) {
788
+ const ts = opts.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
789
+ const attrs = [
790
+ `authored="${opts.authored}"`,
791
+ `session="${escapeAttr(opts.sessionLine)}"`,
792
+ opts.sessionId ? `session-id="${escapeAttr(opts.sessionId)}"` : null,
793
+ `ts="${ts}"`
794
+ ].filter(Boolean).join(" ");
795
+ const header = [
796
+ `> **Last session:** ${opts.sessionLine}`,
797
+ `> **Paused at:** ${ts}`,
798
+ ">",
799
+ `> Working directory: ${opts.cwd}`
800
+ ];
801
+ if (opts.sessionId) header.push(">", `> Resume with: \`claude --resume ${opts.sessionId}\``);
802
+ const body = (opts.body ?? "").trim();
803
+ const parts = [
804
+ CONTINUE_HEADING,
805
+ "",
806
+ `${MARKER_OPEN} ${attrs} -->`,
807
+ "",
808
+ ...header
809
+ ];
810
+ if (body) parts.push("", body);
811
+ else parts.push(">", "> _No checkpoint body was recorded — see the latest session note._");
812
+ parts.push("", MARKER_CLOSE, "", "---", "");
813
+ return parts.join("\n");
814
+ }
815
+ /**
816
+ * Write the ## Continue block, honouring the preservation rules.
817
+ *
818
+ * An AUTO write is unattended — it fires from the session-stop and pre-compact
819
+ * hooks — so it operates under one governing rule: **never destroy content it
820
+ * did not author.** Three cases follow from that:
821
+ *
822
+ * 1. An authored checkpoint for the SAME session is left untouched. The hooks
823
+ * fire after the model has already recorded the real state; overwriting it
824
+ * with metadata is the bug this module exists to fix.
825
+ *
826
+ * "Same session" is decided by the Claude session UUID whenever both sides
827
+ * know it, and only falls back to the human-readable session line when one
828
+ * of them does not. The line is derived from the session note filename,
829
+ * and `session-stop.sh` *renames and renumbers that file* — via `session
830
+ * slug --apply` and `session cleanup --execute` — before it reaches the
831
+ * handover step. So the key the hook computes at exit is not the key the
832
+ * model wrote seconds earlier, and a filename-keyed comparison mismatches
833
+ * by construction. Observed live on 2026-08-01: notes renumbered twice
834
+ * within a single session. The UUID is the only identifier that holds
835
+ * still.
836
+ * 2. An authored checkpoint from an EARLIER session is stale — TODO.md would
837
+ * otherwise keep pointing at the wrong session — so it is replaced. Its
838
+ * content is not lost: `pai pause` mirrors every authored body into the
839
+ * session note.
840
+ * 3. An UNMARKED block predates the marker, so authorship cannot be read off
841
+ * it. If it is nothing but generated header lines it is replaced. If it
842
+ * carries anything else, that content was put there deliberately — quite
843
+ * possibly as a hand-rolled workaround for the very clobbering this fixes —
844
+ * and is carried forward into the new block rather than dropped.
845
+ *
846
+ * A MODEL write always replaces: the model is authoring the checkpoint, and a
847
+ * newer one supersedes an older one.
848
+ */
849
+ /**
850
+ * Do an existing checkpoint and an incoming write describe the same session?
851
+ *
852
+ * The UUID is authoritative when both sides carry one: it is assigned by Claude
853
+ * Code and never changes for the life of the session. The session line is a
854
+ * derived, mutable label and is only consulted when there is no UUID to compare
855
+ * — a checkpoint written before `--session-id` was threaded through, or an auto
856
+ * write from a caller that was not given one.
857
+ */
858
+ function isSameSession(meta, opts) {
859
+ if (meta.sessionId && opts.sessionId) return meta.sessionId === opts.sessionId;
860
+ return meta.session === opts.sessionLine;
861
+ }
862
+ function applyContinue(opts) {
863
+ const target = resolveTodoTarget(opts.rootPath, { create: !opts.dryRun });
864
+ if (!target) return {
865
+ action: "failed",
866
+ path: null,
867
+ block: buildContinueBlock(opts),
868
+ error: "Could not resolve or create a TODO.md target"
869
+ };
870
+ const existing = locateContinue(target.content);
871
+ let carriedForward = false;
872
+ let effectiveBody = opts.body;
873
+ if (opts.authored === "auto" && existing) {
874
+ if (existing.meta?.authored === "model" && isSameSession(existing.meta, opts)) return {
875
+ action: "preserved",
876
+ path: target.path,
877
+ block: buildContinueBlock(opts),
878
+ preservedMeta: existing.meta
879
+ };
880
+ if (!existing.meta && !isBoilerplateOnly(existing.lines)) {
881
+ const salvaged = extractSectionContent(existing.lines);
882
+ if (salvaged) {
883
+ effectiveBody = [
884
+ "_Carried forward from the previous checkpoint (author unknown — this",
885
+ "block predates checkpoint authorship markers):_",
886
+ "",
887
+ salvaged
888
+ ].join("\n");
889
+ carriedForward = true;
890
+ }
891
+ }
892
+ }
893
+ const block = buildContinueBlock({
894
+ ...opts,
895
+ body: effectiveBody
896
+ });
897
+ if (opts.dryRun) return {
898
+ action: "written",
899
+ path: target.path,
900
+ block,
901
+ carriedForward
902
+ };
903
+ const newContent = block + stripContinue(target.content).trimStart();
904
+ const tmpPath = `${target.path}.continue.tmp`;
905
+ try {
906
+ writeFileSync(tmpPath, newContent, "utf8");
907
+ renameSync(tmpPath, target.path);
908
+ } catch (err) {
909
+ try {
910
+ if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
911
+ } catch {}
912
+ return {
913
+ action: "failed",
914
+ path: target.path,
915
+ block,
916
+ error: String(err)
917
+ };
918
+ }
919
+ return {
920
+ action: "written",
921
+ path: target.path,
922
+ block,
923
+ carriedForward
924
+ };
925
+ }
926
+ /** PAI_DIR — mirrors pai-paths.ts resolution. */
927
+ function getPaiDir() {
928
+ const envDir = process.env.PAI_DIR;
929
+ if (envDir) try {
930
+ return realpathSync(envDir);
931
+ } catch {
932
+ return envDir;
933
+ }
934
+ return join(homedir(), ".claude");
935
+ }
936
+ /**
937
+ * Find the notes directory for a project — local first, then the central
938
+ * ~/.claude/projects/<encoded>/Notes fallback. Never creates.
939
+ */
940
+ function findNotesDir(rootPath, encodedDir) {
941
+ for (const rel of [
942
+ "Notes",
943
+ "notes",
944
+ ".claude/Notes"
945
+ ]) {
946
+ const p = join(rootPath, rel);
947
+ if (existsSync(p)) return p;
948
+ }
949
+ const central = join(getPaiDir(), "projects", encodedDir, "Notes");
950
+ if (existsSync(central)) return central;
951
+ return null;
952
+ }
953
+ /**
954
+ * Find the current (highest-numbered) session note: current month, then the
955
+ * previous month, then a flat notesDir as legacy fallback.
956
+ */
957
+ function findLatestNote(notesDir) {
958
+ const findIn = (dir) => {
959
+ if (!existsSync(dir)) return null;
960
+ let files;
961
+ try {
962
+ files = readdirSync(dir);
963
+ } catch {
964
+ return null;
965
+ }
966
+ const notes = files.filter((f) => /^\d{3,4}[\s_-].*\.md$/.test(f)).sort((a, b) => {
967
+ return parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
968
+ });
969
+ return notes.length > 0 ? join(dir, notes[notes.length - 1]) : null;
970
+ };
971
+ const now = /* @__PURE__ */ new Date();
972
+ const current = findIn(join(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
973
+ if (current) return current;
974
+ const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
975
+ const prevFound = findIn(join(notesDir, String(prev.getFullYear()), String(prev.getMonth() + 1).padStart(2, "0")));
976
+ if (prevFound) return prevFound;
977
+ return findIn(notesDir);
978
+ }
979
+ /**
980
+ * Append the checkpoint body to a session note.
981
+ *
982
+ * TODO.md's ## Continue is a single slot that every later checkpoint
983
+ * overwrites. The session note is the durable record, so the body goes to both.
984
+ * Idempotent per timestamp: re-running with the same stamp will not duplicate.
985
+ */
986
+ function appendCheckpointToNote(notePath, body, timestamp) {
987
+ const heading = `## Pause Checkpoint — ${timestamp ?? (/* @__PURE__ */ new Date()).toISOString()}`;
988
+ let existing;
989
+ try {
990
+ existing = readFileSync(notePath, "utf8");
991
+ } catch (err) {
992
+ return {
993
+ appended: false,
994
+ error: String(err)
995
+ };
996
+ }
997
+ if (existing.includes(heading)) return { appended: false };
998
+ const block = `\n\n---\n\n${heading}\n\n${body.trim()}\n`;
999
+ const tmpPath = `${notePath}.checkpoint.tmp`;
1000
+ try {
1001
+ writeFileSync(tmpPath, existing.trimEnd() + block, "utf8");
1002
+ renameSync(tmpPath, notePath);
1003
+ } catch (err) {
1004
+ try {
1005
+ if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
1006
+ } catch {}
1007
+ return {
1008
+ appended: false,
1009
+ error: String(err)
1010
+ };
1011
+ }
1012
+ return { appended: true };
1013
+ }
1014
+ /** Read a checkpoint body from a file, or from stdin when path is "-". */
1015
+ function readBodyFile(path) {
1016
+ if (path === "-") return readFileSync(0, "utf8");
1017
+ return readFileSync(path, "utf8");
1018
+ }
1019
+
1020
+ //#endregion
1021
+ export { readBodyFile as a, loadScanConfig as c, saveScanConfig as d, upsertProject as f, findNotesDir as i, performScan as l, applyContinue as n, extractAndStoreTriples as o, upsertSession as p, findLatestNote as r, cmdScan as s, appendCheckpointToNote as t, resolveHome as u };
1022
+ //# sourceMappingURL=checkpoint-block-CUCG10Uh.mjs.map