@tekmidian/pai 0.5.6 → 0.6.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 (137) hide show
  1. package/ARCHITECTURE.md +72 -1
  2. package/README.md +107 -3
  3. package/dist/{auto-route-BG6I_4B1.mjs → auto-route-C-DrW6BL.mjs} +3 -3
  4. package/dist/{auto-route-BG6I_4B1.mjs.map → auto-route-C-DrW6BL.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +1897 -1569
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/clusters-JIDQW65f.mjs +201 -0
  8. package/dist/clusters-JIDQW65f.mjs.map +1 -0
  9. package/dist/{config-Cf92lGX_.mjs → config-BuhHWyOK.mjs} +21 -6
  10. package/dist/config-BuhHWyOK.mjs.map +1 -0
  11. package/dist/daemon/index.mjs +12 -9
  12. package/dist/daemon/index.mjs.map +1 -1
  13. package/dist/{daemon-D9evGlgR.mjs → daemon-D3hYb5_C.mjs} +670 -219
  14. package/dist/daemon-D3hYb5_C.mjs.map +1 -0
  15. package/dist/daemon-mcp/index.mjs +4597 -4
  16. package/dist/daemon-mcp/index.mjs.map +1 -1
  17. package/dist/{db-4lSqLFb8.mjs → db-BtuN768f.mjs} +9 -2
  18. package/dist/db-BtuN768f.mjs.map +1 -0
  19. package/dist/db-DdUperSl.mjs +110 -0
  20. package/dist/db-DdUperSl.mjs.map +1 -0
  21. package/dist/{detect-BU3Nx_2L.mjs → detect-CdaA48EI.mjs} +1 -1
  22. package/dist/{detect-BU3Nx_2L.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  23. package/dist/{detector-Bp-2SM3x.mjs → detector-jGBuYQJM.mjs} +2 -2
  24. package/dist/{detector-Bp-2SM3x.mjs.map → detector-jGBuYQJM.mjs.map} +1 -1
  25. package/dist/{factory-Bzcy70G9.mjs → factory-Ygqe_bVZ.mjs} +7 -5
  26. package/dist/{factory-Bzcy70G9.mjs.map → factory-Ygqe_bVZ.mjs.map} +1 -1
  27. package/dist/helpers-BEST-4Gx.mjs +420 -0
  28. package/dist/helpers-BEST-4Gx.mjs.map +1 -0
  29. package/dist/hooks/capture-all-events.mjs +19 -4
  30. package/dist/hooks/capture-all-events.mjs.map +4 -4
  31. package/dist/hooks/capture-session-summary.mjs +38 -0
  32. package/dist/hooks/capture-session-summary.mjs.map +3 -3
  33. package/dist/hooks/cleanup-session-files.mjs +6 -12
  34. package/dist/hooks/cleanup-session-files.mjs.map +4 -4
  35. package/dist/hooks/context-compression-hook.mjs +105 -111
  36. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  37. package/dist/hooks/initialize-session.mjs +26 -17
  38. package/dist/hooks/initialize-session.mjs.map +4 -4
  39. package/dist/hooks/inject-observations.mjs +220 -0
  40. package/dist/hooks/inject-observations.mjs.map +7 -0
  41. package/dist/hooks/load-core-context.mjs +18 -2
  42. package/dist/hooks/load-core-context.mjs.map +4 -4
  43. package/dist/hooks/load-project-context.mjs +102 -97
  44. package/dist/hooks/load-project-context.mjs.map +4 -4
  45. package/dist/hooks/observe.mjs +354 -0
  46. package/dist/hooks/observe.mjs.map +7 -0
  47. package/dist/hooks/stop-hook.mjs +174 -90
  48. package/dist/hooks/stop-hook.mjs.map +4 -4
  49. package/dist/hooks/sync-todo-to-md.mjs +31 -33
  50. package/dist/hooks/sync-todo-to-md.mjs.map +4 -4
  51. package/dist/index.d.mts +32 -9
  52. package/dist/index.d.mts.map +1 -1
  53. package/dist/index.mjs +6 -9
  54. package/dist/indexer-D53l5d1U.mjs +1 -0
  55. package/dist/{indexer-backend-CIMXedqk.mjs → indexer-backend-jcJFsmB4.mjs} +37 -127
  56. package/dist/indexer-backend-jcJFsmB4.mjs.map +1 -0
  57. package/dist/{ipc-client-Bjg_a1dc.mjs → ipc-client-CoyUHPod.mjs} +2 -7
  58. package/dist/{ipc-client-Bjg_a1dc.mjs.map → ipc-client-CoyUHPod.mjs.map} +1 -1
  59. package/dist/latent-ideas-bTJo6Omd.mjs +191 -0
  60. package/dist/latent-ideas-bTJo6Omd.mjs.map +1 -0
  61. package/dist/neighborhood-BYYbEkUJ.mjs +135 -0
  62. package/dist/neighborhood-BYYbEkUJ.mjs.map +1 -0
  63. package/dist/note-context-BK24bX8Y.mjs +126 -0
  64. package/dist/note-context-BK24bX8Y.mjs.map +1 -0
  65. package/dist/postgres-CKf-EDtS.mjs +846 -0
  66. package/dist/postgres-CKf-EDtS.mjs.map +1 -0
  67. package/dist/{reranker-D7bRAHi6.mjs → reranker-CMNZcfVx.mjs} +1 -1
  68. package/dist/{reranker-D7bRAHi6.mjs.map → reranker-CMNZcfVx.mjs.map} +1 -1
  69. package/dist/{search-_oHfguA5.mjs → search-DC1qhkKn.mjs} +2 -58
  70. package/dist/search-DC1qhkKn.mjs.map +1 -0
  71. package/dist/{sqlite-WWBq7_2C.mjs → sqlite-l-s9xPjY.mjs} +160 -3
  72. package/dist/sqlite-l-s9xPjY.mjs.map +1 -0
  73. package/dist/state-C6_vqz7w.mjs +102 -0
  74. package/dist/state-C6_vqz7w.mjs.map +1 -0
  75. package/dist/stop-words-BaMEGVeY.mjs +326 -0
  76. package/dist/stop-words-BaMEGVeY.mjs.map +1 -0
  77. package/dist/{indexer-CMPOiY1r.mjs → sync-BOsnEj2-.mjs} +14 -216
  78. package/dist/sync-BOsnEj2-.mjs.map +1 -0
  79. package/dist/themes-BvYF0W8T.mjs +148 -0
  80. package/dist/themes-BvYF0W8T.mjs.map +1 -0
  81. package/dist/{tools-DV_lsiCc.mjs → tools-DcaJlYDN.mjs} +162 -273
  82. package/dist/tools-DcaJlYDN.mjs.map +1 -0
  83. package/dist/trace-CRx9lPuc.mjs +137 -0
  84. package/dist/trace-CRx9lPuc.mjs.map +1 -0
  85. package/dist/{vault-indexer-DXWs9pDn.mjs → vault-indexer-Bi2cRmn7.mjs} +174 -138
  86. package/dist/vault-indexer-Bi2cRmn7.mjs.map +1 -0
  87. package/dist/zettelkasten-cdajbnPr.mjs +708 -0
  88. package/dist/zettelkasten-cdajbnPr.mjs.map +1 -0
  89. package/package.json +1 -2
  90. package/src/hooks/ts/capture-all-events.ts +6 -0
  91. package/src/hooks/ts/lib/project-utils/index.ts +50 -0
  92. package/src/hooks/ts/lib/project-utils/notify.ts +75 -0
  93. package/src/hooks/ts/lib/project-utils/paths.ts +218 -0
  94. package/src/hooks/ts/lib/project-utils/session-notes.ts +363 -0
  95. package/src/hooks/ts/lib/project-utils/todo.ts +178 -0
  96. package/src/hooks/ts/lib/project-utils/tokens.ts +39 -0
  97. package/src/hooks/ts/lib/project-utils.ts +40 -999
  98. package/src/hooks/ts/post-tool-use/observe.ts +327 -0
  99. package/src/hooks/ts/pre-compact/context-compression-hook.ts +6 -0
  100. package/src/hooks/ts/session-end/capture-session-summary.ts +41 -0
  101. package/src/hooks/ts/session-start/initialize-session.ts +7 -1
  102. package/src/hooks/ts/session-start/inject-observations.ts +254 -0
  103. package/src/hooks/ts/session-start/load-core-context.ts +7 -0
  104. package/src/hooks/ts/session-start/load-project-context.ts +8 -1
  105. package/src/hooks/ts/stop/stop-hook.ts +28 -0
  106. package/templates/claude-md.template.md +7 -74
  107. package/templates/skills/user/.gitkeep +0 -0
  108. package/dist/chunker-CbnBe0s0.mjs +0 -191
  109. package/dist/chunker-CbnBe0s0.mjs.map +0 -1
  110. package/dist/config-Cf92lGX_.mjs.map +0 -1
  111. package/dist/daemon-D9evGlgR.mjs.map +0 -1
  112. package/dist/db-4lSqLFb8.mjs.map +0 -1
  113. package/dist/db-Dp8VXIMR.mjs +0 -212
  114. package/dist/db-Dp8VXIMR.mjs.map +0 -1
  115. package/dist/indexer-CMPOiY1r.mjs.map +0 -1
  116. package/dist/indexer-backend-CIMXedqk.mjs.map +0 -1
  117. package/dist/mcp/index.d.mts +0 -1
  118. package/dist/mcp/index.mjs +0 -500
  119. package/dist/mcp/index.mjs.map +0 -1
  120. package/dist/postgres-FXrHDPcE.mjs +0 -358
  121. package/dist/postgres-FXrHDPcE.mjs.map +0 -1
  122. package/dist/schemas-BFIgGntb.mjs +0 -3405
  123. package/dist/schemas-BFIgGntb.mjs.map +0 -1
  124. package/dist/search-_oHfguA5.mjs.map +0 -1
  125. package/dist/sqlite-WWBq7_2C.mjs.map +0 -1
  126. package/dist/tools-DV_lsiCc.mjs.map +0 -1
  127. package/dist/vault-indexer-DXWs9pDn.mjs.map +0 -1
  128. package/dist/zettelkasten-e-a4rW_6.mjs +0 -901
  129. package/dist/zettelkasten-e-a4rW_6.mjs.map +0 -1
  130. package/templates/README.md +0 -181
  131. package/templates/skills/createskill-skill.template.md +0 -78
  132. package/templates/skills/history-system.template.md +0 -371
  133. package/templates/skills/hook-system.template.md +0 -913
  134. package/templates/skills/sessions-skill.template.md +0 -102
  135. package/templates/skills/skill-system.template.md +0 -214
  136. package/templates/skills/terminal-tabs.template.md +0 -120
  137. package/templates/templates.md +0 -20
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { n as openRegistry } from "../db-4lSqLFb8.mjs";
2
+ import { n as openRegistry } from "../db-BtuN768f.mjs";
3
3
  import { _ as warn, a as fmtDate, c as ok, d as scaffoldProjectDirs, f as shortenPath, i as err, l as renderTable, m as slugify, n as dim, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold, u as resolvePath } from "../utils-QSfKagcj.mjs";
4
4
  import { a as slugify$1, i as parseSessionFilename, n as decodeEncodedDir, t as buildEncodedDirMap } from "../migrate-jokLenje.mjs";
5
5
  import { n as ensurePaiMarker, t as discoverPaiMarkers } from "../pai-marker-CXQPX2P6.mjs";
6
- import { n as openFederation } from "../db-Dp8VXIMR.mjs";
7
- import { a as indexProject, n as embedChunks, r as indexAll } from "../indexer-CMPOiY1r.mjs";
6
+ import { n as openFederation } from "../db-DdUperSl.mjs";
7
+ import "../helpers-BEST-4Gx.mjs";
8
+ import { i as indexProject, n as indexAll, t as embedChunks } from "../sync-BOsnEj2-.mjs";
8
9
  import "../embeddings-DGRAPAYb.mjs";
9
- import { n as populateSlugs, r as searchMemory } from "../search-_oHfguA5.mjs";
10
- import { a as expandHome, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig$1, t as CONFIG_DIR } from "../config-Cf92lGX_.mjs";
11
- import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "../detect-BU3Nx_2L.mjs";
12
- import { t as PaiClient } from "../ipc-client-Bjg_a1dc.mjs";
13
- import { t as createStorageBackend } from "../factory-Bzcy70G9.mjs";
10
+ import { t as STOP_WORDS } from "../stop-words-BaMEGVeY.mjs";
11
+ import { n as populateSlugs, r as searchMemory } from "../search-DC1qhkKn.mjs";
12
+ import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "../detect-CdaA48EI.mjs";
13
+ import "../indexer-D53l5d1U.mjs";
14
+ import { t as PaiClient } from "../ipc-client-CoyUHPod.mjs";
15
+ import { a as expandHome, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig, t as CONFIG_DIR } from "../config-BuhHWyOK.mjs";
16
+ import { t as createStorageBackend } from "../factory-Ygqe_bVZ.mjs";
14
17
  import { appendFileSync, chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
15
18
  import { homedir, tmpdir } from "node:os";
16
19
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -19,6 +22,7 @@ import { Command } from "commander";
19
22
  import { fileURLToPath } from "node:url";
20
23
  import { execSync, spawnSync } from "node:child_process";
21
24
  import { createInterface } from "node:readline";
25
+ import { createConnection } from "net";
22
26
 
23
27
  //#region src/session/promote.ts
24
28
  /**
@@ -119,7 +123,7 @@ function cmdPromote(db, opts) {
119
123
  }
120
124
 
121
125
  //#endregion
122
- //#region src/cli/commands/project.ts
126
+ //#region src/cli/commands/project/helpers.ts
123
127
  function getProject$2(db, slug) {
124
128
  const direct = db.prepare("SELECT * FROM projects WHERE slug = ?").get(slug);
125
129
  if (direct) return direct;
@@ -167,6 +171,49 @@ function upsertTag$1(db, tagName) {
167
171
  db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tagName);
168
172
  return db.prepare("SELECT id FROM tags WHERE name = ?").get(tagName).id;
169
173
  }
174
+
175
+ //#endregion
176
+ //#region src/cli/commands/project/commands.ts
177
+ function levenshtein(a, b) {
178
+ const m = a.length;
179
+ const n = b.length;
180
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
181
+ for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
182
+ return dp[m][n];
183
+ }
184
+ function containsIgnoreCase(haystack, needle) {
185
+ return haystack.toLowerCase().includes(needle.toLowerCase());
186
+ }
187
+ function findProjectNotesDirs(project) {
188
+ const claudeProjects = join(homedir(), ".claude", "projects");
189
+ if (!existsSync(claudeProjects)) return [];
190
+ const results = [];
191
+ const rootEncoded = encodeDir(project.root_path);
192
+ try {
193
+ for (const entry of readdirSync(claudeProjects)) {
194
+ const full = join(claudeProjects, entry);
195
+ try {
196
+ if (!statSync(full).isDirectory()) continue;
197
+ } catch {
198
+ continue;
199
+ }
200
+ if (entry !== rootEncoded && !entry.startsWith(rootEncoded)) continue;
201
+ const notesPath = join(full, "Notes");
202
+ if (!existsSync(notesPath)) continue;
203
+ let noteCount = 0;
204
+ try {
205
+ noteCount = readdirSync(notesPath).filter((f) => f.endsWith(".md") || f.endsWith(".txt")).length;
206
+ } catch {}
207
+ results.push({
208
+ encodedDir: entry,
209
+ fullPath: full,
210
+ notesPath,
211
+ noteCount
212
+ });
213
+ }
214
+ } catch {}
215
+ return results;
216
+ }
170
217
  function cmdAdd(db, rawPath, opts) {
171
218
  const rootPath = resolvePath(rawPath);
172
219
  const slug = opts.slug ?? slugFromPath(rootPath);
@@ -187,6 +234,15 @@ function cmdAdd(db, rawPath, opts) {
187
234
  console.error(err(`Project already registered (slug: ${slug} or path: ${rootPath})`));
188
235
  process.exit(1);
189
236
  }
237
+ const dirName = basename(rootPath).toLowerCase();
238
+ const matches = db.prepare(`SELECT slug, root_path FROM projects WHERE status = 'active' AND slug != ?`).all(slug).filter((s) => basename(s.root_path).toLowerCase() === dirName || s.slug.replace(/-\d+$/, "") === slug.replace(/-\d+$/, ""));
239
+ if (matches.length > 0) {
240
+ console.log(warn(`Similar project(s) already registered:`));
241
+ for (const m of matches) console.log(dim(` ${bold(m.slug)} ${shortenPath(m.root_path, 50)}`));
242
+ console.log(dim(` Consider: pai project alias ${matches[0].slug} <name> (to link them)`));
243
+ console.log(dim(` Or: pai project archive ${slug} (if this is a duplicate)`));
244
+ console.log();
245
+ }
190
246
  const ts = now();
191
247
  db.prepare(`INSERT INTO projects
192
248
  (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
@@ -200,7 +256,7 @@ function cmdAdd(db, rawPath, opts) {
200
256
  console.log(dim(` Encoded dir: ${encodedDir}`));
201
257
  console.log(dim(` Type: ${type}`));
202
258
  }
203
- function cmdList$1(db, opts) {
259
+ function cmdList$2(db, opts) {
204
260
  let query = `
205
261
  SELECT p.*,
206
262
  (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
@@ -378,142 +434,6 @@ function cmdEdit(db, slug, opts) {
378
434
  console.log(ok(`Type updated: ${bold(opts.type)}`));
379
435
  }
380
436
  }
381
- /**
382
- * Find Claude project dirs (~/.claude/projects/) that look like they belong
383
- * to a project based on encoded_dir prefix matching.
384
- */
385
- function findOrphanedNotesDirs(project) {
386
- const claudeProjects = join(homedir(), ".claude", "projects");
387
- if (!existsSync(claudeProjects)) return [];
388
- const expected = encodeDir(project.root_path);
389
- const results = [];
390
- try {
391
- for (const entry of readdirSync(claudeProjects)) {
392
- const full = join(claudeProjects, entry);
393
- try {
394
- if (!statSync(full).isDirectory()) continue;
395
- } catch {
396
- continue;
397
- }
398
- if (entry === expected || entry === project.encoded_dir) {
399
- const notesDir = join(full, "Notes");
400
- if (existsSync(notesDir)) results.push(notesDir);
401
- }
402
- }
403
- } catch {}
404
- return results;
405
- }
406
- /**
407
- * Try to find a moved project by looking for a directory with the same name
408
- * as the last path component in common nearby locations.
409
- */
410
- function suggestMovedPath(project) {
411
- const name = basename(project.root_path);
412
- const candidates = [
413
- join(homedir(), "dev", name),
414
- join(homedir(), "dev", "ai", name),
415
- join(homedir(), "Desktop", name),
416
- join(homedir(), "Projects", name)
417
- ];
418
- for (const c of candidates) if (existsSync(c)) return c;
419
- }
420
- function cmdHealth$1(db, opts) {
421
- const rows = db.prepare(`SELECT p.*,
422
- (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
423
- FROM projects p
424
- ORDER BY p.status ASC, p.updated_at DESC`).all();
425
- const results = rows.map((project) => {
426
- const pathExists = existsSync(project.root_path);
427
- const orphaned = findOrphanedNotesDirs(project);
428
- let category;
429
- let suggestedPath;
430
- if (pathExists) category = "active";
431
- else {
432
- suggestedPath = suggestMovedPath(project);
433
- category = suggestedPath ? "stale" : "dead";
434
- }
435
- const claudeNotesExists = orphaned.length > 0;
436
- return {
437
- project,
438
- category,
439
- suggestedPath,
440
- claudeNotesExists,
441
- orphanedNotesDirs: orphaned
442
- };
443
- });
444
- const filtered = opts.status ? results.filter((r) => r.category === opts.status) : results;
445
- if (opts.json) {
446
- console.log(JSON.stringify(filtered.map((r) => ({
447
- slug: r.project.slug,
448
- root_path: r.project.root_path,
449
- status: r.project.status,
450
- health: r.category,
451
- session_count: r.project.session_count,
452
- suggested_path: r.suggestedPath ?? null,
453
- claude_notes_exists: r.claudeNotesExists,
454
- orphaned_notes_dirs: r.orphanedNotesDirs
455
- })), null, 2));
456
- return;
457
- }
458
- const active = filtered.filter((r) => r.category === "active");
459
- const stale = filtered.filter((r) => r.category === "stale");
460
- const dead = filtered.filter((r) => r.category === "dead");
461
- console.log();
462
- console.log(header(" PAI Project Health Report"));
463
- console.log();
464
- console.log(` ${chalk.green("Active:")} ${active.length} ${chalk.yellow("Stale (moved?):")} ${stale.length} ${chalk.red("Dead (missing):")} ${dead.length}`);
465
- console.log();
466
- if (active.length) {
467
- console.log(bold(" Active projects (path exists):"));
468
- const tableRows = active.map((r) => [
469
- bold(r.project.slug),
470
- dim(shortenPath(r.project.root_path, 50)),
471
- String(r.project.session_count),
472
- r.claudeNotesExists ? chalk.green("yes") : dim("no")
473
- ]);
474
- console.log(renderTable([
475
- "Slug",
476
- "Path",
477
- "Sessions",
478
- "Claude Notes"
479
- ], tableRows).split("\n").map((l) => " " + l).join("\n"));
480
- console.log();
481
- }
482
- if (stale.length) {
483
- console.log(warn(" Stale projects (path missing, possible new location found):"));
484
- for (const r of stale) {
485
- console.log(` ${bold(r.project.slug)}`);
486
- console.log(dim(` Old path: ${r.project.root_path}`));
487
- console.log(chalk.cyan(` Found at: ${r.suggestedPath}`));
488
- if (r.claudeNotesExists) console.log(chalk.green(` Notes: ${r.orphanedNotesDirs.join(", ")}`));
489
- if (opts.fix && r.suggestedPath) {
490
- const ts = now();
491
- const newEncoded = encodeDir(r.suggestedPath);
492
- db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(r.suggestedPath, newEncoded, ts, r.project.id);
493
- console.log(ok(` Auto-fixed: updated path to ${r.suggestedPath}`));
494
- } else if (r.suggestedPath) console.log(dim(` Fix: pai project move ${r.project.slug} ${r.suggestedPath}`));
495
- }
496
- console.log();
497
- }
498
- if (dead.length) {
499
- console.log(err(" Dead projects (path missing, no match found):"));
500
- for (const r of dead) {
501
- console.log(` ${bold(r.project.slug)} ${dim(r.project.root_path)}`);
502
- if (r.claudeNotesExists) console.log(chalk.yellow(` Notes: ${r.orphanedNotesDirs.join(", ")}`));
503
- if (r.project.session_count === 0 && opts.fix) {
504
- db.prepare("UPDATE projects SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), r.project.id);
505
- console.log(ok(" Auto-fixed: archived (0 sessions, path gone)"));
506
- } else console.log(dim(` Fix: pai project archive ${r.project.slug} (or pai project move ...)`));
507
- }
508
- console.log();
509
- }
510
- const summary = ` ${rows.length} total: ${active.length} active, ${stale.length} stale, ${dead.length} dead`;
511
- console.log(dim(summary));
512
- if (!opts.fix && (stale.length > 0 || dead.length > 0)) {
513
- console.log();
514
- console.log(warn(" Run with --fix to auto-remediate where possible."));
515
- }
516
- }
517
437
  function cmdDetect(db, pathArg, opts) {
518
438
  const cwd = pathArg ? resolvePath(pathArg) : process.cwd();
519
439
  const detection = detectProject(db, cwd);
@@ -539,40 +459,6 @@ function cmdDetect(db, pathArg, opts) {
539
459
  console.log(formatDetection(detection).split("\n").map((l) => " " + l).join("\n"));
540
460
  console.log();
541
461
  }
542
- /**
543
- * Find all ~/.claude/projects/ encoded dirs whose name encodes to a path
544
- * that is a child-of or exact-match of the given project's root_path.
545
- */
546
- function findProjectNotesDirs(project) {
547
- const claudeProjects = join(homedir(), ".claude", "projects");
548
- if (!existsSync(claudeProjects)) return [];
549
- const results = [];
550
- const rootEncoded = encodeDir(project.root_path);
551
- try {
552
- for (const entry of readdirSync(claudeProjects)) {
553
- const full = join(claudeProjects, entry);
554
- try {
555
- if (!statSync(full).isDirectory()) continue;
556
- } catch {
557
- continue;
558
- }
559
- if (entry !== rootEncoded && !entry.startsWith(rootEncoded)) continue;
560
- const notesPath = join(full, "Notes");
561
- if (!existsSync(notesPath)) continue;
562
- let noteCount = 0;
563
- try {
564
- noteCount = readdirSync(notesPath).filter((f) => f.endsWith(".md") || f.endsWith(".txt")).length;
565
- } catch {}
566
- results.push({
567
- encodedDir: entry,
568
- fullPath: full,
569
- notesPath,
570
- noteCount
571
- });
572
- }
573
- } catch {}
574
- return results;
575
- }
576
462
  function cmdConsolidate(db, identifier, opts) {
577
463
  const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
578
464
  console.log();
@@ -628,22 +514,6 @@ function cmdConsolidate(db, identifier, opts) {
628
514
  console.log();
629
515
  console.log(ok(` Consolidated ${movedCount} file(s) into ${canonicalNotes}`));
630
516
  }
631
- /**
632
- * Simple Levenshtein distance for "did you mean?" suggestions.
633
- */
634
- function levenshtein(a, b) {
635
- const m = a.length;
636
- const n = b.length;
637
- const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
638
- for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
639
- return dp[m][n];
640
- }
641
- /**
642
- * Check if `needle` appears as a substring (case-insensitive) in `haystack`.
643
- */
644
- function containsIgnoreCase(haystack, needle) {
645
- return haystack.toLowerCase().includes(needle.toLowerCase());
646
- }
647
517
  function cmdGo(db, query) {
648
518
  const all = db.prepare("SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC").all();
649
519
  if (!all.length) {
@@ -689,46 +559,581 @@ function cmdGo(db, query) {
689
559
  }
690
560
  process.exit(1);
691
561
  }
692
- function registerProjectCommands(projectCmd, getDb) {
693
- projectCmd.command("add <path>").description("Register a project directory in the PAI registry").option("--slug <slug>", "Override auto-generated slug").option("--type <type>", "Project type: local | central | obsidian-linked | external", "local").option("--display-name <name>", "Human-readable display name").action((rawPath, opts) => {
694
- cmdAdd(getDb(), rawPath, opts);
695
- });
696
- projectCmd.command("list").description("List registered projects").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
697
- cmdList$1(getDb(), opts);
698
- });
699
- projectCmd.command("info <slug>").description("Show full details for a project").action((slug) => {
700
- cmdInfo$1(getDb(), slug);
701
- });
702
- projectCmd.command("archive <slug>").description("Archive a project").action((slug) => {
703
- cmdArchive(getDb(), slug);
704
- });
705
- projectCmd.command("unarchive <slug>").description("Restore an archived project to active status").action((slug) => {
706
- cmdUnarchive(getDb(), slug);
707
- });
708
- projectCmd.command("move <slug> <new-path>").description("Update the root path for a project").action((slug, newPath) => {
709
- cmdMove(getDb(), slug, newPath);
710
- });
711
- projectCmd.command("tag <slug> <tags...>").description("Add one or more tags to a project").action((slug, tags) => {
712
- cmdTag$1(getDb(), slug, tags);
713
- });
714
- projectCmd.command("alias <slug> <alias>").description("Register an alternative slug for a project").action((slug, alias) => {
715
- cmdAlias(getDb(), slug, alias);
716
- });
717
- projectCmd.command("edit <slug>").description("Edit project metadata").option("--display-name <name>", "New display name").option("--type <type>", "New type").action((slug, opts) => {
718
- cmdEdit(getDb(), slug, opts);
719
- });
720
- projectCmd.command("cd <identifier>").description("Print the root path for a project (use with: cd $(pai project cd <id>))").action((identifier) => {
721
- const project = resolveIdentifier(getDb(), identifier);
722
- if (!project) {
723
- console.error(`Project not found: ${identifier}`);
724
- process.exit(1);
725
- }
726
- process.stdout.write(project.root_path + "\n");
727
- });
728
- projectCmd.command("detect [path]").description("Detect which registered project the given path (or CWD) belongs to").option("--json", "Output raw JSON instead of human-readable text").action((pathArg, opts) => {
729
- cmdDetect(getDb(), pathArg, opts);
730
- });
731
- projectCmd.command("health").description("Audit all registered projects: check which paths still exist, find moved/dead projects").option("--fix", "Auto-remediate where possible (update moved paths, archive dead zero-session projects)").option("--json", "Output raw JSON report").option("--status <category>", "Filter output to: active | stale | dead").action((opts) => {
562
+
563
+ //#endregion
564
+ //#region src/cli/commands/project/session-config.ts
565
+ const CONFIG_OPTIONS = [
566
+ {
567
+ key: "permission",
568
+ type: "string",
569
+ description: "Permission preset: full | trusted | default",
570
+ examples: [
571
+ "full",
572
+ "trusted",
573
+ "default"
574
+ ]
575
+ },
576
+ {
577
+ key: "flags",
578
+ type: "string",
579
+ description: "Raw Claude CLI flags",
580
+ examples: ["--dangerously-skip-permissions", "--allowedTools Edit,Read"]
581
+ },
582
+ {
583
+ key: "env",
584
+ type: "object",
585
+ description: "Environment variables as key=value pairs",
586
+ examples: ["IS_SANDBOX=1", "CLAUDE_MODEL=opus"]
587
+ },
588
+ {
589
+ key: "autoStart",
590
+ type: "boolean",
591
+ description: "Auto-start session (skip interactive prompt)",
592
+ examples: ["true", "false"]
593
+ },
594
+ {
595
+ key: "prompt",
596
+ type: "string",
597
+ description: "Initial prompt sent to Claude on launch",
598
+ examples: [
599
+ "go",
600
+ "continue",
601
+ "run tests"
602
+ ]
603
+ },
604
+ {
605
+ key: "model",
606
+ type: "string",
607
+ description: "Model override for the session",
608
+ examples: [
609
+ "opus",
610
+ "sonnet",
611
+ "haiku"
612
+ ]
613
+ }
614
+ ];
615
+ const PERMISSION_PRESETS = {
616
+ full: {
617
+ permission: "full",
618
+ flags: "--dangerously-skip-permissions",
619
+ env: { IS_SANDBOX: "1" },
620
+ autoStart: true,
621
+ prompt: "go"
622
+ },
623
+ trusted: {
624
+ permission: "trusted",
625
+ flags: "",
626
+ env: {},
627
+ autoStart: true,
628
+ prompt: "go"
629
+ },
630
+ default: {
631
+ permission: "default",
632
+ flags: "",
633
+ env: {},
634
+ autoStart: false
635
+ }
636
+ };
637
+ function getSessionConfig(project) {
638
+ return project.session_config ? JSON.parse(project.session_config) : {};
639
+ }
640
+ function getGlobalDefaults() {
641
+ try {
642
+ const configPath = join(homedir(), ".config", "pai", "config.json");
643
+ if (existsSync(configPath)) return JSON.parse(readFileSync(configPath, "utf-8")).sessionDefaults ?? {};
644
+ } catch {}
645
+ return {};
646
+ }
647
+ function saveGlobalDefaults(defaults) {
648
+ const configPath = join(homedir(), ".config", "pai", "config.json");
649
+ let config = {};
650
+ try {
651
+ if (existsSync(configPath)) config = JSON.parse(readFileSync(configPath, "utf-8"));
652
+ } catch {}
653
+ config.sessionDefaults = defaults;
654
+ mkdirSync(join(homedir(), ".config", "pai"), { recursive: true });
655
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
656
+ }
657
+ function applyPreset(config, preset) {
658
+ const presetConfig = PERMISSION_PRESETS[preset];
659
+ if (!presetConfig) return {
660
+ ...config,
661
+ permission: "custom",
662
+ flags: preset
663
+ };
664
+ return {
665
+ ...config,
666
+ ...presetConfig
667
+ };
668
+ }
669
+ function parseConfigValue(key, value) {
670
+ const option = CONFIG_OPTIONS.find((o) => o.key === key);
671
+ if (!option) return value;
672
+ switch (option.type) {
673
+ case "boolean": return value === "true" || value === "1" || value === "yes";
674
+ case "object":
675
+ if (key === "env") {
676
+ const [k, ...vParts] = value.split("=");
677
+ return { [k]: vParts.join("=") || "1" };
678
+ }
679
+ return value;
680
+ default: return value;
681
+ }
682
+ }
683
+ function cmdName(db, identifier, shortname, opts) {
684
+ const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
685
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(shortname)) {
686
+ console.error(err(`Invalid name "${shortname}". Use letters, digits, hyphens, underscores. Must start with a letter.`));
687
+ process.exit(1);
688
+ }
689
+ if (db.prepare("SELECT id FROM projects WHERE slug = ? AND id != ?").get(shortname, project.id)) {
690
+ console.error(err(`"${shortname}" is already a project slug.`));
691
+ process.exit(1);
692
+ }
693
+ const conflictAlias = db.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(shortname);
694
+ if (conflictAlias && conflictAlias.project_id !== project.id) {
695
+ console.error(err(`"${shortname}" is already used by another project.`));
696
+ process.exit(1);
697
+ }
698
+ if (!conflictAlias) db.prepare("INSERT INTO aliases (alias, project_id) VALUES (?, ?)").run(shortname, project.id);
699
+ if (opts.permission) {
700
+ const config = applyPreset(getSessionConfig(project), opts.permission);
701
+ db.prepare("UPDATE projects SET session_config = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(config), now(), project.id);
702
+ }
703
+ console.log(ok(`Named: ${bold(shortname)} → ${project.slug} (${shortenPath(project.root_path, 50)})`));
704
+ if (opts.permission) console.log(dim(` Permission: ${opts.permission}`));
705
+ }
706
+ function cmdUnname(db, shortname) {
707
+ const alias = db.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(shortname);
708
+ if (!alias) {
709
+ console.error(err(`No named project found: "${shortname}"`));
710
+ process.exit(1);
711
+ }
712
+ db.prepare("DELETE FROM aliases WHERE alias = ?").run(shortname);
713
+ const remaining = db.prepare("SELECT COUNT(*) AS cnt FROM aliases WHERE project_id = ?").get(alias.project_id);
714
+ console.log(ok(`Removed name: ${bold(shortname)}`));
715
+ if (remaining.cnt === 0) console.log(dim(" Project has no remaining names."));
716
+ }
717
+ function cmdNames(db, opts) {
718
+ const rows = db.prepare(`
719
+ SELECT p.*, a.alias AS name,
720
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
721
+ (SELECT MAX(s.created_at) FROM sessions s WHERE s.project_id = p.id) AS last_active
722
+ FROM projects p
723
+ JOIN aliases a ON a.project_id = p.id
724
+ WHERE p.status = 'active'
725
+ ORDER BY p.updated_at DESC
726
+ `).all();
727
+ if (opts.json) {
728
+ const grouped = /* @__PURE__ */ new Map();
729
+ for (const row of rows) if (!grouped.has(row.id)) grouped.set(row.id, {
730
+ name: row.name,
731
+ names: [row.name],
732
+ slug: row.slug,
733
+ display_name: row.display_name,
734
+ root_path: row.root_path,
735
+ session_count: row.session_count,
736
+ last_active: row.last_active ? new Date(row.last_active).toISOString() : null,
737
+ session_config: row.session_config ? JSON.parse(row.session_config) : null
738
+ });
739
+ else grouped.get(row.id).names.push(row.name);
740
+ console.log(JSON.stringify([...grouped.values()], null, 2));
741
+ return;
742
+ }
743
+ if (!rows.length) {
744
+ console.log(warn("No named projects. Use: pai project name <slug-or-number> <shortname>"));
745
+ return;
746
+ }
747
+ const seen = /* @__PURE__ */ new Set();
748
+ const tableRows = [];
749
+ for (const row of rows) {
750
+ if (seen.has(row.id)) continue;
751
+ seen.add(row.id);
752
+ const allNames = rows.filter((r) => r.id === row.id).map((r) => r.name);
753
+ const perm = (row.session_config ? JSON.parse(row.session_config) : null)?.permission ?? dim("default");
754
+ tableRows.push([
755
+ bold(allNames.join(", ")),
756
+ row.slug,
757
+ dim(shortenPath(row.root_path, 40)),
758
+ typeof perm === "string" ? perm : dim("default"),
759
+ String(row.session_count),
760
+ fmtDate(row.last_active)
761
+ ]);
762
+ }
763
+ console.log();
764
+ console.log(header(" Named Projects"));
765
+ console.log();
766
+ console.log(renderTable([
767
+ "Name(s)",
768
+ "Slug",
769
+ "Path",
770
+ "Permission",
771
+ "Sessions",
772
+ "Last Active"
773
+ ], tableRows));
774
+ console.log();
775
+ console.log(dim(` ${tableRows.length} named project(s)`));
776
+ }
777
+ function cmdConfig(db, identifier, opts) {
778
+ if (opts.options) {
779
+ if (opts.json) {
780
+ console.log(JSON.stringify({
781
+ options: CONFIG_OPTIONS,
782
+ presets: Object.entries(PERMISSION_PRESETS).map(([name, config]) => ({
783
+ name,
784
+ ...config
785
+ }))
786
+ }, null, 2));
787
+ return;
788
+ }
789
+ console.log();
790
+ console.log(header(" Session Config Options"));
791
+ console.log();
792
+ console.log(bold(" Available keys:"));
793
+ console.log();
794
+ for (const opt of CONFIG_OPTIONS) {
795
+ console.log(` ${bold(opt.key.padEnd(14))} ${dim(`(${opt.type})`)} ${opt.description}`);
796
+ console.log(` ${" ".repeat(14)} ${dim("e.g.")} ${opt.examples.map((e) => chalk.cyan(e)).join(", ")}`);
797
+ }
798
+ console.log();
799
+ console.log(bold(" Permission presets:"));
800
+ console.log();
801
+ for (const [name, config] of Object.entries(PERMISSION_PRESETS)) {
802
+ const parts = [];
803
+ if (config.flags) parts.push(`flags: ${config.flags}`);
804
+ if (config.env && Object.keys(config.env).length) parts.push(`env: ${Object.entries(config.env).map(([k, v]) => `${k}=${v}`).join(" ")}`);
805
+ if (config.autoStart) parts.push("autoStart");
806
+ if (config.prompt) parts.push(`prompt: "${config.prompt}"`);
807
+ console.log(` ${bold(name.padEnd(10))} ${dim(parts.join(", ") || "(vanilla)")}`);
808
+ }
809
+ console.log();
810
+ return;
811
+ }
812
+ if (opts.defaults) {
813
+ let defaults = getGlobalDefaults();
814
+ if (opts.reset) {
815
+ saveGlobalDefaults({});
816
+ console.log(ok("Global session defaults reset."));
817
+ return;
818
+ }
819
+ if (opts.preset) {
820
+ defaults = applyPreset(defaults, opts.preset);
821
+ saveGlobalDefaults(defaults);
822
+ console.log(ok(`Global defaults set to preset: ${bold(opts.preset)}`));
823
+ return;
824
+ }
825
+ if (opts.set?.length) {
826
+ for (const pair of opts.set) {
827
+ const eqIdx = pair.indexOf("=");
828
+ if (eqIdx === -1) {
829
+ console.error(err(`Invalid format: "${pair}". Use key=value.`));
830
+ continue;
831
+ }
832
+ const key = pair.substring(0, eqIdx);
833
+ const value = pair.substring(eqIdx + 1);
834
+ if (!CONFIG_OPTIONS.find((o) => o.key === key)) {
835
+ console.error(err(`Unknown key: "${key}". Run: pai project config --options`));
836
+ continue;
837
+ }
838
+ if (key === "env") defaults.env = {
839
+ ...defaults.env ?? {},
840
+ ...parseConfigValue("env", value)
841
+ };
842
+ else defaults[key] = parseConfigValue(key, value);
843
+ }
844
+ saveGlobalDefaults(defaults);
845
+ console.log(ok("Global defaults updated."));
846
+ }
847
+ if (opts.unset?.length) {
848
+ for (const key of opts.unset) if (key.startsWith("env.")) {
849
+ const envKey = key.substring(4);
850
+ if (defaults.env) delete defaults.env[envKey];
851
+ } else delete defaults[key];
852
+ saveGlobalDefaults(defaults);
853
+ console.log(ok("Global defaults updated."));
854
+ }
855
+ if (opts.json) console.log(JSON.stringify(defaults, null, 2));
856
+ else {
857
+ console.log();
858
+ console.log(header(" Global Session Defaults"));
859
+ console.log();
860
+ if (Object.keys(defaults).length === 0) {
861
+ console.log(dim(" No defaults set. New sessions use vanilla Claude."));
862
+ console.log(dim(" Set with: pai project config --defaults --preset full"));
863
+ } else for (const [key, value] of Object.entries(defaults)) {
864
+ const display = typeof value === "object" ? JSON.stringify(value) : String(value);
865
+ console.log(` ${bold(key.padEnd(14))} ${display}`);
866
+ }
867
+ console.log();
868
+ }
869
+ return;
870
+ }
871
+ if (!identifier) {
872
+ console.error(err("Specify a project: pai project config <name-or-slug>"));
873
+ console.error(dim(" Or use --defaults for global defaults, --options for available keys."));
874
+ process.exit(1);
875
+ }
876
+ const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
877
+ let config = getSessionConfig(project);
878
+ if (opts.reset) {
879
+ db.prepare("UPDATE projects SET session_config = NULL, updated_at = ? WHERE id = ?").run(now(), project.id);
880
+ console.log(ok(`Config reset for ${bold(project.slug)}. Will use global defaults.`));
881
+ return;
882
+ }
883
+ if (opts.preset) {
884
+ config = applyPreset(config, opts.preset);
885
+ db.prepare("UPDATE projects SET session_config = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(config), now(), project.id);
886
+ console.log(ok(`Applied preset ${bold(opts.preset)} to ${bold(project.slug)}`));
887
+ }
888
+ if (opts.set?.length) {
889
+ for (const pair of opts.set) {
890
+ const eqIdx = pair.indexOf("=");
891
+ if (eqIdx === -1) {
892
+ console.error(err(`Invalid format: "${pair}". Use key=value.`));
893
+ continue;
894
+ }
895
+ const key = pair.substring(0, eqIdx);
896
+ const value = pair.substring(eqIdx + 1);
897
+ if (!CONFIG_OPTIONS.find((o) => o.key === key)) {
898
+ console.error(err(`Unknown key: "${key}". Run: pai project config --options`));
899
+ continue;
900
+ }
901
+ if (key === "env") config.env = {
902
+ ...config.env ?? {},
903
+ ...parseConfigValue("env", value)
904
+ };
905
+ else config[key] = parseConfigValue(key, value);
906
+ }
907
+ db.prepare("UPDATE projects SET session_config = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(config), now(), project.id);
908
+ console.log(ok(`Config updated for ${bold(project.slug)}`));
909
+ }
910
+ if (opts.unset?.length) {
911
+ for (const key of opts.unset) if (key.startsWith("env.")) {
912
+ const envKey = key.substring(4);
913
+ if (config.env) delete config.env[envKey];
914
+ } else delete config[key];
915
+ db.prepare("UPDATE projects SET session_config = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(config), now(), project.id);
916
+ console.log(ok(`Config updated for ${bold(project.slug)}`));
917
+ }
918
+ const effective = {
919
+ ...getGlobalDefaults(),
920
+ ...config
921
+ };
922
+ const aliases = getProjectAliases(db, project.id);
923
+ if (opts.json) {
924
+ console.log(JSON.stringify({
925
+ project: project.slug,
926
+ names: aliases,
927
+ root_path: project.root_path,
928
+ config,
929
+ global_defaults: getGlobalDefaults(),
930
+ effective
931
+ }, null, 2));
932
+ return;
933
+ }
934
+ console.log();
935
+ console.log(header(` Config: ${project.slug}`));
936
+ if (aliases.length) console.log(dim(` Names: ${aliases.join(", ")}`));
937
+ console.log(dim(` Path: ${project.root_path}`));
938
+ console.log();
939
+ if (Object.keys(config).length === 0) console.log(dim(" No project-specific config. Using global defaults."));
940
+ else {
941
+ console.log(bold(" Project config:"));
942
+ for (const [key, value] of Object.entries(config)) {
943
+ const display = typeof value === "object" ? JSON.stringify(value) : String(value);
944
+ console.log(` ${bold(key.padEnd(14))} ${display}`);
945
+ }
946
+ }
947
+ const globalDefaults = getGlobalDefaults();
948
+ if (Object.keys(globalDefaults).length > 0) {
949
+ console.log();
950
+ console.log(dim(" Global defaults (overridden by project config):"));
951
+ for (const [key, value] of Object.entries(globalDefaults)) {
952
+ const overridden = key in config;
953
+ const display = typeof value === "object" ? JSON.stringify(value) : String(value);
954
+ console.log(` ${dim(key.padEnd(14))} ${overridden ? chalk.strikethrough(display) + " " + dim("(overridden)") : display}`);
955
+ }
956
+ }
957
+ console.log();
958
+ console.log(bold(" Effective (what AIBroker uses):"));
959
+ for (const [key, value] of Object.entries(effective)) {
960
+ const display = typeof value === "object" ? JSON.stringify(value) : String(value);
961
+ console.log(` ${bold(key.padEnd(14))} ${display}`);
962
+ }
963
+ console.log();
964
+ }
965
+
966
+ //#endregion
967
+ //#region src/cli/commands/project/health.ts
968
+ function findOrphanedNotesDirs(project) {
969
+ const claudeProjects = join(homedir(), ".claude", "projects");
970
+ if (!existsSync(claudeProjects)) return [];
971
+ const expected = encodeDir(project.root_path);
972
+ const results = [];
973
+ try {
974
+ for (const entry of readdirSync(claudeProjects)) {
975
+ const full = join(claudeProjects, entry);
976
+ try {
977
+ if (!statSync(full).isDirectory()) continue;
978
+ } catch {
979
+ continue;
980
+ }
981
+ if (entry === expected || entry === project.encoded_dir) {
982
+ const notesDir = join(full, "Notes");
983
+ if (existsSync(notesDir)) results.push(notesDir);
984
+ }
985
+ }
986
+ } catch {}
987
+ return results;
988
+ }
989
+ function suggestMovedPath(project) {
990
+ const name = basename(project.root_path);
991
+ const candidates = [
992
+ join(homedir(), "dev", name),
993
+ join(homedir(), "dev", "ai", name),
994
+ join(homedir(), "Desktop", name),
995
+ join(homedir(), "Projects", name)
996
+ ];
997
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
998
+ }
999
+ function cmdHealth$1(db, opts) {
1000
+ const rows = db.prepare(`SELECT p.*,
1001
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
1002
+ FROM projects p
1003
+ ORDER BY p.status ASC, p.updated_at DESC`).all();
1004
+ const results = rows.map((project) => {
1005
+ const pathExists = existsSync(project.root_path);
1006
+ const orphaned = findOrphanedNotesDirs(project);
1007
+ let category;
1008
+ let suggestedPath;
1009
+ if (pathExists) category = "active";
1010
+ else {
1011
+ suggestedPath = suggestMovedPath(project);
1012
+ category = suggestedPath ? "stale" : "dead";
1013
+ }
1014
+ return {
1015
+ project,
1016
+ category,
1017
+ suggestedPath,
1018
+ claudeNotesExists: orphaned.length > 0,
1019
+ orphanedNotesDirs: orphaned
1020
+ };
1021
+ });
1022
+ const filtered = opts.status ? results.filter((r) => r.category === opts.status) : results;
1023
+ if (opts.json) {
1024
+ console.log(JSON.stringify(filtered.map((r) => ({
1025
+ slug: r.project.slug,
1026
+ root_path: r.project.root_path,
1027
+ status: r.project.status,
1028
+ health: r.category,
1029
+ session_count: r.project.session_count,
1030
+ suggested_path: r.suggestedPath ?? null,
1031
+ claude_notes_exists: r.claudeNotesExists,
1032
+ orphaned_notes_dirs: r.orphanedNotesDirs
1033
+ })), null, 2));
1034
+ return;
1035
+ }
1036
+ const active = filtered.filter((r) => r.category === "active");
1037
+ const stale = filtered.filter((r) => r.category === "stale");
1038
+ const dead = filtered.filter((r) => r.category === "dead");
1039
+ console.log();
1040
+ console.log(header(" PAI Project Health Report"));
1041
+ console.log();
1042
+ console.log(` ${chalk.green("Active:")} ${active.length} ${chalk.yellow("Stale (moved?):")} ${stale.length} ${chalk.red("Dead (missing):")} ${dead.length}`);
1043
+ console.log();
1044
+ if (active.length) {
1045
+ console.log(bold(" Active projects (path exists):"));
1046
+ const tableRows = active.map((r) => [
1047
+ bold(r.project.slug),
1048
+ dim(shortenPath(r.project.root_path, 50)),
1049
+ String(r.project.session_count),
1050
+ r.claudeNotesExists ? chalk.green("yes") : dim("no")
1051
+ ]);
1052
+ console.log(renderTable([
1053
+ "Slug",
1054
+ "Path",
1055
+ "Sessions",
1056
+ "Claude Notes"
1057
+ ], tableRows).split("\n").map((l) => " " + l).join("\n"));
1058
+ console.log();
1059
+ }
1060
+ if (stale.length) {
1061
+ console.log(warn(" Stale projects (path missing, possible new location found):"));
1062
+ for (const r of stale) {
1063
+ console.log(` ${bold(r.project.slug)}`);
1064
+ console.log(dim(` Old path: ${r.project.root_path}`));
1065
+ console.log(chalk.cyan(` Found at: ${r.suggestedPath}`));
1066
+ if (r.claudeNotesExists) console.log(chalk.green(` Notes: ${r.orphanedNotesDirs.join(", ")}`));
1067
+ if (opts.fix && r.suggestedPath) {
1068
+ const ts = now();
1069
+ const newEncoded = encodeDir(r.suggestedPath);
1070
+ db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(r.suggestedPath, newEncoded, ts, r.project.id);
1071
+ console.log(ok(` Auto-fixed: updated path to ${r.suggestedPath}`));
1072
+ } else if (r.suggestedPath) console.log(dim(` Fix: pai project move ${r.project.slug} ${r.suggestedPath}`));
1073
+ }
1074
+ console.log();
1075
+ }
1076
+ if (dead.length) {
1077
+ console.log(err(" Dead projects (path missing, no match found):"));
1078
+ for (const r of dead) {
1079
+ console.log(` ${bold(r.project.slug)} ${dim(r.project.root_path)}`);
1080
+ if (r.claudeNotesExists) console.log(chalk.yellow(` Notes: ${r.orphanedNotesDirs.join(", ")}`));
1081
+ if (r.project.session_count === 0 && opts.fix) {
1082
+ db.prepare("UPDATE projects SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), r.project.id);
1083
+ console.log(ok(" Auto-fixed: archived (0 sessions, path gone)"));
1084
+ } else console.log(dim(` Fix: pai project archive ${r.project.slug} (or pai project move ...)`));
1085
+ }
1086
+ console.log();
1087
+ }
1088
+ console.log(dim(` ${rows.length} total: ${active.length} active, ${stale.length} stale, ${dead.length} dead`));
1089
+ if (!opts.fix && (stale.length > 0 || dead.length > 0)) {
1090
+ console.log();
1091
+ console.log(warn(" Run with --fix to auto-remediate where possible."));
1092
+ }
1093
+ }
1094
+
1095
+ //#endregion
1096
+ //#region src/cli/commands/project/index.ts
1097
+ function registerProjectCommands(projectCmd, getDb) {
1098
+ projectCmd.command("add <path>").description("Register a project directory in the PAI registry").option("--slug <slug>", "Override auto-generated slug").option("--type <type>", "Project type: local | central | obsidian-linked | external", "local").option("--display-name <name>", "Human-readable display name").action((rawPath, opts) => {
1099
+ cmdAdd(getDb(), rawPath, opts);
1100
+ });
1101
+ projectCmd.command("list").description("List registered projects").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
1102
+ cmdList$2(getDb(), opts);
1103
+ });
1104
+ projectCmd.command("info <slug>").description("Show full details for a project").action((slug) => {
1105
+ cmdInfo$1(getDb(), slug);
1106
+ });
1107
+ projectCmd.command("archive <slug>").description("Archive a project").action((slug) => {
1108
+ cmdArchive(getDb(), slug);
1109
+ });
1110
+ projectCmd.command("unarchive <slug>").description("Restore an archived project to active status").action((slug) => {
1111
+ cmdUnarchive(getDb(), slug);
1112
+ });
1113
+ projectCmd.command("move <slug> <new-path>").description("Update the root path for a project").action((slug, newPath) => {
1114
+ cmdMove(getDb(), slug, newPath);
1115
+ });
1116
+ projectCmd.command("tag <slug> <tags...>").description("Add one or more tags to a project").action((slug, tags) => {
1117
+ cmdTag$1(getDb(), slug, tags);
1118
+ });
1119
+ projectCmd.command("alias <slug> <alias>").description("Register an alternative slug for a project").action((slug, alias) => {
1120
+ cmdAlias(getDb(), slug, alias);
1121
+ });
1122
+ projectCmd.command("edit <slug>").description("Edit project metadata").option("--display-name <name>", "New display name").option("--type <type>", "New type").action((slug, opts) => {
1123
+ cmdEdit(getDb(), slug, opts);
1124
+ });
1125
+ projectCmd.command("cd <identifier>").description("Print the root path for a project (use with: cd $(pai project cd <id>))").action((identifier) => {
1126
+ const project = resolveIdentifier(getDb(), identifier);
1127
+ if (!project) {
1128
+ console.error(`Project not found: ${identifier}`);
1129
+ process.exit(1);
1130
+ }
1131
+ process.stdout.write(project.root_path + "\n");
1132
+ });
1133
+ projectCmd.command("detect [path]").description("Detect which registered project the given path (or CWD) belongs to").option("--json", "Output raw JSON instead of human-readable text").action((pathArg, opts) => {
1134
+ cmdDetect(getDb(), pathArg, opts);
1135
+ });
1136
+ projectCmd.command("health").description("Audit all registered projects: check which paths still exist, find moved/dead projects").option("--fix", "Auto-remediate where possible (update moved paths, archive dead zero-session projects)").option("--json", "Output raw JSON report").option("--status <category>", "Filter output to: active | stale | dead").action((opts) => {
732
1137
  cmdHealth$1(getDb(), opts);
733
1138
  });
734
1139
  projectCmd.command("consolidate <identifier>").description("Consolidate scattered ~/.claude/projects/.../Notes/ directories for a project into its canonical Notes/ location").option("--yes", "Perform consolidation without confirmation prompt").option("--dry-run", "Preview what would be moved without making changes").action((identifier, opts) => {
@@ -740,6 +1145,18 @@ function registerProjectCommands(projectCmd, getDb) {
740
1145
  projectCmd.command("go <query>").description("Print the root path for a project by slug, partial name, or fuzzy match.\nDesigned for shell integration: cd $(pai project go <query>)\nOr set a shell alias: alias pcd='cd $(pai project go)'").action((query) => {
741
1146
  cmdGo(getDb(), query);
742
1147
  });
1148
+ projectCmd.command("name <identifier> <shortname>").description("Give a project a short name for quick access (used by AIBroker to launch sessions)").option("--permission <level>", "Permission level: full | trusted | default (or raw CLI flags)").action((identifier, shortname, opts) => {
1149
+ cmdName(getDb(), identifier, shortname, opts);
1150
+ });
1151
+ projectCmd.command("unname <shortname>").description("Remove a project's short name").action((shortname) => {
1152
+ cmdUnname(getDb(), shortname);
1153
+ });
1154
+ projectCmd.command("names").description("List named projects (your curated shortlist)").option("--json", "Output JSON for AIBroker consumption").action((opts) => {
1155
+ cmdNames(getDb(), opts);
1156
+ });
1157
+ projectCmd.command("config [identifier]").description("View or modify session launch config for a project.\nUse --options to discover available keys and presets.\nUse --defaults to manage global defaults for new sessions.").option("--set <key=value...>", "Set config values (repeatable)", (v, prev) => [...prev, v], []).option("--unset <key...>", "Remove config keys (repeatable, use env.KEY for env vars)", (v, prev) => [...prev, v], []).option("--preset <name>", "Apply a permission preset: full | trusted | default").option("--defaults", "Manage global session defaults instead of a project").option("--options", "List available config keys and presets").option("--json", "Output JSON").option("--reset", "Reset config to empty (inherit global defaults)").action((identifier, opts) => {
1158
+ cmdConfig(getDb(), identifier, opts);
1159
+ });
743
1160
  }
744
1161
 
745
1162
  //#endregion
@@ -754,275 +1171,6 @@ function registerProjectCommands(projectCmd, getDb) {
754
1171
  * - type "user": { type: "user", message: { role: "user", content: string | [{ type, text }] } }
755
1172
  * - type "assistant": { type: "assistant", message: { role: "assistant", content: [{ type, text }] } }
756
1173
  */
757
- const STOP_WORDS = new Set([
758
- "the",
759
- "a",
760
- "an",
761
- "is",
762
- "are",
763
- "was",
764
- "were",
765
- "be",
766
- "been",
767
- "being",
768
- "have",
769
- "has",
770
- "had",
771
- "do",
772
- "does",
773
- "did",
774
- "will",
775
- "would",
776
- "could",
777
- "should",
778
- "may",
779
- "might",
780
- "can",
781
- "shall",
782
- "this",
783
- "that",
784
- "these",
785
- "those",
786
- "it",
787
- "its",
788
- "i",
789
- "you",
790
- "we",
791
- "they",
792
- "he",
793
- "she",
794
- "my",
795
- "your",
796
- "our",
797
- "their",
798
- "what",
799
- "which",
800
- "who",
801
- "whom",
802
- "how",
803
- "when",
804
- "where",
805
- "why",
806
- "not",
807
- "no",
808
- "yes",
809
- "just",
810
- "also",
811
- "very",
812
- "really",
813
- "about",
814
- "after",
815
- "before",
816
- "from",
817
- "into",
818
- "with",
819
- "without",
820
- "for",
821
- "and",
822
- "or",
823
- "but",
824
- "if",
825
- "then",
826
- "else",
827
- "so",
828
- "because",
829
- "as",
830
- "at",
831
- "by",
832
- "in",
833
- "on",
834
- "of",
835
- "to",
836
- "up",
837
- "out",
838
- "off",
839
- "over",
840
- "under",
841
- "more",
842
- "most",
843
- "some",
844
- "any",
845
- "all",
846
- "each",
847
- "every",
848
- "both",
849
- "few",
850
- "many",
851
- "much",
852
- "other",
853
- "another",
854
- "such",
855
- "only",
856
- "own",
857
- "same",
858
- "than",
859
- "too",
860
- "let",
861
- "me",
862
- "us",
863
- "ok",
864
- "okay",
865
- "sure",
866
- "please",
867
- "thanks",
868
- "thank",
869
- "here",
870
- "there",
871
- "now",
872
- "well",
873
- "like",
874
- "want",
875
- "need",
876
- "know",
877
- "think",
878
- "see",
879
- "look",
880
- "make",
881
- "get",
882
- "go",
883
- "come",
884
- "take",
885
- "use",
886
- "find",
887
- "give",
888
- "tell",
889
- "say",
890
- "said",
891
- "try",
892
- "keep",
893
- "run",
894
- "set",
895
- "put",
896
- "add",
897
- "show",
898
- "check",
899
- "new",
900
- "file",
901
- "code",
902
- "going",
903
- "done",
904
- "got",
905
- "https",
906
- "http",
907
- "www",
908
- "com",
909
- "org",
910
- "net",
911
- "io",
912
- "null",
913
- "undefined",
914
- "true",
915
- "false",
916
- "ll",
917
- "ve",
918
- "re",
919
- "don",
920
- "thats",
921
- "its",
922
- "heres",
923
- "theres",
924
- "youre",
925
- "theyre",
926
- "didnt",
927
- "dont",
928
- "doesnt",
929
- "havent",
930
- "hasnt",
931
- "wont",
932
- "cant",
933
- "shouldnt",
934
- "wouldnt",
935
- "couldnt",
936
- "isnt",
937
- "arent",
938
- "wasnt",
939
- "werent",
940
- "never",
941
- "ever",
942
- "still",
943
- "already",
944
- "yet",
945
- "back",
946
- "away",
947
- "down",
948
- "right",
949
- "left",
950
- "next",
951
- "last",
952
- "first",
953
- "second",
954
- "third",
955
- "one",
956
- "two",
957
- "three",
958
- "four",
959
- "five",
960
- "six",
961
- "seven",
962
- "eight",
963
- "nine",
964
- "ten",
965
- "time",
966
- "way",
967
- "thing",
968
- "something",
969
- "anything",
970
- "nothing",
971
- "everything",
972
- "someone",
973
- "anyone",
974
- "everyone",
975
- "then",
976
- "again",
977
- "once",
978
- "twice",
979
- "since",
980
- "while",
981
- "though",
982
- "although",
983
- "however",
984
- "therefore",
985
- "thus",
986
- "hence",
987
- "meanwhile",
988
- "moreover",
989
- "furthermore",
990
- "otherwise",
991
- "instead",
992
- "anyway",
993
- "actually",
994
- "basically",
995
- "literally",
996
- "simply",
997
- "exactly",
998
- "probably",
999
- "possibly",
1000
- "maybe",
1001
- "perhaps",
1002
- "certainly",
1003
- "definitely",
1004
- "absolutely",
1005
- "completely",
1006
- "totally",
1007
- "quite",
1008
- "rather",
1009
- "fairly",
1010
- "nearly",
1011
- "almost",
1012
- "barely",
1013
- "hardly",
1014
- "quickly",
1015
- "slowly",
1016
- "easily",
1017
- "likely",
1018
- "unlikely",
1019
- "via",
1020
- "per",
1021
- "etc",
1022
- "ie",
1023
- "eg",
1024
- "vs"
1025
- ]);
1026
1174
  /**
1027
1175
  * Extract plain text from a parsed JSONL message object.
1028
1176
  */
@@ -1148,7 +1296,7 @@ function generateSlug(messages, opts) {
1148
1296
  }
1149
1297
 
1150
1298
  //#endregion
1151
- //#region src/cli/commands/session.ts
1299
+ //#region src/cli/commands/session/helpers.ts
1152
1300
  function getProject$1(db, slug) {
1153
1301
  return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(slug);
1154
1302
  }
@@ -1159,36 +1307,19 @@ function statusColor(status) {
1159
1307
  default: return chalk.yellow(status);
1160
1308
  }
1161
1309
  }
1162
- /**
1163
- * Convert a slug to a title-cased display name suitable for filenames.
1164
- * "memory-engine" → "Memory Engine"
1165
- * "slug-generator" → "Slug Generator"
1166
- * "session-slug-fix" → "Session Slug Fix"
1167
- */
1310
+ /** Convert a slug to title-cased display name: "memory-engine" → "Memory Engine" */
1168
1311
  function toTitleCase$1(slug) {
1169
1312
  return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1170
1313
  }
1171
- /**
1172
- * Find the Notes directory for a project.
1173
- *
1174
- * Notes live inside the Claude-managed project directory:
1175
- * ~/.claude/projects/<encoded_dir>/Notes/
1176
- */
1314
+ /** Notes directory for a project: ~/.claude/projects/<encoded_dir>/Notes/ */
1177
1315
  function getNotesDir(project) {
1178
1316
  return join(homedir(), ".claude", "projects", project.encoded_dir, "Notes");
1179
1317
  }
1180
- /**
1181
- * Format a session filename from its parts.
1182
- * number=27, date="2026-02-23", titleSlug="Memory Engine"
1183
- * → "0027 - 2026-02-23 - Memory Engine.md"
1184
- */
1318
+ /** Format a session filename: number=27, date="2026-02-23" → "0027 - 2026-02-23 - Title.md" */
1185
1319
  function formatFilename(number, date, titleSlug) {
1186
1320
  return `${String(number).padStart(4, "0")} - ${date} - ${titleSlug}.md`;
1187
1321
  }
1188
- /**
1189
- * Look up a session by project + number OR "latest".
1190
- * Returns the session row or exits with an error.
1191
- */
1322
+ /** Resolve a session by project + number or "latest". Exits on failure. */
1192
1323
  function resolveSession(db, project, numberOrLatest) {
1193
1324
  let session;
1194
1325
  if (numberOrLatest === "latest") session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
@@ -1206,7 +1337,20 @@ function resolveSession(db, project, numberOrLatest) {
1206
1337
  }
1207
1338
  return session;
1208
1339
  }
1209
- function cmdList(db, projectSlug, opts) {
1340
+ function upsertTag(db, tagName) {
1341
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tagName);
1342
+ return db.prepare("SELECT id FROM tags WHERE name = ?").get(tagName).id;
1343
+ }
1344
+ function getSessionTags(db, sessionId) {
1345
+ return db.prepare(`SELECT t.name FROM tags t
1346
+ JOIN session_tags st ON st.tag_id = t.id
1347
+ WHERE st.session_id = ?
1348
+ ORDER BY t.name`).all(sessionId).map((r) => r.name);
1349
+ }
1350
+
1351
+ //#endregion
1352
+ //#region src/cli/commands/session/commands.ts
1353
+ function cmdList$1(db, projectSlug, opts) {
1210
1354
  const limit = parseInt(opts.limit ?? "20", 10);
1211
1355
  const params = [];
1212
1356
  let query = `
@@ -1301,10 +1445,6 @@ function cmdInfo(db, projectSlug, sessionNumber) {
1301
1445
  if (session.closed_at) console.log(` ${bold("Closed:")} ${fmtDate(session.closed_at)}`);
1302
1446
  console.log();
1303
1447
  }
1304
- /**
1305
- * Rename a session note: updates the database (slug + title), renames the
1306
- * file on disk, and updates the H1 title inside the Markdown file.
1307
- */
1308
1448
  function cmdRename(db, projectSlug, numberOrLatest, newSlug) {
1309
1449
  const project = getProject$1(db, projectSlug);
1310
1450
  if (!project) {
@@ -1357,10 +1497,6 @@ function cmdRename(db, projectSlug, numberOrLatest, newSlug) {
1357
1497
  console.log(` ${bold("Title:")} ${titleSlug}`);
1358
1498
  console.log();
1359
1499
  }
1360
- /**
1361
- * Generate (and optionally apply) a slug for a session by analysing its
1362
- * Claude Code JSONL transcript.
1363
- */
1364
1500
  function cmdSlug(db, projectSlug, numberOrLatest, opts) {
1365
1501
  const project = getProject$1(db, projectSlug);
1366
1502
  if (!project) {
@@ -1388,108 +1524,204 @@ function cmdSlug(db, projectSlug, numberOrLatest, opts) {
1388
1524
  cmdRename(db, projectSlug, String(session.number), generatedSlug);
1389
1525
  }
1390
1526
  }
1391
- function upsertTag(db, tagName) {
1392
- db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tagName);
1393
- return db.prepare("SELECT id FROM tags WHERE name = ?").get(tagName).id;
1394
- }
1395
- function getSessionTags(db, sessionId) {
1396
- return db.prepare(`SELECT t.name FROM tags t
1397
- JOIN session_tags st ON st.tag_id = t.id
1398
- WHERE st.session_id = ?
1399
- ORDER BY t.name`).all(sessionId).map((r) => r.name);
1400
- }
1401
- /**
1402
- * Set or show tags on a session.
1403
- *
1404
- * With no tags supplied, prints the current tags.
1405
- * Tags can be supplied as separate args or as a single comma-separated string.
1406
- * pai session tag 20-webseiten 81 — show current tags
1407
- * pai session tag 20-webseiten 81 docker migration server
1408
- * pai session tag 20-webseiten 81 docker,migration,server
1409
- */
1410
1527
  function cmdTag(db, projectSlug, sessionNumber, rawTags) {
1411
1528
  const project = getProject$1(db, projectSlug);
1412
1529
  if (!project) {
1413
1530
  console.error(err(`Project not found: ${projectSlug}`));
1414
1531
  process.exit(1);
1415
1532
  }
1416
- const session = resolveSession(db, project, sessionNumber);
1417
- if (rawTags.length === 0) {
1418
- const current = getSessionTags(db, session.id);
1419
- console.log();
1420
- if (current.length === 0) console.log(dim(` Session #${session.number} has no tags.`));
1421
- else console.log(` ${bold(`Session #${session.number}`)} tags: ${current.map((t) => chalk.cyan(t)).join(", ")}`);
1422
- console.log();
1533
+ const session = resolveSession(db, project, sessionNumber);
1534
+ if (rawTags.length === 0) {
1535
+ const current = getSessionTags(db, session.id);
1536
+ console.log();
1537
+ if (current.length === 0) console.log(dim(` Session #${session.number} has no tags.`));
1538
+ else console.log(` ${bold(`Session #${session.number}`)} tags: ${current.map((t) => chalk.cyan(t)).join(", ")}`);
1539
+ console.log();
1540
+ return;
1541
+ }
1542
+ const tags = rawTags.flatMap((t) => t.split(",")).map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
1543
+ if (tags.length === 0) {
1544
+ console.log(warn("No valid tags provided."));
1545
+ return;
1546
+ }
1547
+ const added = [];
1548
+ const skipped = [];
1549
+ for (const tagName of tags) {
1550
+ const tagId = upsertTag(db, tagName);
1551
+ if (db.prepare("SELECT 1 FROM session_tags WHERE session_id = ? AND tag_id = ?").get(session.id, tagId)) skipped.push(tagName);
1552
+ else {
1553
+ db.prepare("INSERT INTO session_tags (session_id, tag_id) VALUES (?, ?)").run(session.id, tagId);
1554
+ added.push(tagName);
1555
+ }
1556
+ }
1557
+ console.log();
1558
+ if (added.length) console.log(ok(` Tagged session #${session.number}: ${added.map((t) => chalk.cyan(t)).join(", ")}`));
1559
+ if (skipped.length) console.log(dim(` Already present: ${skipped.join(", ")}`));
1560
+ const allTags = getSessionTags(db, session.id);
1561
+ console.log(` ${bold("All tags:")} ${allTags.map((t) => chalk.cyan(t)).join(", ")}`);
1562
+ console.log();
1563
+ }
1564
+ function cmdRoute(db, projectSlug, sessionNumber, targetProjectSlug, opts) {
1565
+ const project = getProject$1(db, projectSlug);
1566
+ if (!project) {
1567
+ console.error(err(`Project not found: ${projectSlug}`));
1568
+ process.exit(1);
1569
+ }
1570
+ const session = resolveSession(db, project, sessionNumber);
1571
+ const targetProject = db.prepare("SELECT id, slug, display_name FROM projects WHERE slug = ?").get(targetProjectSlug);
1572
+ if (!targetProject) {
1573
+ console.error(err(`Target project not found: ${targetProjectSlug}`));
1574
+ process.exit(1);
1575
+ }
1576
+ const validTypes = [
1577
+ "related",
1578
+ "follow-up",
1579
+ "reference"
1580
+ ];
1581
+ const linkType = opts.type ?? "related";
1582
+ if (!validTypes.includes(linkType)) {
1583
+ console.error(err(`Invalid link type "${linkType}". Valid: ${validTypes.join(", ")}`));
1584
+ process.exit(1);
1585
+ }
1586
+ try {
1587
+ db.prepare(`INSERT INTO links (session_id, target_project_id, link_type, created_at)
1588
+ VALUES (?, ?, ?, ?)`).run(session.id, targetProject.id, linkType, Date.now());
1589
+ } catch {
1590
+ console.log(warn(` Link already exists: session #${session.number} → ${targetProjectSlug}`));
1591
+ return;
1592
+ }
1593
+ console.log();
1594
+ console.log(ok(` Linked session #${session.number} (${project.slug}) → ${targetProject.display_name} (${targetProjectSlug})`));
1595
+ console.log(dim(` Link type: ${linkType}`));
1596
+ console.log();
1597
+ }
1598
+ function cmdActive(db, opts) {
1599
+ const minutes = parseInt(opts.minutes ?? "60", 10);
1600
+ const cutoff = Date.now() - minutes * 60 * 1e3;
1601
+ const claudeProjectsDir = join(homedir(), ".claude", "projects");
1602
+ if (!existsSync(claudeProjectsDir)) {
1603
+ console.log(err("Claude projects directory not found."));
1604
+ return;
1605
+ }
1606
+ const active = [];
1607
+ const entries = readdirSync(claudeProjectsDir);
1608
+ for (const entry of entries) {
1609
+ const projectDir = join(claudeProjectsDir, entry);
1610
+ try {
1611
+ if (!statSync(projectDir).isDirectory()) continue;
1612
+ } catch {
1613
+ continue;
1614
+ }
1615
+ let latestJsonl = null;
1616
+ let latestMtime = 0;
1617
+ try {
1618
+ for (const file of readdirSync(projectDir)) {
1619
+ if (!file.endsWith(".jsonl")) continue;
1620
+ const filePath = join(projectDir, file);
1621
+ try {
1622
+ const mtime = statSync(filePath).mtimeMs;
1623
+ if (mtime > latestMtime) {
1624
+ latestMtime = mtime;
1625
+ latestJsonl = filePath;
1626
+ }
1627
+ } catch {
1628
+ continue;
1629
+ }
1630
+ }
1631
+ } catch {
1632
+ continue;
1633
+ }
1634
+ if (!latestJsonl || latestMtime < cutoff) continue;
1635
+ const project = db.prepare("SELECT slug, display_name, root_path FROM projects WHERE encoded_dir = ?").get(entry);
1636
+ active.push({
1637
+ slug: project?.slug ?? entry,
1638
+ displayName: project?.display_name ?? project?.slug ?? entry,
1639
+ rootPath: project?.root_path ?? "",
1640
+ encodedDir: entry,
1641
+ lastModified: new Date(latestMtime),
1642
+ jsonlFile: latestJsonl
1643
+ });
1644
+ }
1645
+ active.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
1646
+ const seen = /* @__PURE__ */ new Set();
1647
+ const deduped = active.filter((a) => {
1648
+ const key = a.slug.replace(/-\d+$/, "");
1649
+ if (seen.has(key)) return false;
1650
+ seen.add(key);
1651
+ return true;
1652
+ });
1653
+ if (opts.json) {
1654
+ console.log(JSON.stringify(deduped.map((a) => ({
1655
+ slug: a.slug,
1656
+ display_name: a.displayName,
1657
+ root_path: a.rootPath,
1658
+ last_modified: a.lastModified.toISOString()
1659
+ })), null, 2));
1423
1660
  return;
1424
1661
  }
1425
- const tags = rawTags.flatMap((t) => t.split(",")).map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
1426
- if (tags.length === 0) {
1427
- console.log(warn("No valid tags provided."));
1662
+ if (deduped.length === 0) {
1663
+ console.log(dim(`No active sessions in the last ${minutes} minutes.`));
1428
1664
  return;
1429
1665
  }
1430
- const added = [];
1431
- const skipped = [];
1432
- for (const tagName of tags) {
1433
- const tagId = upsertTag(db, tagName);
1434
- if (db.prepare("SELECT 1 FROM session_tags WHERE session_id = ? AND tag_id = ?").get(session.id, tagId)) skipped.push(tagName);
1435
- else {
1436
- db.prepare("INSERT INTO session_tags (session_id, tag_id) VALUES (?, ?)").run(session.id, tagId);
1437
- added.push(tagName);
1438
- }
1439
- }
1440
- console.log();
1441
- if (added.length) console.log(ok(` Tagged session #${session.number}: ${added.map((t) => chalk.cyan(t)).join(", ")}`));
1442
- if (skipped.length) console.log(dim(` Already present: ${skipped.join(", ")}`));
1443
- const allTags = getSessionTags(db, session.id);
1444
- console.log(` ${bold("All tags:")} ${allTags.map((t) => chalk.cyan(t)).join(", ")}`);
1666
+ console.log(header(`Currently Active Sessions`) + dim(` (modified in last ${minutes}min)`));
1445
1667
  console.log();
1668
+ const rows = deduped.map((a) => {
1669
+ const time = a.lastModified.toTimeString().slice(0, 5);
1670
+ const dirName = a.rootPath ? a.rootPath.replace(homedir(), "~").split("/").pop() ?? a.slug : a.slug;
1671
+ return [
1672
+ chalk.cyan(dirName),
1673
+ dim(a.slug),
1674
+ chalk.green(time)
1675
+ ];
1676
+ });
1677
+ console.log(renderTable([
1678
+ "Directory",
1679
+ "Project",
1680
+ "Last Active"
1681
+ ], rows));
1446
1682
  }
1447
- /**
1448
- * Create a cross-reference link from a session to a target project.
1449
- *
1450
- * pai session route <project-slug> <session-number> <target-project>
1451
- * pai session route <project-slug> <session-number> <target-project> --type follow-up
1452
- */
1453
- function cmdRoute(db, projectSlug, sessionNumber, targetProjectSlug, opts) {
1454
- const project = getProject$1(db, projectSlug);
1455
- if (!project) {
1456
- console.error(err(`Project not found: ${projectSlug}`));
1457
- process.exit(1);
1458
- }
1459
- const session = resolveSession(db, project, sessionNumber);
1460
- const targetProject = db.prepare("SELECT id, slug, display_name FROM projects WHERE slug = ?").get(targetProjectSlug);
1461
- if (!targetProject) {
1462
- console.error(err(`Target project not found: ${targetProjectSlug}`));
1463
- process.exit(1);
1464
- }
1465
- const validTypes = [
1466
- "related",
1467
- "follow-up",
1468
- "reference"
1469
- ];
1470
- const linkType = opts.type ?? "related";
1471
- if (!validTypes.includes(linkType)) {
1472
- console.error(err(`Invalid link type "${linkType}". Valid: ${validTypes.join(", ")}`));
1473
- process.exit(1);
1683
+ async function cmdAutoRoute(opts) {
1684
+ const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("../auto-route-C-DrW6BL.mjs");
1685
+ const { openRegistry } = await import("../db-BtuN768f.mjs").then((n) => n.t);
1686
+ const { createStorageBackend } = await import("../factory-Ygqe_bVZ.mjs").then((n) => n.n);
1687
+ const { loadConfig } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
1688
+ const config = loadConfig();
1689
+ const registryDb = openRegistry();
1690
+ const federation = await createStorageBackend(config);
1691
+ const targetCwd = opts.cwd ?? process.cwd();
1692
+ const result = await autoRoute(registryDb, federation, targetCwd, opts.context);
1693
+ if (!result) {
1694
+ console.log();
1695
+ console.log(warn(" No project match found for: " + targetCwd));
1696
+ console.log();
1697
+ console.log(dim(" Tried: path match, PAI.md marker walk") + (opts.context ? dim(", topic detection") : ""));
1698
+ console.log();
1699
+ console.log(dim(" Run 'pai project add .' to register this directory."));
1700
+ console.log();
1701
+ return;
1474
1702
  }
1475
- try {
1476
- db.prepare(`INSERT INTO links (session_id, target_project_id, link_type, created_at)
1477
- VALUES (?, ?, ?, ?)`).run(session.id, targetProject.id, linkType, Date.now());
1478
- } catch {
1479
- console.log(warn(` Link already exists: session #${session.number} → ${targetProjectSlug}`));
1703
+ if (opts.json) {
1704
+ console.log(formatAutoRouteJson(result));
1480
1705
  return;
1481
1706
  }
1482
1707
  console.log();
1483
- console.log(ok(` Linked session #${session.number} (${project.slug}) → ${targetProject.display_name} (${targetProjectSlug})`));
1484
- console.log(dim(` Link type: ${linkType}`));
1708
+ console.log(header(" PAI Auto-Route"));
1709
+ console.log();
1710
+ console.log(` ${bold("Project:")} ${result.display_name}`);
1711
+ console.log(` ${bold("Slug:")} ${result.slug}`);
1712
+ console.log(` ${bold("Root path:")} ${result.root_path}`);
1713
+ console.log(` ${bold("Method:")} ${result.method}`);
1714
+ console.log(` ${bold("Confidence:")} ${(result.confidence * 100).toFixed(0)}%`);
1715
+ console.log();
1716
+ console.log(ok(" Routed to: ") + bold(result.slug));
1485
1717
  console.log();
1486
1718
  }
1719
+
1720
+ //#endregion
1721
+ //#region src/cli/commands/session/checkpoint.ts
1487
1722
  /**
1488
- * Find the Notes directory for the current working directory by scanning
1489
- * ~/.claude/projects/ for a matching encoded-dir entry.
1490
- *
1491
- * Returns the Notes dir path if found, or null if the CWD has no Claude
1492
- * project directory yet.
1723
+ * Session checkpoint command appends a timestamped block to the active
1724
+ * session note. Designed for use in hooks; fast, silent, rate-limited.
1493
1725
  */
1494
1726
  function findNotesDirForCwd() {
1495
1727
  const cwd = process.cwd();
@@ -1519,9 +1751,6 @@ function findNotesDirForCwd() {
1519
1751
  const notesDir = join(claudeProjectsDir, encodedDir, "Notes");
1520
1752
  return existsSync(notesDir) ? notesDir : null;
1521
1753
  }
1522
- /**
1523
- * Find the most recently modified .md file in a directory.
1524
- */
1525
1754
  function findLatestNoteFile(notesDir) {
1526
1755
  let entries;
1527
1756
  try {
@@ -1545,10 +1774,6 @@ function findLatestNoteFile(notesDir) {
1545
1774
  }
1546
1775
  return latestPath;
1547
1776
  }
1548
- /**
1549
- * Rate-limit guard: returns true if the last checkpoint was written less
1550
- * than `minGapSeconds` ago, using a temp file keyed to the notes directory.
1551
- */
1552
1777
  function checkpointTooRecent(notesDir, minGapSeconds) {
1553
1778
  const safeKey = notesDir.replace(/[^a-zA-Z0-9]/g, "-").slice(-80);
1554
1779
  const tmpFile = join(tmpdir(), `pai-checkpoint-${safeKey}`);
@@ -1560,9 +1785,6 @@ function checkpointTooRecent(notesDir, minGapSeconds) {
1560
1785
  return false;
1561
1786
  }
1562
1787
  }
1563
- /**
1564
- * Touch the rate-limit sentinel file.
1565
- */
1566
1788
  function touchCheckpointSentinel(notesDir) {
1567
1789
  const safeKey = notesDir.replace(/[^a-zA-Z0-9]/g, "-").slice(-80);
1568
1790
  const tmpFile = join(tmpdir(), `pai-checkpoint-${safeKey}`);
@@ -1570,12 +1792,6 @@ function touchCheckpointSentinel(notesDir) {
1570
1792
  writeFileSync(tmpFile, String(Date.now()), "utf8");
1571
1793
  } catch {}
1572
1794
  }
1573
- /**
1574
- * Append a timestamped checkpoint block to the active session note.
1575
- *
1576
- * Designed to be called from Claude Code hooks (PostToolUse,
1577
- * UserPromptSubmit). Fast, silent, exit 0 on success or skip.
1578
- */
1579
1795
  function cmdCheckpoint(message, opts) {
1580
1796
  const minGapSeconds = parseInt(opts.minGap ?? "300", 10);
1581
1797
  const notesDir = findNotesDirForCwd();
@@ -1597,19 +1813,15 @@ function cmdCheckpoint(message, opts) {
1597
1813
  touchCheckpointSentinel(notesDir);
1598
1814
  process.exit(0);
1599
1815
  }
1600
- /**
1601
- * TODO candidate locations searched in priority order (mirrors toolProjectTodo).
1602
- */
1816
+
1817
+ //#endregion
1818
+ //#region src/cli/commands/session/handover.ts
1603
1819
  const HANDOVER_TODO_LOCATIONS = [
1604
1820
  "Notes/TODO.md",
1605
1821
  ".claude/Notes/TODO.md",
1606
1822
  "tasks/todo.md",
1607
1823
  "TODO.md"
1608
1824
  ];
1609
- /**
1610
- * Find the TODO.md for a given project root path.
1611
- * Returns { path, content } for the first location that exists, or null.
1612
- */
1613
1825
  function findProjectTodo(rootPath) {
1614
1826
  for (const rel of HANDOVER_TODO_LOCATIONS) {
1615
1827
  const full = join(rootPath, rel);
@@ -1622,11 +1834,6 @@ function findProjectTodo(rootPath) {
1622
1834
  }
1623
1835
  return null;
1624
1836
  }
1625
- /**
1626
- * Strip any existing `## Continue` section (up to but not including the
1627
- * first `---` separator or next `##` heading that follows it).
1628
- * Returns the content with that section removed.
1629
- */
1630
1837
  function stripContinueSection(content) {
1631
1838
  const lines = content.split("\n");
1632
1839
  const startIdx = lines.findIndex((l) => l.trim() === "## Continue");
@@ -1646,18 +1853,10 @@ function stripContinueSection(content) {
1646
1853
  while (after.length > 0 && after[0].trim() === "") after.shift();
1647
1854
  return [...before, ...after].join("\n");
1648
1855
  }
1649
- /**
1650
- * Write (or overwrite) the `## Continue` section at the TOP of the TODO file.
1651
- *
1652
- * pai session handover [project-slug] [session-id|"latest"]
1653
- *
1654
- * Called from hooks (session-stop, pre-compact) with project-slug + "latest".
1655
- * Falls back to auto-detecting the project from cwd when no slug is supplied.
1656
- */
1657
1856
  function cmdHandover(db, projectSlug, numberOrLatest) {
1658
1857
  let project;
1659
1858
  if (projectSlug) {
1660
- project = getProject$1(db, projectSlug);
1859
+ project = db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(projectSlug);
1661
1860
  if (!project) process.exit(0);
1662
1861
  } else {
1663
1862
  const cwd = process.cwd();
@@ -1710,143 +1909,25 @@ function cmdHandover(db, projectSlug, numberOrLatest) {
1710
1909
  "",
1711
1910
  "---",
1712
1911
  ""
1713
- ].join("\n") + stripContinueSection(existingContent).trimStart();
1714
- const tmpPath = `${todoPath}.handover.tmp`;
1715
- try {
1716
- writeFileSync(tmpPath, newContent, "utf8");
1717
- renameSync(tmpPath, todoPath);
1718
- } catch {
1719
- try {
1720
- if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
1721
- } catch {}
1722
- process.exit(0);
1723
- }
1724
- process.exit(0);
1725
- }
1726
- function cmdActive(db, opts) {
1727
- const minutes = parseInt(opts.minutes ?? "60", 10);
1728
- const cutoff = Date.now() - minutes * 60 * 1e3;
1729
- const claudeProjectsDir = join(homedir(), ".claude", "projects");
1730
- if (!existsSync(claudeProjectsDir)) {
1731
- console.log(err("Claude projects directory not found."));
1732
- return;
1733
- }
1734
- const active = [];
1735
- const entries = readdirSync(claudeProjectsDir);
1736
- for (const entry of entries) {
1737
- const projectDir = join(claudeProjectsDir, entry);
1738
- try {
1739
- if (!statSync(projectDir).isDirectory()) continue;
1740
- } catch {
1741
- continue;
1742
- }
1743
- let latestJsonl = null;
1744
- let latestMtime = 0;
1745
- try {
1746
- for (const file of readdirSync(projectDir)) {
1747
- if (!file.endsWith(".jsonl")) continue;
1748
- const filePath = join(projectDir, file);
1749
- try {
1750
- const mtime = statSync(filePath).mtimeMs;
1751
- if (mtime > latestMtime) {
1752
- latestMtime = mtime;
1753
- latestJsonl = filePath;
1754
- }
1755
- } catch {
1756
- continue;
1757
- }
1758
- }
1759
- } catch {
1760
- continue;
1761
- }
1762
- if (!latestJsonl || latestMtime < cutoff) continue;
1763
- const project = db.prepare("SELECT slug, display_name, root_path FROM projects WHERE encoded_dir = ?").get(entry);
1764
- active.push({
1765
- slug: project?.slug ?? entry,
1766
- displayName: project?.display_name ?? project?.slug ?? entry,
1767
- rootPath: project?.root_path ?? "",
1768
- encodedDir: entry,
1769
- lastModified: new Date(latestMtime),
1770
- jsonlFile: latestJsonl
1771
- });
1772
- }
1773
- active.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
1774
- const seen = /* @__PURE__ */ new Set();
1775
- const deduped = active.filter((a) => {
1776
- const key = a.slug.replace(/-\d+$/, "");
1777
- if (seen.has(key)) return false;
1778
- seen.add(key);
1779
- return true;
1780
- });
1781
- if (opts.json) {
1782
- console.log(JSON.stringify(deduped.map((a) => ({
1783
- slug: a.slug,
1784
- display_name: a.displayName,
1785
- root_path: a.rootPath,
1786
- last_modified: a.lastModified.toISOString()
1787
- })), null, 2));
1788
- return;
1789
- }
1790
- if (deduped.length === 0) {
1791
- console.log(dim(`No active sessions in the last ${minutes} minutes.`));
1792
- return;
1793
- }
1794
- console.log(header(`Currently Active Sessions`) + dim(` (modified in last ${minutes}min)`));
1795
- console.log();
1796
- const rows = deduped.map((a) => {
1797
- const time = a.lastModified.toTimeString().slice(0, 5);
1798
- const dirName = a.rootPath ? a.rootPath.replace(homedir(), "~").split("/").pop() ?? a.slug : a.slug;
1799
- return [
1800
- chalk.cyan(dirName),
1801
- dim(a.slug),
1802
- chalk.green(time)
1803
- ];
1804
- });
1805
- console.log(renderTable([
1806
- "Directory",
1807
- "Project",
1808
- "Last Active"
1809
- ], rows));
1810
- }
1811
- async function cmdAutoRoute(opts) {
1812
- const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("../auto-route-BG6I_4B1.mjs");
1813
- const { openRegistry } = await import("../db-4lSqLFb8.mjs").then((n) => n.t);
1814
- const { createStorageBackend } = await import("../factory-Bzcy70G9.mjs").then((n) => n.n);
1815
- const { loadConfig } = await import("../config-Cf92lGX_.mjs").then((n) => n.r);
1816
- const config = loadConfig();
1817
- const registryDb = openRegistry();
1818
- const federation = await createStorageBackend(config);
1819
- const targetCwd = opts.cwd ?? process.cwd();
1820
- const result = await autoRoute(registryDb, federation, targetCwd, opts.context);
1821
- if (!result) {
1822
- console.log();
1823
- console.log(warn(" No project match found for: " + targetCwd));
1824
- console.log();
1825
- console.log(dim(" Tried: path match, PAI.md marker walk") + (opts.context ? dim(", topic detection") : ""));
1826
- console.log();
1827
- console.log(dim(" Run 'pai project add .' to register this directory."));
1828
- console.log();
1829
- return;
1830
- }
1831
- if (opts.json) {
1832
- console.log(formatAutoRouteJson(result));
1833
- return;
1912
+ ].join("\n") + stripContinueSection(existingContent).trimStart();
1913
+ const tmpPath = `${todoPath}.handover.tmp`;
1914
+ try {
1915
+ writeFileSync(tmpPath, newContent, "utf8");
1916
+ renameSync(tmpPath, todoPath);
1917
+ } catch {
1918
+ try {
1919
+ if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
1920
+ } catch {}
1921
+ process.exit(0);
1834
1922
  }
1835
- console.log();
1836
- console.log(header(" PAI Auto-Route"));
1837
- console.log();
1838
- console.log(` ${bold("Project:")} ${result.display_name}`);
1839
- console.log(` ${bold("Slug:")} ${result.slug}`);
1840
- console.log(` ${bold("Root path:")} ${result.root_path}`);
1841
- console.log(` ${bold("Method:")} ${result.method}`);
1842
- console.log(` ${bold("Confidence:")} ${(result.confidence * 100).toFixed(0)}%`);
1843
- console.log();
1844
- console.log(ok(" Routed to: ") + bold(result.slug));
1845
- console.log();
1923
+ process.exit(0);
1846
1924
  }
1925
+
1926
+ //#endregion
1927
+ //#region src/cli/commands/session/index.ts
1847
1928
  function registerSessionCommands(sessionCmd, getDb) {
1848
1929
  sessionCmd.command("list [project-slug]").description("List sessions, optionally filtered to a single project").option("--limit <n>", "Maximum number of sessions to show", "20").option("--status <status>", "Filter by status: open | completed | compacted").action((projectSlug, opts) => {
1849
- cmdList(getDb(), projectSlug, opts);
1930
+ cmdList$1(getDb(), projectSlug, opts);
1850
1931
  });
1851
1932
  sessionCmd.command("info <project-slug> <number>").description("Show full details for a specific session").action((projectSlug, number) => {
1852
1933
  cmdInfo(getDb(), projectSlug, number);
@@ -1878,7 +1959,7 @@ function registerSessionCommands(sessionCmd, getDb) {
1878
1959
  }
1879
1960
 
1880
1961
  //#endregion
1881
- //#region src/cli/commands/session-cleanup.ts
1962
+ //#region src/cli/commands/session-cleanup/types.ts
1882
1963
  const TEMPLATE_INDICATORS = [
1883
1964
  "<!-- PAI will add completed work here during session -->",
1884
1965
  "<!-- PAI will add completed work here -->",
@@ -1887,82 +1968,13 @@ const TEMPLATE_INDICATORS = [
1887
1968
  ];
1888
1969
  const MODERN_PATTERN = /^(\d{4}) - (\d{4}-\d{2}-\d{2}) - (.+)\.md$/;
1889
1970
  const LEGACY_PATTERN = /^(\d{4})_(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
1890
- function getAllProjects(db) {
1891
- return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE status = 'active' ORDER BY slug").all();
1892
- }
1893
- function getProject(db, slug) {
1894
- return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE slug = ?").get(slug);
1895
- }
1896
- function getProjectSessions(db, projectId) {
1897
- return db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number ASC").all(projectId);
1898
- }
1899
- /**
1900
- * Find the project-root Notes directory (e.g. {root}/Notes or {root}/.claude/Notes).
1901
- * Returns null if neither exists on disk.
1902
- */
1903
- function findRootNotesDir(rootPath) {
1904
- const canonical = join(rootPath, "Notes");
1905
- if (existsSync(canonical)) return canonical;
1906
- const alt = join(rootPath, ".claude", "Notes");
1907
- if (existsSync(alt)) return alt;
1908
- return null;
1909
- }
1910
- /**
1911
- * Find the Claude Code session notes directory for a project.
1912
- * Falls back to computing the path from encoded_dir if claude_notes_dir is not set.
1913
- * Returns null if the directory does not exist on disk, or if it is identical to
1914
- * rootNotesDir (to avoid processing the same directory twice).
1915
- */
1916
- function findClaudeNotesDir$1(project, rootNotesDir) {
1917
- const candidate = project.claude_notes_dir ?? join(homedir(), ".claude", "projects", project.encoded_dir, "Notes");
1918
- if (!existsSync(candidate)) return null;
1919
- if (rootNotesDir && candidate === rootNotesDir) return null;
1920
- return candidate;
1921
- }
1922
- /**
1923
- * Collect up to two distinct Notes/ directories for a project.
1924
- * Returns an array of existing, distinct paths in the order:
1925
- * 1. Root Notes/ (from project root_path)
1926
- * 2. Claude Code Notes/ (from claude_notes_dir or encoded_dir)
1927
- */
1928
- function findAllNotesDirs(project) {
1929
- const rootDir = findRootNotesDir(project.root_path);
1930
- const claudeDir = findClaudeNotesDir$1(project, rootDir);
1931
- const dirs = [];
1932
- if (rootDir) dirs.push(rootDir);
1933
- if (claudeDir) dirs.push(claudeDir);
1934
- return dirs;
1935
- }
1971
+
1972
+ //#endregion
1973
+ //#region src/cli/commands/session-cleanup/rename.ts
1936
1974
  /**
1937
- * Determine if a file's content is essentially empty (just the template).
1938
- *
1939
- * A file is template-only if:
1940
- * - It contains a template placeholder marker AND
1941
- * - The "Work Done" section has no real content after the placeholder
1942
- * (i.e., no lines with actual text beyond the placeholder comment itself)
1975
+ * Auto-name extraction and string helpers for session-cleanup.
1976
+ * Derives a meaningful session title from Markdown content.
1943
1977
  */
1944
- function isTemplateOnly(content) {
1945
- if (!TEMPLATE_INDICATORS.some((ind) => content.includes(ind))) return false;
1946
- const lines = content.split("\n");
1947
- let inWorkDone = false;
1948
- for (const line of lines) {
1949
- const trimmed = line.trim();
1950
- if (trimmed === "## Work Done") {
1951
- inWorkDone = true;
1952
- continue;
1953
- }
1954
- if (trimmed.startsWith("## ") && inWorkDone) break;
1955
- if (!inWorkDone) continue;
1956
- if (!trimmed) continue;
1957
- if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) continue;
1958
- if (trimmed.startsWith("<!--")) continue;
1959
- if (trimmed === "-->") continue;
1960
- if (trimmed === "Session completed.") continue;
1961
- if (trimmed === "#Session" || trimmed === "**Tags:** #Session") continue;
1962
- return false;
1963
- }
1964
- return true;
1965
- }
1966
1978
  const META_PHRASE_PATTERNS = [
1967
1979
  /session initialized and ready for your instructions/i,
1968
1980
  /fresh session with no pending tasks/i,
@@ -1996,10 +2008,6 @@ const TITLE_CASE_MINOR_WORDS = new Set([
1996
2008
  "as",
1997
2009
  "nor"
1998
2010
  ]);
1999
- /**
2000
- * Strip markdown checkbox syntax, bullets, and inline formatting from a line.
2001
- * Returns the cleaned plain text, or null if the result is too short to be useful.
2002
- */
2003
2011
  function cleanMarkdownLine(raw) {
2004
2012
  let s = raw.trim();
2005
2013
  s = s.replace(/^[-*+]\s+\[[ xX]\]\s*/, "");
@@ -2013,17 +2021,9 @@ function cleanMarkdownLine(raw) {
2013
2021
  s = s.replace(/\s+/g, " ").trim();
2014
2022
  return s.length >= 4 ? s : null;
2015
2023
  }
2016
- /**
2017
- * Return true if the line contains only meta-status text that should not
2018
- * be used as a session title.
2019
- */
2020
2024
  function isMetaPhrase(text) {
2021
2025
  return META_PHRASE_PATTERNS.some((re) => re.test(text));
2022
2026
  }
2023
- /**
2024
- * Convert a string to Title Case, skipping minor words (articles, prepositions)
2025
- * except as the very first word.
2026
- */
2027
2027
  function toTitleCase(text) {
2028
2028
  return text.split(" ").map((word, i) => {
2029
2029
  const lower = word.toLowerCase();
@@ -2031,11 +2031,6 @@ function toTitleCase(text) {
2031
2031
  return word.charAt(0).toUpperCase() + word.slice(1);
2032
2032
  }).join(" ");
2033
2033
  }
2034
- /**
2035
- * Sanitize a string into a valid filename component.
2036
- * Strips chars that don't belong in filenames, collapses spaces, trims to
2037
- * 60 chars at a word boundary, then applies Title Case.
2038
- */
2039
2034
  function sanitizeName(raw) {
2040
2035
  let s = raw.replace(/[\/\\:*?"<>|#`]/g, "");
2041
2036
  s = s.replace(/\s+/g, " ").trim();
@@ -2047,16 +2042,6 @@ function sanitizeName(raw) {
2047
2042
  s = s.trim();
2048
2043
  return toTitleCase(s);
2049
2044
  }
2050
- /**
2051
- * Extract a meaningful auto-name from session content.
2052
- *
2053
- * Strategy (in priority order):
2054
- * 1. H2 content sections (## Work Done, ## Summary, etc.):
2055
- * look at the CONTENT under them for the first real work bullet.
2056
- * 2. Other descriptive H2 headings that aren't structural section names.
2057
- * 3. H1 heading — only if it is not a plain session-number line.
2058
- * 5. Fallback: "Unnamed Session".
2059
- */
2060
2045
  function extractAutoName(content) {
2061
2046
  const lines = content.split("\n");
2062
2047
  const CONTENT_SECTION_HEADINGS = new Set([
@@ -2140,28 +2125,65 @@ function extractAutoName(content) {
2140
2125
  }
2141
2126
  return "Unnamed Session";
2142
2127
  }
2143
- /**
2144
- * Format a 4-digit padded session number.
2145
- */
2128
+ /** Format a 4-digit padded session number. */
2146
2129
  function padNum(n) {
2147
2130
  return String(n).padStart(4, "0");
2148
2131
  }
2149
- /**
2150
- * Given the sessions still alive after deletion, build a renumber map.
2151
- * Returns a Map of oldNumber → newNumber.
2152
- */
2153
- function buildRenumberMap(survivors) {
2154
- const map = /* @__PURE__ */ new Map();
2155
- [...survivors].sort((a, b) => a.number - b.number).forEach((s, idx) => {
2156
- const newNum = idx + 1;
2157
- if (s.number !== newNum) map.set(s.number, newNum);
2158
- });
2159
- return map;
2132
+
2133
+ //#endregion
2134
+ //#region src/cli/commands/session-cleanup/scanner.ts
2135
+ function getAllProjects(db) {
2136
+ return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE status = 'active' ORDER BY slug").all();
2137
+ }
2138
+ function getProject(db, slug) {
2139
+ return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE slug = ?").get(slug);
2140
+ }
2141
+ function getProjectSessions(db, projectId) {
2142
+ return db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number ASC").all(projectId);
2143
+ }
2144
+ function findRootNotesDir(rootPath) {
2145
+ const canonical = join(rootPath, "Notes");
2146
+ if (existsSync(canonical)) return canonical;
2147
+ const alt = join(rootPath, ".claude", "Notes");
2148
+ if (existsSync(alt)) return alt;
2149
+ return null;
2150
+ }
2151
+ function findClaudeNotesDir$1(project, rootNotesDir) {
2152
+ const candidate = project.claude_notes_dir ?? join(homedir(), ".claude", "projects", project.encoded_dir, "Notes");
2153
+ if (!existsSync(candidate)) return null;
2154
+ if (rootNotesDir && candidate === rootNotesDir) return null;
2155
+ return candidate;
2156
+ }
2157
+ function findAllNotesDirs(project) {
2158
+ const rootDir = findRootNotesDir(project.root_path);
2159
+ const claudeDir = findClaudeNotesDir$1(project, rootDir);
2160
+ const dirs = [];
2161
+ if (rootDir) dirs.push(rootDir);
2162
+ if (claudeDir) dirs.push(claudeDir);
2163
+ return dirs;
2164
+ }
2165
+ function isTemplateOnly(content) {
2166
+ if (!TEMPLATE_INDICATORS.some((ind) => content.includes(ind))) return false;
2167
+ const lines = content.split("\n");
2168
+ let inWorkDone = false;
2169
+ for (const line of lines) {
2170
+ const trimmed = line.trim();
2171
+ if (trimmed === "## Work Done") {
2172
+ inWorkDone = true;
2173
+ continue;
2174
+ }
2175
+ if (trimmed.startsWith("## ") && inWorkDone) break;
2176
+ if (!inWorkDone) continue;
2177
+ if (!trimmed) continue;
2178
+ if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) continue;
2179
+ if (trimmed.startsWith("<!--")) continue;
2180
+ if (trimmed === "-->") continue;
2181
+ if (trimmed === "Session completed.") continue;
2182
+ if (trimmed === "#Session" || trimmed === "**Tags:** #Session") continue;
2183
+ return false;
2184
+ }
2185
+ return true;
2160
2186
  }
2161
- /**
2162
- * Scan a single Notes/ directory and return all session candidates found in it.
2163
- * Looks in both the flat top-level and any YYYY/MM/ sub-directories.
2164
- */
2165
2187
  function scanNotesDir(notesDir, dbByFilename) {
2166
2188
  const candidates = [];
2167
2189
  let flatFiles = [];
@@ -2222,48 +2244,117 @@ function scanNotesDir(notesDir, dbByFilename) {
2222
2244
  if (sizeBytes < 400 || isTemplateOnly(content)) classification = "EMPTY";
2223
2245
  else if (namepart === "New Session" || namepart === (process.env.USER ?? "") || namepart === "session-started-and-ready-for-your-instructions") classification = "UNNAMED";
2224
2246
  }
2225
- const candidate = {
2226
- session: dbSession,
2227
- filename,
2228
- filepath,
2229
- sizeBytes,
2230
- classification,
2231
- date,
2232
- number: num
2233
- };
2234
- if (classification === "UNNAMED" || classification === "LEGACY_FORMAT") candidate.autoName = extractAutoName(content);
2235
- candidates.push(candidate);
2236
- }
2237
- return candidates;
2238
- }
2239
- function analyzeProject(db, project) {
2240
- const notesDirPaths = findAllNotesDirs(project);
2241
- if (notesDirPaths.length === 0) return null;
2242
- const dbSessions = getProjectSessions(db, project.id);
2243
- const dbByFilename = /* @__PURE__ */ new Map();
2244
- for (const s of dbSessions) dbByFilename.set(s.filename, s);
2245
- const notesDirPlans = [];
2246
- const allSurvivors = [];
2247
- for (const notesDir of notesDirPaths) {
2248
- const candidates = scanNotesDir(notesDir, dbByFilename);
2249
- if (candidates.length === 0) continue;
2250
- const toDelete = candidates.filter((c) => c.classification === "EMPTY");
2251
- const toRename = candidates.filter((c) => c.classification === "UNNAMED" || c.classification === "LEGACY_FORMAT");
2252
- const survivors = candidates.filter((c) => c.classification !== "EMPTY");
2253
- notesDirPlans.push({
2254
- notesDir,
2255
- toDelete,
2256
- toRename,
2257
- toMove: survivors
2258
- });
2259
- allSurvivors.push(...survivors);
2247
+ const candidate = {
2248
+ session: dbSession,
2249
+ filename,
2250
+ filepath,
2251
+ sizeBytes,
2252
+ classification,
2253
+ date,
2254
+ number: num
2255
+ };
2256
+ if (classification === "UNNAMED" || classification === "LEGACY_FORMAT") candidate.autoName = extractAutoName(content);
2257
+ candidates.push(candidate);
2258
+ }
2259
+ return candidates;
2260
+ }
2261
+ function buildRenumberMap(survivors) {
2262
+ const map = /* @__PURE__ */ new Map();
2263
+ [...survivors].sort((a, b) => a.number - b.number).forEach((s, idx) => {
2264
+ const newNum = idx + 1;
2265
+ if (s.number !== newNum) map.set(s.number, newNum);
2266
+ });
2267
+ return map;
2268
+ }
2269
+ function analyzeProject(db, project) {
2270
+ const notesDirPaths = findAllNotesDirs(project);
2271
+ if (notesDirPaths.length === 0) return null;
2272
+ const dbSessions = getProjectSessions(db, project.id);
2273
+ const dbByFilename = /* @__PURE__ */ new Map();
2274
+ for (const s of dbSessions) dbByFilename.set(s.filename, s);
2275
+ const notesDirPlans = [];
2276
+ const allSurvivors = [];
2277
+ for (const notesDir of notesDirPaths) {
2278
+ const candidates = scanNotesDir(notesDir, dbByFilename);
2279
+ if (candidates.length === 0) continue;
2280
+ const toDelete = candidates.filter((c) => c.classification === "EMPTY");
2281
+ const toRename = candidates.filter((c) => c.classification === "UNNAMED" || c.classification === "LEGACY_FORMAT");
2282
+ const survivors = candidates.filter((c) => c.classification !== "EMPTY");
2283
+ notesDirPlans.push({
2284
+ notesDir,
2285
+ toDelete,
2286
+ toRename,
2287
+ toMove: survivors
2288
+ });
2289
+ allSurvivors.push(...survivors);
2290
+ }
2291
+ if (notesDirPlans.length === 0) return null;
2292
+ return {
2293
+ project,
2294
+ notesDirs: notesDirPlans,
2295
+ renumberMap: buildRenumberMap(allSurvivors)
2296
+ };
2297
+ }
2298
+
2299
+ //#endregion
2300
+ //#region src/cli/commands/session-cleanup/executor.ts
2301
+ async function countVectorDbPaths(oldPaths) {
2302
+ if (oldPaths.length === 0) return 0;
2303
+ try {
2304
+ const { loadConfig } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
2305
+ const { PostgresBackend } = await import("../postgres-CKf-EDtS.mjs");
2306
+ const config = loadConfig();
2307
+ if (config.storageBackend !== "postgres") return 0;
2308
+ const pgBackend = new PostgresBackend(config.postgres ?? {});
2309
+ if (await pgBackend.testConnection()) {
2310
+ await pgBackend.close();
2311
+ return 0;
2312
+ }
2313
+ const pool = pgBackend.pool;
2314
+ const placeholders = oldPaths.map((_, i) => `$${i + 1}`).join(", ");
2315
+ const result = await pool.query(`SELECT COUNT(*)::text AS n FROM pai_files WHERE path IN (${placeholders})`, oldPaths);
2316
+ await pgBackend.close();
2317
+ return parseInt(result.rows[0]?.n ?? "0", 10);
2318
+ } catch {
2319
+ return 0;
2320
+ }
2321
+ }
2322
+ async function updateVectorDbPaths(moves) {
2323
+ if (moves.length === 0) return 0;
2324
+ try {
2325
+ const { loadConfig } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
2326
+ const { PostgresBackend } = await import("../postgres-CKf-EDtS.mjs");
2327
+ const config = loadConfig();
2328
+ if (config.storageBackend !== "postgres") return 0;
2329
+ const pgBackend = new PostgresBackend(config.postgres ?? {});
2330
+ const connErr = await pgBackend.testConnection();
2331
+ if (connErr) {
2332
+ process.stderr.write(`[session-cleanup] Postgres unavailable (${connErr}). Skipping vector DB path update.\n`);
2333
+ await pgBackend.close();
2334
+ return 0;
2335
+ }
2336
+ const client = await pgBackend.pool.connect();
2337
+ let filesUpdated = 0;
2338
+ try {
2339
+ await client.query("BEGIN", []);
2340
+ for (const { oldPath, newPath } of moves) {
2341
+ const r = await client.query("UPDATE pai_files SET path = $1 WHERE path = $2", [newPath, oldPath]);
2342
+ filesUpdated += r.rowCount ?? 0;
2343
+ await client.query("UPDATE pai_chunks SET path = $1 WHERE path = $2", [newPath, oldPath]);
2344
+ }
2345
+ await client.query("COMMIT", []);
2346
+ } catch (e) {
2347
+ await client.query("ROLLBACK", []);
2348
+ throw e;
2349
+ } finally {
2350
+ client.release();
2351
+ }
2352
+ await pgBackend.close();
2353
+ return filesUpdated;
2354
+ } catch (e) {
2355
+ process.stderr.write(`[session-cleanup] Failed to update vector DB paths: ${e}\n`);
2356
+ return -1;
2260
2357
  }
2261
- if (notesDirPlans.length === 0) return null;
2262
- return {
2263
- project,
2264
- notesDirs: notesDirPlans,
2265
- renumberMap: buildRenumberMap(allSurvivors)
2266
- };
2267
2358
  }
2268
2359
  async function displayDryRun(plans) {
2269
2360
  let totalDelete = 0;
@@ -2333,74 +2424,6 @@ async function displayDryRun(plans) {
2333
2424
  console.log(warn(" This is a dry-run. Add --execute to apply changes."));
2334
2425
  console.log();
2335
2426
  }
2336
- /**
2337
- * Count how many files in the vector DB match the given old paths.
2338
- * Used for dry-run reporting. Returns 0 if Postgres is unavailable.
2339
- */
2340
- async function countVectorDbPaths(oldPaths) {
2341
- if (oldPaths.length === 0) return 0;
2342
- try {
2343
- const { loadConfig } = await import("../config-Cf92lGX_.mjs").then((n) => n.r);
2344
- const { PostgresBackend } = await import("../postgres-FXrHDPcE.mjs");
2345
- const config = loadConfig();
2346
- if (config.storageBackend !== "postgres") return 0;
2347
- const pgBackend = new PostgresBackend(config.postgres ?? {});
2348
- if (await pgBackend.testConnection()) {
2349
- await pgBackend.close();
2350
- return 0;
2351
- }
2352
- const pool = pgBackend.pool;
2353
- const placeholders = oldPaths.map((_, i) => `$${i + 1}`).join(", ");
2354
- const result = await pool.query(`SELECT COUNT(*)::text AS n FROM pai_files WHERE path IN (${placeholders})`, oldPaths);
2355
- await pgBackend.close();
2356
- return parseInt(result.rows[0]?.n ?? "0", 10);
2357
- } catch {
2358
- return 0;
2359
- }
2360
- }
2361
- /**
2362
- * Update file paths in pai_files and pai_chunks for all moved session notes.
2363
- * Returns the number of pai_files rows updated, or -1 on error.
2364
- *
2365
- * Both tables store path directly (no FK between them), so both must be updated.
2366
- */
2367
- async function updateVectorDbPaths(moves) {
2368
- if (moves.length === 0) return 0;
2369
- try {
2370
- const { loadConfig } = await import("../config-Cf92lGX_.mjs").then((n) => n.r);
2371
- const { PostgresBackend } = await import("../postgres-FXrHDPcE.mjs");
2372
- const config = loadConfig();
2373
- if (config.storageBackend !== "postgres") return 0;
2374
- const pgBackend = new PostgresBackend(config.postgres ?? {});
2375
- const connErr = await pgBackend.testConnection();
2376
- if (connErr) {
2377
- process.stderr.write(`[session-cleanup] Postgres unavailable (${connErr}). Skipping vector DB path update.\n`);
2378
- await pgBackend.close();
2379
- return 0;
2380
- }
2381
- const client = await pgBackend.pool.connect();
2382
- let filesUpdated = 0;
2383
- try {
2384
- await client.query("BEGIN", []);
2385
- for (const { oldPath, newPath } of moves) {
2386
- const filesResult = await client.query("UPDATE pai_files SET path = $1 WHERE path = $2", [newPath, oldPath]);
2387
- filesUpdated += filesResult.rowCount ?? 0;
2388
- await client.query("UPDATE pai_chunks SET path = $1 WHERE path = $2", [newPath, oldPath]);
2389
- }
2390
- await client.query("COMMIT", []);
2391
- } catch (e) {
2392
- await client.query("ROLLBACK", []);
2393
- throw e;
2394
- } finally {
2395
- client.release();
2396
- }
2397
- await pgBackend.close();
2398
- return filesUpdated;
2399
- } catch (e) {
2400
- process.stderr.write(`[session-cleanup] Failed to update vector DB paths: ${e}\n`);
2401
- return -1;
2402
- }
2403
- }
2404
2427
  async function executeCleanup(db, plans, skipReindex) {
2405
2428
  let deleted = 0;
2406
2429
  let renamed = 0;
@@ -2586,6 +2609,9 @@ async function executeCleanup(db, plans, skipReindex) {
2586
2609
  }
2587
2610
  console.log();
2588
2611
  }
2612
+
2613
+ //#endregion
2614
+ //#region src/cli/commands/session-cleanup/index.ts
2589
2615
  function registerSessionCleanupCommand(sessionCmd, getDb) {
2590
2616
  sessionCmd.command("cleanup [project-slug]").description("Clean up session notes: delete empties, auto-name unnamed, move into YYYY/MM/ hierarchy, renumber").option("--execute", "Actually perform the cleanup (default is dry-run)").option("--no-renumber", "Skip renumbering sessions after deletions").option("--no-reindex", "Skip triggering memory re-index after moves").action(async (projectSlug, opts) => {
2591
2617
  const db = getDb();
@@ -2621,45 +2647,9 @@ function registerSessionCleanupCommand(sessionCmd, getDb) {
2621
2647
  }
2622
2648
 
2623
2649
  //#endregion
2624
- //#region src/cli/commands/registry.ts
2625
- /**
2626
- * Recursively find all .md files in a directory, including YYYY/MM subdirectories
2627
- * created by session cleanup. Returns filenames (basename only).
2628
- */
2629
- function findNoteFiles(dir) {
2630
- const results = [];
2631
- if (!existsSync(dir)) return results;
2632
- for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".md")) results.push(entry.name);
2633
- else if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
2634
- const yearDir = join(dir, entry.name);
2635
- for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) if (monthEntry.isDirectory() && /^\d{2}$/.test(monthEntry.name)) {
2636
- const monthDir = join(yearDir, monthEntry.name);
2637
- for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) if (noteEntry.isFile() && noteEntry.name.endsWith(".md")) results.push(noteEntry.name);
2638
- }
2639
- }
2640
- return results;
2641
- }
2642
- const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
2643
- const PAI_CONFIG_DIR = join(homedir(), ".pai");
2644
- const PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, "config.json");
2645
- function loadConfig() {
2646
- if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };
2647
- try {
2648
- return JSON.parse(readFileSync(PAI_CONFIG_FILE, "utf8"));
2649
- } catch {
2650
- return { scan_dirs: [] };
2651
- }
2652
- }
2653
- function saveConfig(config) {
2654
- mkdirSync(PAI_CONFIG_DIR, { recursive: true });
2655
- writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
2656
- }
2657
- function resolveHome(p) {
2658
- if (p.startsWith("~/")) return join(homedir(), p.slice(2));
2659
- return resolve(p);
2660
- }
2650
+ //#region src/cli/commands/registry/utils.ts
2661
2651
  /**
2662
- * Upsert a project row. Returns { id, isNew }.
2652
+ * Upsert a project row. Returns { id, isNew }.
2663
2653
  *
2664
2654
  * Matching priority:
2665
2655
  * 1. root_path — most reliable; handles slug collisions
@@ -2709,9 +2699,7 @@ function upsertProject(db, slug, rootPath, encodedDir) {
2709
2699
  isNew: true
2710
2700
  };
2711
2701
  }
2712
- /**
2713
- * Upsert a session note. Returns true if newly inserted.
2714
- */
2702
+ /** Upsert a session note. Returns true if newly inserted. */
2715
2703
  function upsertSession(db, projectId, number, date, slug, title, filename) {
2716
2704
  if (db.prepare("SELECT id FROM sessions WHERE project_id = ? AND number = ?").get(projectId, number)) return false;
2717
2705
  const ts = now();
@@ -2720,6 +2708,46 @@ function upsertSession(db, projectId, number, date, slug, title, filename) {
2720
2708
  VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`).run(projectId, number, date, slug, title, filename, ts);
2721
2709
  return true;
2722
2710
  }
2711
+
2712
+ //#endregion
2713
+ //#region src/cli/commands/registry/scan.ts
2714
+ /** Registry scan command: walk ~/.claude/projects/ and populate the registry. */
2715
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
2716
+ const PAI_CONFIG_DIR = join(homedir(), ".pai");
2717
+ const PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, "config.json");
2718
+ function loadScanConfig() {
2719
+ if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };
2720
+ try {
2721
+ return JSON.parse(readFileSync(PAI_CONFIG_FILE, "utf8"));
2722
+ } catch {
2723
+ return { scan_dirs: [] };
2724
+ }
2725
+ }
2726
+ function saveScanConfig(config) {
2727
+ mkdirSync(PAI_CONFIG_DIR, { recursive: true });
2728
+ writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
2729
+ }
2730
+ function resolveHome(p) {
2731
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
2732
+ return resolve(p);
2733
+ }
2734
+ /**
2735
+ * Recursively find all .md files in a directory, including YYYY/MM subdirectories.
2736
+ * Returns filenames (basename only).
2737
+ */
2738
+ function findNoteFiles(dir) {
2739
+ const results = [];
2740
+ if (!existsSync(dir)) return results;
2741
+ for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".md")) results.push(entry.name);
2742
+ else if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
2743
+ const yearDir = join(dir, entry.name);
2744
+ for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) if (monthEntry.isDirectory() && /^\d{2}$/.test(monthEntry.name)) {
2745
+ const monthDir = join(yearDir, monthEntry.name);
2746
+ for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) if (noteEntry.isFile() && noteEntry.name.endsWith(".md")) results.push(noteEntry.name);
2747
+ }
2748
+ }
2749
+ return results;
2750
+ }
2723
2751
  function performScan(db) {
2724
2752
  const result = {
2725
2753
  projectsScanned: 0,
@@ -2751,7 +2779,7 @@ function performScan(db) {
2751
2779
  } catch {}
2752
2780
  const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, "Notes");
2753
2781
  if (existsSync(claudeNotesDir)) {
2754
- if (claudeNotesDir !== join(rootPath, "Notes")) db.prepare("UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?").run(claudeNotesDir, now(), id);
2782
+ if (claudeNotesDir !== join(rootPath, "Notes")) db.prepare("UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?").run(claudeNotesDir, Date.now(), id);
2755
2783
  }
2756
2784
  if (!existsSync(claudeNotesDir)) continue;
2757
2785
  const noteFiles = findNoteFiles(claudeNotesDir);
@@ -2781,7 +2809,7 @@ function performScan(db) {
2781
2809
  }
2782
2810
  }
2783
2811
  }
2784
- const config = loadConfig();
2812
+ const config = loadScanConfig();
2785
2813
  if (config.scan_dirs.length) for (const rawDir of config.scan_dirs) {
2786
2814
  const scanDir = resolveHome(rawDir);
2787
2815
  if (!existsSync(scanDir)) {
@@ -2859,7 +2887,7 @@ function performScan(db) {
2859
2887
  return result;
2860
2888
  }
2861
2889
  function cmdScan(db) {
2862
- const config = loadConfig();
2890
+ const config = loadScanConfig();
2863
2891
  console.log(dim("Scanning ~/.claude/projects/ ..."));
2864
2892
  if (config.scan_dirs.length) console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(", ")}`));
2865
2893
  console.log(dim("Scanning project-root Notes/ directories ..."));
@@ -2880,6 +2908,10 @@ function cmdScan(db) {
2880
2908
  if (result.skipped.length > 10) console.log(dim(` ... and ${result.skipped.length - 10} more`));
2881
2909
  }
2882
2910
  }
2911
+
2912
+ //#endregion
2913
+ //#region src/cli/commands/registry/migrate.ts
2914
+ /** Registry migrate command: import data from ~/.claude/session-registry.json. */
2883
2915
  const SESSION_REGISTRY_PATH = join(homedir(), ".claude", "session-registry.json");
2884
2916
  function cmdMigrate$1(db) {
2885
2917
  if (!existsSync(SESSION_REGISTRY_PATH)) {
@@ -2936,7 +2968,10 @@ function cmdMigrate$1(db) {
2936
2968
  if (errors.length > 5) console.log(dim(` ... and ${errors.length - 5} more`));
2937
2969
  }
2938
2970
  }
2939
- function cmdStats(db) {
2971
+
2972
+ //#endregion
2973
+ //#region src/cli/commands/registry/index.ts
2974
+ function cmdStats$1(db) {
2940
2975
  const totalProjects = db.prepare("SELECT COUNT(*) AS n FROM projects").get().n;
2941
2976
  const activeProjects = db.prepare("SELECT COUNT(*) AS n FROM projects WHERE status = 'active'").get().n;
2942
2977
  const archivedProjects = db.prepare("SELECT COUNT(*) AS n FROM projects WHERE status = 'archived'").get().n;
@@ -2972,10 +3007,6 @@ function cmdRebuild(db) {
2972
3007
  console.log(dim("Registry cleared. Re-scanning ..."));
2973
3008
  cmdScan(db);
2974
3009
  }
2975
- /**
2976
- * Print the project slug whose root_path matches the given filesystem path.
2977
- * Exits 0 on success, 1 if not found. Output is plain (for use in scripts).
2978
- */
2979
3010
  function cmdLookup(db, fsPath) {
2980
3011
  const resolved = resolve(fsPath);
2981
3012
  const row = db.prepare("SELECT slug FROM projects WHERE root_path = ?").get(resolved);
@@ -2983,9 +3014,9 @@ function cmdLookup(db, fsPath) {
2983
3014
  process.stdout.write(row.slug + "\n");
2984
3015
  }
2985
3016
  function registerRegistryCommands(registryCmd, getDb) {
2986
- registryCmd.command("scan").description("Walk ~/.claude/projects/ and configured scan_dirs, upsert all projects").option("--add-dir <path>", "Add a directory to scan_dirs config (immediate children become projects)").option("--remove-dir <path>", "Remove a directory from scan_dirs config").option("--show-dirs", "Show currently configured scan directories").action((opts) => {
3017
+ registryCmd.command("scan").description("Walk ~/.claude/projects/ and configured scan_dirs, upsert all projects").option("--add-dir <path>", "Add a directory to scan_dirs config").option("--remove-dir <path>", "Remove a directory from scan_dirs config").option("--show-dirs", "Show currently configured scan directories").action((opts) => {
2987
3018
  if (opts.showDirs) {
2988
- const config = loadConfig();
3019
+ const config = loadScanConfig();
2989
3020
  if (!config.scan_dirs.length) {
2990
3021
  console.log(dim(" No extra scan directories configured."));
2991
3022
  console.log(dim(" Use --add-dir <path> to add one."));
@@ -2996,7 +3027,7 @@ function registerRegistryCommands(registryCmd, getDb) {
2996
3027
  return;
2997
3028
  }
2998
3029
  if (opts.addDir) {
2999
- const config = loadConfig();
3030
+ const config = loadScanConfig();
3000
3031
  const resolved = resolveHome(opts.addDir);
3001
3032
  if (!existsSync(resolved)) {
3002
3033
  console.error(err(`Directory not found: ${resolved}`));
@@ -3006,18 +3037,18 @@ function registerRegistryCommands(registryCmd, getDb) {
3006
3037
  if (config.scan_dirs.includes(display) || config.scan_dirs.includes(resolved)) console.log(warn(`Already configured: ${display}`));
3007
3038
  else {
3008
3039
  config.scan_dirs.push(display);
3009
- saveConfig(config);
3040
+ saveScanConfig(config);
3010
3041
  console.log(ok(`Added scan directory: ${bold(display)}`));
3011
3042
  }
3012
3043
  }
3013
3044
  if (opts.removeDir) {
3014
- const config = loadConfig();
3045
+ const config = loadScanConfig();
3015
3046
  const resolved = resolveHome(opts.removeDir);
3016
3047
  const display = resolved.startsWith(homedir()) ? "~" + resolved.slice(homedir().length) : resolved;
3017
3048
  const before = config.scan_dirs.length;
3018
3049
  config.scan_dirs = config.scan_dirs.filter((d) => resolveHome(d) !== resolved);
3019
3050
  if (config.scan_dirs.length < before) {
3020
- saveConfig(config);
3051
+ saveScanConfig(config);
3021
3052
  console.log(ok(`Removed scan directory: ${bold(display)}`));
3022
3053
  } else console.log(warn(`Not found in config: ${display}`));
3023
3054
  }
@@ -3027,7 +3058,7 @@ function registerRegistryCommands(registryCmd, getDb) {
3027
3058
  cmdMigrate$1(getDb());
3028
3059
  });
3029
3060
  registryCmd.command("stats").description("Show summary statistics for the registry").action(() => {
3030
- cmdStats(getDb());
3061
+ cmdStats$1(getDb());
3031
3062
  });
3032
3063
  registryCmd.command("rebuild").description("Erase all registry data and rebuild from the filesystem (destructive)").action(() => {
3033
3064
  cmdRebuild(getDb());
@@ -3038,25 +3069,7 @@ function registerRegistryCommands(registryCmd, getDb) {
3038
3069
  }
3039
3070
 
3040
3071
  //#endregion
3041
- //#region src/cli/commands/memory.ts
3042
- /**
3043
- * CLI commands for the PAI memory engine (Phase 2 / Phase 2.5).
3044
- *
3045
- * Commands:
3046
- * pai memory index [project-slug] — index one or all projects
3047
- * pai memory embed [project-slug] — generate embeddings for un-embedded chunks
3048
- * pai memory search <query> — BM25/semantic/hybrid search across federation.db
3049
- * pai memory status [project-slug] — show index stats
3050
- */
3051
- function tierColor(tier) {
3052
- switch (tier) {
3053
- case "evergreen": return chalk.green(tier);
3054
- case "daily": return chalk.yellow(tier);
3055
- case "topic": return chalk.blue(tier);
3056
- case "session": return chalk.dim(tier);
3057
- default: return tier;
3058
- }
3059
- }
3072
+ //#region src/cli/commands/memory/embed.ts
3060
3073
  async function runEmbed(federation, projectId, projectSlug, batchSize = 50) {
3061
3074
  const label = projectSlug ? `project ${projectSlug}` : "all projects";
3062
3075
  console.log(dim(`Generating embeddings for ${label} (this may take a while on first run)...`));
@@ -3066,11 +3079,34 @@ async function runEmbed(federation, projectId, projectSlug, batchSize = 50) {
3066
3079
  process.stdout.write("\r");
3067
3080
  console.log(ok(`Done.`) + ` ${bold(String(chunksEmbedded))} chunks embedded`);
3068
3081
  }
3069
- function registerMemoryCommands(memoryCmd, getDb) {
3082
+ function registerEmbedCommand(memoryCmd, getDb) {
3083
+ memoryCmd.command("embed [project-slug]").description("Generate embeddings for un-embedded chunks (Phase 2.5)").option("--batch-size <n>", "Chunks to embed per batch", "50").action(async (projectSlug, opts) => {
3084
+ const registryDb = getDb();
3085
+ let federation;
3086
+ try {
3087
+ federation = openFederation();
3088
+ } catch (e) {
3089
+ console.error(err(`Failed to open federation database: ${e}`));
3090
+ process.exit(1);
3091
+ }
3092
+ if (projectSlug) {
3093
+ const project = registryDb.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(projectSlug);
3094
+ if (!project) {
3095
+ console.error(err(`Project not found: ${projectSlug}`));
3096
+ process.exit(1);
3097
+ }
3098
+ await runEmbed(federation, project.id, project.slug, parseInt(opts.batchSize ?? "50", 10));
3099
+ } else await runEmbed(federation, void 0, void 0, parseInt(opts.batchSize ?? "50", 10));
3100
+ });
3101
+ }
3102
+
3103
+ //#endregion
3104
+ //#region src/cli/commands/memory/index-cmd.ts
3105
+ function registerIndexCommand(memoryCmd, getDb) {
3070
3106
  memoryCmd.command("index [project-slug]").description("Index memory files for one project or all projects").option("--all", "Index all active projects (default when no slug given)").option("--embed", "Also generate embeddings for newly indexed chunks (Phase 2.5)").option("--direct", "Skip daemon IPC and run index directly (for debugging)").action(async (projectSlug, opts) => {
3071
3107
  const registryDb = getDb();
3072
3108
  if (!opts.direct && !projectSlug) try {
3073
- await new PaiClient(loadConfig$1().socketPath).triggerIndex();
3109
+ await new PaiClient(loadConfig().socketPath).triggerIndex();
3074
3110
  console.log(ok("Index triggered in daemon.") + dim(" Check daemon logs for progress."));
3075
3111
  console.log(dim(" Run `pai daemon logs` to watch progress."));
3076
3112
  return;
@@ -3101,24 +3137,20 @@ function registerMemoryCommands(memoryCmd, getDb) {
3101
3137
  if (opts.embed) await runEmbed(federation);
3102
3138
  }
3103
3139
  });
3104
- memoryCmd.command("embed [project-slug]").description("Generate embeddings for un-embedded chunks (Phase 2.5)").option("--batch-size <n>", "Chunks to embed per batch", "50").action(async (projectSlug, opts) => {
3105
- const registryDb = getDb();
3106
- let federation;
3107
- try {
3108
- federation = openFederation();
3109
- } catch (e) {
3110
- console.error(err(`Failed to open federation database: ${e}`));
3111
- process.exit(1);
3112
- }
3113
- if (projectSlug) {
3114
- const project = registryDb.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(projectSlug);
3115
- if (!project) {
3116
- console.error(err(`Project not found: ${projectSlug}`));
3117
- process.exit(1);
3118
- }
3119
- await runEmbed(federation, project.id, project.slug, parseInt(opts.batchSize ?? "50", 10));
3120
- } else await runEmbed(federation, void 0, void 0, parseInt(opts.batchSize ?? "50", 10));
3121
- });
3140
+ }
3141
+
3142
+ //#endregion
3143
+ //#region src/cli/commands/memory/search.ts
3144
+ function tierColor$1(tier) {
3145
+ switch (tier) {
3146
+ case "evergreen": return chalk.green(tier);
3147
+ case "daily": return chalk.yellow(tier);
3148
+ case "topic": return chalk.blue(tier);
3149
+ case "session": return chalk.dim(tier);
3150
+ default: return tier;
3151
+ }
3152
+ }
3153
+ function registerSearchCommand(memoryCmd, getDb) {
3122
3154
  memoryCmd.command("search <query>").description("Search indexed memory (BM25 keyword, semantic, or hybrid)").option("--project <slug>", "Restrict search to a specific project").option("--source <source>", "Restrict to 'memory' or 'notes'").option("--limit <n>", "Maximum results to return").option("--mode <mode>", "Search mode: keyword (default), semantic, hybrid").option("--no-rerank", "Skip cross-encoder reranking (reranking is on by default)").option("--recency <days>", "Apply recency boost: score halves every N days. 0 = off").action(async (query, opts) => {
3123
3155
  const registryDb = getDb();
3124
3156
  let federation;
@@ -3128,7 +3160,8 @@ function registerMemoryCommands(memoryCmd, getDb) {
3128
3160
  console.error(err(`Failed to open federation database: ${e}`));
3129
3161
  process.exit(1);
3130
3162
  }
3131
- const searchConfig = loadConfig$1().search;
3163
+ const config = loadConfig();
3164
+ const searchConfig = config.search;
3132
3165
  const maxResults = parseInt(opts.limit ?? String(searchConfig.defaultLimit), 10);
3133
3166
  const mode = opts.mode ?? searchConfig.mode;
3134
3167
  if (![
@@ -3154,7 +3187,7 @@ function registerMemoryCommands(memoryCmd, getDb) {
3154
3187
  let results;
3155
3188
  if (mode === "keyword") results = searchMemory(federation, query, searchOpts);
3156
3189
  else if (mode === "semantic" || mode === "hybrid") {
3157
- const backend = await createStorageBackend(loadConfig$1());
3190
+ const backend = await createStorageBackend(config);
3158
3191
  try {
3159
3192
  const { generateEmbedding } = await import("../embeddings-DGRAPAYb.mjs").then((n) => n.i);
3160
3193
  console.log(dim("Generating query embedding..."));
@@ -3205,13 +3238,13 @@ function registerMemoryCommands(memoryCmd, getDb) {
3205
3238
  return;
3206
3239
  }
3207
3240
  if (opts.rerank !== false) {
3208
- const { rerankResults } = await import("../reranker-D7bRAHi6.mjs").then((n) => n.r);
3241
+ const { rerankResults } = await import("../reranker-CMNZcfVx.mjs").then((n) => n.r);
3209
3242
  console.log(dim("Reranking with cross-encoder..."));
3210
3243
  results = await rerankResults(query, results, { topK: maxResults });
3211
3244
  }
3212
3245
  const recencyDays = parseInt(opts.recency ?? String(searchConfig.recencyBoostDays), 10);
3213
3246
  if (recencyDays > 0) {
3214
- const { applyRecencyBoost } = await import("../search-_oHfguA5.mjs").then((n) => n.o);
3247
+ const { applyRecencyBoost } = await import("../search-DC1qhkKn.mjs").then((n) => n.o);
3215
3248
  console.log(dim(`Applying recency boost (half-life: ${recencyDays} days)...`));
3216
3249
  results = applyRecencyBoost(results, recencyDays);
3217
3250
  }
@@ -3221,7 +3254,7 @@ function registerMemoryCommands(memoryCmd, getDb) {
3221
3254
  console.log(`\n ${bold(`Search results for: "${query}"`)}${modeLabel} ${dim(`(${withSlugs.length} found)`)}\n`);
3222
3255
  for (const result of withSlugs) {
3223
3256
  const projectLabel = result.projectSlug ? chalk.cyan(result.projectSlug) : chalk.cyan(String(result.projectId));
3224
- const tierLabel = tierColor(result.tier);
3257
+ const tierLabel = tierColor$1(result.tier);
3225
3258
  const scoreLabel = dim(`score: ${result.score.toFixed(4)}`);
3226
3259
  const locationLabel = dim(`${result.path}:${result.startLine}-${result.endLine}`);
3227
3260
  console.log(` ${projectLabel} ${tierLabel} ${locationLabel} ${scoreLabel}`);
@@ -3230,6 +3263,20 @@ function registerMemoryCommands(memoryCmd, getDb) {
3230
3263
  console.log();
3231
3264
  }
3232
3265
  });
3266
+ }
3267
+
3268
+ //#endregion
3269
+ //#region src/cli/commands/memory/stats.ts
3270
+ function tierColor(tier) {
3271
+ switch (tier) {
3272
+ case "evergreen": return chalk.green(tier);
3273
+ case "daily": return chalk.yellow(tier);
3274
+ case "topic": return chalk.blue(tier);
3275
+ case "session": return chalk.dim(tier);
3276
+ default: return tier;
3277
+ }
3278
+ }
3279
+ function registerStatsCommands(memoryCmd, getDb) {
3233
3280
  memoryCmd.command("status [project-slug]").description("Show memory index statistics").action((projectSlug) => {
3234
3281
  const registryDb = getDb();
3235
3282
  let federation;
@@ -3300,7 +3347,7 @@ function registerMemoryCommands(memoryCmd, getDb) {
3300
3347
  }
3301
3348
  });
3302
3349
  memoryCmd.command("settings [key] [value]").description("View or modify search settings in ~/.config/pai/config.json").action((key, value) => {
3303
- const search = loadConfig$1().search;
3350
+ const search = loadConfig().search;
3304
3351
  if (!key) {
3305
3352
  console.log(`\n ${bold("PAI Memory — Search Settings")}\n`);
3306
3353
  console.log(` ${bold("mode:")} ${search.mode}`);
@@ -3376,6 +3423,15 @@ function registerMemoryCommands(memoryCmd, getDb) {
3376
3423
  });
3377
3424
  }
3378
3425
 
3426
+ //#endregion
3427
+ //#region src/cli/commands/memory/index.ts
3428
+ function registerMemoryCommands(memoryCmd, getDb) {
3429
+ registerIndexCommand(memoryCmd, getDb);
3430
+ registerEmbedCommand(memoryCmd, getDb);
3431
+ registerSearchCommand(memoryCmd, getDb);
3432
+ registerStatsCommands(memoryCmd, getDb);
3433
+ }
3434
+
3379
3435
  //#endregion
3380
3436
  //#region src/cli/commands/mcp.ts
3381
3437
  const CLAUDE_JSON_PATH$1 = join(homedir(), ".claude.json");
@@ -3547,7 +3603,7 @@ function generatePlist(daemonBin) {
3547
3603
  `;
3548
3604
  }
3549
3605
  async function cmdStatus$2() {
3550
- const client = new PaiClient(loadConfig$1().socketPath);
3606
+ const client = new PaiClient(loadConfig().socketPath);
3551
3607
  try {
3552
3608
  const s = await client.status();
3553
3609
  console.log();
@@ -3737,8 +3793,8 @@ function cmdLogs(opts) {
3737
3793
  }
3738
3794
  function registerDaemonCommands(daemonCmd) {
3739
3795
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
3740
- const { serve } = await import("../daemon-D9evGlgR.mjs").then((n) => n.t);
3741
- const { loadConfig: lc, ensureConfigDir } = await import("../config-Cf92lGX_.mjs").then((n) => n.r);
3796
+ const { serve } = await import("../daemon-D3hYb5_C.mjs").then((n) => n.t);
3797
+ const { loadConfig: lc, ensureConfigDir } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
3742
3798
  ensureConfigDir();
3743
3799
  await serve(lc());
3744
3800
  });
@@ -4103,205 +4159,11 @@ function registerRestoreCommands(program) {
4103
4159
  }
4104
4160
 
4105
4161
  //#endregion
4106
- //#region src/cli/commands/settings-manager.ts
4107
- /**
4108
- * settings-manager — merge-not-overwrite utility for ~/.claude/settings.json
4109
- *
4110
- * Provides safe, idempotent writes to Claude Code's settings.json:
4111
- * - env vars: added only if the key is absent (never overwrites)
4112
- * - hooks: appended per hookType, deduplicated by command string
4113
- * - statusLine: written only if the key is not already present
4114
- */
4115
- const CLAUDE_DIR = join(homedir(), ".claude");
4116
- const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
4117
- function readSettingsJson() {
4118
- if (!existsSync(SETTINGS_FILE)) return {};
4119
- try {
4120
- return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
4121
- } catch {
4122
- return {};
4123
- }
4124
- }
4125
- function writeSettingsJson(data) {
4126
- if (!existsSync(CLAUDE_DIR)) mkdirSync(CLAUDE_DIR, { recursive: true });
4127
- writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n", "utf-8");
4128
- }
4129
- /**
4130
- * Merge env vars — add keys that are absent, never overwrite existing ones.
4131
- */
4132
- function mergeEnv(settings, incoming, report) {
4133
- let changed = false;
4134
- const existing = typeof settings["env"] === "object" && settings["env"] !== null ? settings["env"] : {};
4135
- for (const [key, value] of Object.entries(incoming)) if (Object.prototype.hasOwnProperty.call(existing, key)) report.push(chalk.dim(` Skipped: env.${key} already set`));
4136
- else {
4137
- existing[key] = value;
4138
- report.push(chalk.green(` Added env: ${key}`));
4139
- changed = true;
4140
- }
4141
- settings["env"] = existing;
4142
- return changed;
4143
- }
4144
- /**
4145
- * Strip file extension from a command basename for extension-agnostic dedup.
4146
- * This ensures that e.g. "context-compression-hook.ts" and
4147
- * "context-compression-hook.mjs" are treated as the same hook.
4148
- */
4149
- function commandStem(cmd) {
4150
- return (cmd.split("/").pop() ?? cmd).replace(/\.(mjs|ts|js|sh)$/, "");
4151
- }
4152
- /**
4153
- * Collect every command string already registered for a given hookType.
4154
- * Stores full command, basename, AND extension-stripped stem for flexible
4155
- * matching (handles ${PAI_DIR}/Hooks/foo.sh vs /Users/.../Hooks/foo.sh,
4156
- * and .ts → .mjs migrations).
4157
- */
4158
- function existingCommandsForHookType(rules) {
4159
- const cmds = /* @__PURE__ */ new Set();
4160
- for (const rule of rules) for (const entry of rule.hooks) {
4161
- cmds.add(entry.command);
4162
- const base = entry.command.split("/").pop();
4163
- if (base) cmds.add(base);
4164
- cmds.add(commandStem(entry.command));
4165
- }
4166
- return cmds;
4167
- }
4168
- /**
4169
- * Find and remove an existing rule whose command has the same stem
4170
- * (extension-agnostic) as the incoming command. Returns true if a
4171
- * replacement was made. This handles .ts → .mjs migrations cleanly.
4172
- */
4173
- function replaceStaleHook(existingRules, incomingStem, incomingCommand, incomingMatcher) {
4174
- for (let i = 0; i < existingRules.length; i++) {
4175
- const rule = existingRules[i];
4176
- for (let j = 0; j < rule.hooks.length; j++) if (commandStem(rule.hooks[j].command) === incomingStem && rule.hooks[j].command !== incomingCommand) {
4177
- rule.hooks[j].command = incomingCommand;
4178
- if (incomingMatcher !== void 0) rule.matcher = incomingMatcher;
4179
- return true;
4180
- }
4181
- }
4182
- return false;
4183
- }
4184
- /**
4185
- * Merge hooks — append entries, deduplicating by command string.
4186
- * Extension-agnostic: a .mjs hook replaces an existing .ts hook with the
4187
- * same stem, ensuring clean .ts → .mjs migrations without duplicates.
4188
- */
4189
- function mergeHooks(settings, incoming, report) {
4190
- let changed = false;
4191
- const hooksSection = typeof settings["hooks"] === "object" && settings["hooks"] !== null ? settings["hooks"] : {};
4192
- for (const entry of incoming) {
4193
- const { hookType, matcher, command } = entry;
4194
- const existingRules = Array.isArray(hooksSection[hookType]) ? hooksSection[hookType] : [];
4195
- const basename = command.split("/").pop() ?? command;
4196
- const stem = commandStem(command);
4197
- const existingCmds = existingCommandsForHookType(existingRules);
4198
- if (existingCmds.has(command) || existingCmds.has(basename)) {
4199
- report.push(chalk.dim(` Skipped: hook ${hookType} → ${basename} already registered`));
4200
- continue;
4201
- }
4202
- if (existingCmds.has(stem)) {
4203
- if (replaceStaleHook(existingRules, stem, command, matcher)) {
4204
- hooksSection[hookType] = existingRules;
4205
- report.push(chalk.yellow(` Upgraded: hook ${hookType} → ${basename} (replaced stale extension)`));
4206
- changed = true;
4207
- continue;
4208
- }
4209
- }
4210
- const newRule = { hooks: [{
4211
- type: "command",
4212
- command
4213
- }] };
4214
- if (matcher !== void 0) newRule.matcher = matcher;
4215
- existingRules.push(newRule);
4216
- hooksSection[hookType] = existingRules;
4217
- report.push(chalk.green(` Added hook: ${hookType} → ${basename}`));
4218
- changed = true;
4219
- }
4220
- settings["hooks"] = hooksSection;
4221
- return changed;
4222
- }
4223
- /**
4224
- * Merge statusLine — write only if the key is not already present.
4225
- */
4226
- function mergeStatusLine(settings, incoming, report) {
4227
- if (Object.prototype.hasOwnProperty.call(settings, "statusLine")) {
4228
- report.push(chalk.dim(" Skipped: statusLine already configured"));
4229
- return false;
4230
- }
4231
- settings["statusLine"] = { ...incoming };
4232
- report.push(chalk.green(" Added statusLine"));
4233
- return true;
4234
- }
4235
- /**
4236
- * Merge permissions — append allow/deny entries, deduplicating.
4237
- */
4238
- function mergePermissions(settings, incoming, report) {
4239
- let changed = false;
4240
- const perms = typeof settings["permissions"] === "object" && settings["permissions"] !== null ? settings["permissions"] : {};
4241
- for (const list of ["allow", "deny"]) {
4242
- const entries = incoming[list];
4243
- if (!entries || entries.length === 0) continue;
4244
- const existing = Array.isArray(perms[list]) ? perms[list] : [];
4245
- const existingSet = new Set(existing);
4246
- for (const entry of entries) if (existingSet.has(entry)) report.push(chalk.dim(` Skipped: permissions.${list} "${entry}" already present`));
4247
- else {
4248
- existing.push(entry);
4249
- existingSet.add(entry);
4250
- report.push(chalk.green(` Added permissions.${list}: ${entry}`));
4251
- changed = true;
4252
- }
4253
- perms[list] = existing;
4254
- }
4255
- settings["permissions"] = perms;
4256
- return changed;
4257
- }
4258
- /**
4259
- * Merge flags — set keys only if not already present, never overwrite.
4260
- */
4261
- function mergeFlags(settings, incoming, report) {
4262
- let changed = false;
4263
- for (const [key, value] of Object.entries(incoming)) if (Object.prototype.hasOwnProperty.call(settings, key)) report.push(chalk.dim(` Skipped: ${key} already set`));
4264
- else {
4265
- settings[key] = value;
4266
- report.push(chalk.green(` Added flag: ${key}`));
4267
- changed = true;
4268
- }
4269
- return changed;
4270
- }
4162
+ //#region src/cli/commands/setup/utils.ts
4271
4163
  /**
4272
- * Merge env vars, hooks, and/or a statusLine entry into ~/.claude/settings.json.
4273
- * Never overwrites existing values only adds what is missing.
4274
- *
4275
- * Returns { changed, report } where report contains human-readable lines.
4164
+ * Shared helpers for the PAI setup wizard: chalk colour shortcuts,
4165
+ * readline prompts, config read/write, and filesystem path finders.
4276
4166
  */
4277
- function mergeSettings(opts) {
4278
- const settings = readSettingsJson();
4279
- const report = [];
4280
- let changed = false;
4281
- if (opts.env !== void 0 && Object.keys(opts.env).length > 0) {
4282
- if (mergeEnv(settings, opts.env, report)) changed = true;
4283
- }
4284
- if (opts.hooks !== void 0 && opts.hooks.length > 0) {
4285
- if (mergeHooks(settings, opts.hooks, report)) changed = true;
4286
- }
4287
- if (opts.statusLine !== void 0) {
4288
- if (mergeStatusLine(settings, opts.statusLine, report)) changed = true;
4289
- }
4290
- if (opts.permissions !== void 0) {
4291
- if (mergePermissions(settings, opts.permissions, report)) changed = true;
4292
- }
4293
- if (opts.flags !== void 0 && Object.keys(opts.flags).length > 0) {
4294
- if (mergeFlags(settings, opts.flags, report)) changed = true;
4295
- }
4296
- if (changed) writeSettingsJson(settings);
4297
- return {
4298
- changed,
4299
- report
4300
- };
4301
- }
4302
-
4303
- //#endregion
4304
- //#region src/cli/commands/setup.ts
4305
4167
  const c = {
4306
4168
  bold: (s) => chalk.bold(s),
4307
4169
  dim: (s) => chalk.dim(s),
@@ -4342,10 +4204,7 @@ async function prompt(rl, question) {
4342
4204
  });
4343
4205
  });
4344
4206
  }
4345
- /**
4346
- * Prompt for a numbered menu selection.
4347
- * Returns the 0-based index of the selected option, or defaultIdx if empty input.
4348
- */
4207
+ /** Prompt for a numbered menu selection. Returns 0-based index. */
4349
4208
  async function promptMenu(rl, options, defaultIdx = 0) {
4350
4209
  for (let i = 0; i < options.length; i++) {
4351
4210
  const num = chalk.bold(` ${i + 1}.`);
@@ -4363,9 +4222,7 @@ async function promptMenu(rl, options, defaultIdx = 0) {
4363
4222
  console.log(c.warn(`Please enter a number between 1 and ${options.length}.`));
4364
4223
  }
4365
4224
  }
4366
- /**
4367
- * Prompt for a yes/no answer. Returns true for yes.
4368
- */
4225
+ /** Prompt for a yes/no answer. Returns true for yes. */
4369
4226
  async function promptYesNo(rl, question, defaultYes = true) {
4370
4227
  const hint = defaultYes ? "[Y/n]" : "[y/N]";
4371
4228
  const answer = await prompt(rl, ` ${question} ${chalk.dim(hint)}: `);
@@ -4409,16 +4266,27 @@ function getDockerDir() {
4409
4266
  join(homedir(), "dev", "ai", "PAI", "docker"),
4410
4267
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "docker")
4411
4268
  ];
4412
- for (const c of candidates) if (existsSync(join(c, "docker-compose.yml"))) return c;
4269
+ for (const candidate of candidates) if (existsSync(join(candidate, "docker-compose.yml"))) return candidate;
4413
4270
  return join(process.cwd(), "docker");
4414
4271
  }
4272
+ async function testPostgresConnection(connectionString) {
4273
+ try {
4274
+ const pgModule = await import("pg");
4275
+ const client = new (pgModule.default ?? pgModule).Client({ connectionString });
4276
+ await client.connect();
4277
+ await client.end();
4278
+ return true;
4279
+ } catch {
4280
+ return false;
4281
+ }
4282
+ }
4415
4283
  function getTemplatesDir$1() {
4416
4284
  const candidates = [
4417
4285
  join(process.cwd(), "templates"),
4418
4286
  join(homedir(), "dev", "ai", "PAI", "templates"),
4419
4287
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "templates")
4420
4288
  ];
4421
- for (const c of candidates) if (existsSync(join(c, "claude-md.template.md"))) return c;
4289
+ for (const candidate of candidates) if (existsSync(join(candidate, "claude-md.template.md"))) return candidate;
4422
4290
  return join(process.cwd(), "templates");
4423
4291
  }
4424
4292
  function getHooksDir() {
@@ -4427,12 +4295,12 @@ function getHooksDir() {
4427
4295
  join(homedir(), "dev", "ai", "PAI", "src", "hooks"),
4428
4296
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "src", "hooks")
4429
4297
  ];
4430
- for (const c of candidates) if (existsSync(join(c, "session-stop.sh"))) return c;
4298
+ for (const candidate of candidates) if (existsSync(join(candidate, "session-stop.sh"))) return candidate;
4431
4299
  return join(process.cwd(), "src", "hooks");
4432
4300
  }
4433
4301
  function getDistHooksDir() {
4434
4302
  const moduleDir = new URL(".", import.meta.url).pathname;
4435
- const fromModule = join(moduleDir, "..", "hooks");
4303
+ const fromModule = join(moduleDir, "..", "..", "hooks");
4436
4304
  const candidates = [
4437
4305
  fromModule,
4438
4306
  join(process.cwd(), "dist", "hooks"),
@@ -4448,7 +4316,7 @@ function getStatuslineScript() {
4448
4316
  join(homedir(), "dev", "ai", "PAI", "statusline-command.sh"),
4449
4317
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "statusline-command.sh")
4450
4318
  ];
4451
- for (const c of candidates) if (existsSync(c)) return c;
4319
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
4452
4320
  return null;
4453
4321
  }
4454
4322
  function getTabColorScript() {
@@ -4457,9 +4325,30 @@ function getTabColorScript() {
4457
4325
  join(homedir(), "dev", "ai", "PAI", "tab-color-command.sh"),
4458
4326
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "tab-color-command.sh")
4459
4327
  ];
4460
- for (const c of candidates) if (existsSync(c)) return c;
4328
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
4461
4329
  return null;
4462
4330
  }
4331
+
4332
+ //#endregion
4333
+ //#region src/cli/commands/setup/steps/01-welcome.ts
4334
+ /** Step 1: Welcome banner displayed at the start of the setup wizard. */
4335
+ function stepWelcome() {
4336
+ line$1();
4337
+ line$1(chalk.bold.cyan(" ╔════════════════════════════════════════╗"));
4338
+ line$1(chalk.bold.cyan(" ║ PAI Knowledge OS — Setup Wizard ║"));
4339
+ line$1(chalk.bold.cyan(" ╚════════════════════════════════════════╝"));
4340
+ line$1();
4341
+ line$1(" PAI is a personal knowledge system that indexes your files, generates");
4342
+ line$1(" semantic embeddings for intelligent search, and stores everything in a");
4343
+ line$1(" local database so you can search your knowledge base with natural language.");
4344
+ line$1();
4345
+ line$1(c.dim(" This wizard will guide you through the initial configuration."));
4346
+ line$1(c.dim(" Press Ctrl+C at any time to cancel."));
4347
+ }
4348
+
4349
+ //#endregion
4350
+ //#region src/cli/commands/setup/steps/02-storage.ts
4351
+ /** Step 2: Storage backend selection (SQLite or PostgreSQL) and Docker helper. */
4463
4352
  async function startDocker(rl) {
4464
4353
  const dockerDir = getDockerDir();
4465
4354
  if (!existsSync(join(dockerDir, "docker-compose.yml"))) {
@@ -4476,49 +4365,19 @@ async function startDocker(rl) {
4476
4365
  "-d"
4477
4366
  ], {
4478
4367
  cwd: dockerDir,
4479
- stdio: "inherit"
4480
- }).status !== 0) {
4481
- console.log(c.warn(" Docker compose failed. You can start it manually:"));
4482
- console.log(c.dim(` cd ${dockerDir} && docker compose up -d`));
4483
- return false;
4484
- }
4485
- console.log(c.ok("PostgreSQL container started."));
4486
- return true;
4487
- } catch (e) {
4488
- console.log(c.warn(` Could not run docker compose: ${e}`));
4489
- return false;
4490
- }
4491
- }
4492
- async function testPostgresConnection(connectionString) {
4493
- try {
4494
- const pgModule = await import("pg");
4495
- const client = new (pgModule.default ?? pgModule).Client({ connectionString });
4496
- await client.connect();
4497
- await client.end();
4368
+ stdio: "inherit"
4369
+ }).status !== 0) {
4370
+ console.log(c.warn(" Docker compose failed. You can start it manually:"));
4371
+ console.log(c.dim(` cd ${dockerDir} && docker compose up -d`));
4372
+ return false;
4373
+ }
4374
+ console.log(c.ok("PostgreSQL container started."));
4498
4375
  return true;
4499
- } catch {
4376
+ } catch (e) {
4377
+ console.log(c.warn(` Could not run docker compose: ${e}`));
4500
4378
  return false;
4501
4379
  }
4502
4380
  }
4503
- /**
4504
- * Step 1: Welcome banner and overview
4505
- */
4506
- function stepWelcome() {
4507
- line$1();
4508
- line$1(chalk.bold.cyan(" ╔════════════════════════════════════════╗"));
4509
- line$1(chalk.bold.cyan(" ║ PAI Knowledge OS — Setup Wizard ║"));
4510
- line$1(chalk.bold.cyan(" ╚════════════════════════════════════════╝"));
4511
- line$1();
4512
- line$1(" PAI is a personal knowledge system that indexes your files, generates");
4513
- line$1(" semantic embeddings for intelligent search, and stores everything in a");
4514
- line$1(" local database so you can search your knowledge base with natural language.");
4515
- line$1();
4516
- line$1(c.dim(" This wizard will guide you through the initial configuration."));
4517
- line$1(c.dim(" Press Ctrl+C at any time to cancel."));
4518
- }
4519
- /**
4520
- * Step 2: Storage backend selection
4521
- */
4522
4381
  async function stepStorage(rl) {
4523
4382
  section("Step 2: Storage Backend");
4524
4383
  const existing = readConfigRaw();
@@ -4578,20 +4437,15 @@ async function stepStorage(rl) {
4578
4437
  await new Promise((r) => setTimeout(r, 3e3));
4579
4438
  const connStr = "postgresql://pai:pai@localhost:5432/pai";
4580
4439
  console.log(c.dim(` Testing connection to ${connStr}...`));
4581
- if (await testPostgresConnection(connStr)) {
4582
- console.log(c.ok("Connection successful!"));
4583
- return {
4584
- storageBackend: "postgres",
4585
- postgres: { connectionString: connStr }
4586
- };
4587
- } else {
4440
+ if (await testPostgresConnection(connStr)) console.log(c.ok("Connection successful!"));
4441
+ else {
4588
4442
  console.log(c.warn("Connection test failed. The container may still be starting."));
4589
4443
  console.log(c.dim(" Using default connection string — you can verify with `pai daemon status`."));
4590
- return {
4591
- storageBackend: "postgres",
4592
- postgres: { connectionString: connStr }
4593
- };
4594
4444
  }
4445
+ return {
4446
+ storageBackend: "postgres",
4447
+ postgres: { connectionString: connStr }
4448
+ };
4595
4449
  }
4596
4450
  } else console.log(c.dim(" Docker not found. Using manual connection string entry."));
4597
4451
  line$1();
@@ -4622,9 +4476,10 @@ async function stepStorage(rl) {
4622
4476
  postgres: { connectionString: connStr }
4623
4477
  };
4624
4478
  }
4625
- /**
4626
- * Step 3: Embedding model selection
4627
- */
4479
+
4480
+ //#endregion
4481
+ //#region src/cli/commands/setup/steps/03-embedding.ts
4482
+ /** Step 3: Embedding model selection for semantic search. */
4628
4483
  async function stepEmbedding(rl) {
4629
4484
  section("Step 3: Embedding Model");
4630
4485
  const existing = readConfigRaw();
@@ -4670,9 +4525,10 @@ async function stepEmbedding(rl) {
4670
4525
  }
4671
4526
  return { embeddingModel: selectedModel ?? "none" };
4672
4527
  }
4673
- /**
4674
- * Step 4: Agent configuration — generate ~/.claude/CLAUDE.md from template
4675
- */
4528
+
4529
+ //#endregion
4530
+ //#region src/cli/commands/setup/steps/04-claude-md.ts
4531
+ /** Step 4: CLAUDE.md generation from PAI template. */
4676
4532
  async function stepClaudeMd(rl) {
4677
4533
  section("Step 4: Agent Configuration (CLAUDE.md)");
4678
4534
  line$1();
@@ -4733,9 +4589,10 @@ async function stepClaudeMd(rl) {
4733
4589
  } else console.log(c.dim(" Personal preferences: " + agentPrefs));
4734
4590
  return true;
4735
4591
  }
4736
- /**
4737
- * Step 5: PAI skill installation (~/.claude/skills/PAI/SKILL.md)
4738
- */
4592
+
4593
+ //#endregion
4594
+ //#region src/cli/commands/setup/steps/05-pai-skill.ts
4595
+ /** Step 5: PAI SKILL.md installation to ~/.claude/skills/PAI/. */
4739
4596
  async function stepPaiSkill(rl) {
4740
4597
  section("Step 5: PAI Skill Installation");
4741
4598
  line$1();
@@ -4779,9 +4636,10 @@ async function stepPaiSkill(rl) {
4779
4636
  console.log(c.ok("Installed ~/.claude/skills/PAI/SKILL.md"));
4780
4637
  return true;
4781
4638
  }
4782
- /**
4783
- * Step 6: AI Steering Rules installation (~/.claude/skills/PAI/AI-STEERING-RULES.md)
4784
- */
4639
+
4640
+ //#endregion
4641
+ //#region src/cli/commands/setup/steps/06-steering-rules.ts
4642
+ /** Step 6: AI Steering Rules installation to ~/.claude/skills/PAI/. */
4785
4643
  async function stepAiSteeringRules(rl) {
4786
4644
  section("Step 6: AI Steering Rules");
4787
4645
  line$1();
@@ -4826,9 +4684,10 @@ async function stepAiSteeringRules(rl) {
4826
4684
  console.log(c.ok("Installed ~/.claude/skills/PAI/AI-STEERING-RULES.md"));
4827
4685
  return true;
4828
4686
  }
4829
- /**
4830
- * Step 7: Hook scripts (pre-compact, session-stop, statusline)
4831
- */
4687
+
4688
+ //#endregion
4689
+ //#region src/cli/commands/setup/steps/08-hooks.ts
4690
+ /** Step 7: Shell lifecycle hooks installation (pre-compact, session-stop, statusline). */
4832
4691
  async function stepHooks(rl) {
4833
4692
  section("Step 7: Lifecycle Hooks");
4834
4693
  line$1();
@@ -4872,13 +4731,10 @@ async function stepHooks(rl) {
4872
4731
  else console.log(c.warn(" tab-color-command.sh not found — skipping tab color."));
4873
4732
  return anyInstalled;
4874
4733
  }
4875
- /**
4876
- * Step 7b: Install compiled TypeScript hooks (.mjs) to ~/.claude/Hooks/
4877
- *
4878
- * Finds all .mjs files in the package's dist/hooks/ directory and copies them
4879
- * to ~/.claude/Hooks/. Content is compared before copying — identical files are
4880
- * skipped for idempotent re-runs. Each installed file gets chmod 755.
4881
- */
4734
+
4735
+ //#endregion
4736
+ //#region src/cli/commands/setup/steps/09-ts-hooks.ts
4737
+ /** Step 7b: TypeScript (.mjs) hooks installation to ~/.claude/Hooks/. */
4882
4738
  async function stepTsHooks(rl) {
4883
4739
  section("Step 7b: TypeScript Hooks Installation");
4884
4740
  line$1();
@@ -4940,22 +4796,218 @@ async function stepTsHooks(rl) {
4940
4796
  cleanedCount++;
4941
4797
  }
4942
4798
  }
4943
- line$1();
4944
- if (copiedCount > 0 || cleanedCount > 0) {
4945
- const parts = [];
4946
- if (copiedCount > 0) parts.push(`${copiedCount} hook(s) installed`);
4947
- if (skippedCount > 0) parts.push(`${skippedCount} unchanged`);
4948
- if (cleanedCount > 0) parts.push(`${cleanedCount} stale .ts file(s) cleaned up`);
4949
- console.log(c.ok(parts.join(", ") + "."));
4950
- } else console.log(c.dim(` All ${skippedCount} hook(s) already up-to-date.`));
4951
- return copiedCount > 0 || cleanedCount > 0;
4799
+ line$1();
4800
+ if (copiedCount > 0 || cleanedCount > 0) {
4801
+ const parts = [];
4802
+ if (copiedCount > 0) parts.push(`${copiedCount} hook(s) installed`);
4803
+ if (skippedCount > 0) parts.push(`${skippedCount} unchanged`);
4804
+ if (cleanedCount > 0) parts.push(`${cleanedCount} stale .ts file(s) cleaned up`);
4805
+ console.log(c.ok(parts.join(", ") + "."));
4806
+ } else console.log(c.dim(` All ${skippedCount} hook(s) already up-to-date.`));
4807
+ return copiedCount > 0 || cleanedCount > 0;
4808
+ }
4809
+
4810
+ //#endregion
4811
+ //#region src/cli/commands/settings-manager.ts
4812
+ /**
4813
+ * settings-manager — merge-not-overwrite utility for ~/.claude/settings.json
4814
+ *
4815
+ * Provides safe, idempotent writes to Claude Code's settings.json:
4816
+ * - env vars: added only if the key is absent (never overwrites)
4817
+ * - hooks: appended per hookType, deduplicated by command string
4818
+ * - statusLine: written only if the key is not already present
4819
+ */
4820
+ const CLAUDE_DIR = join(homedir(), ".claude");
4821
+ const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
4822
+ function readSettingsJson() {
4823
+ if (!existsSync(SETTINGS_FILE)) return {};
4824
+ try {
4825
+ return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
4826
+ } catch {
4827
+ return {};
4828
+ }
4829
+ }
4830
+ function writeSettingsJson(data) {
4831
+ if (!existsSync(CLAUDE_DIR)) mkdirSync(CLAUDE_DIR, { recursive: true });
4832
+ writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n", "utf-8");
4833
+ }
4834
+ /**
4835
+ * Merge env vars — add keys that are absent, never overwrite existing ones.
4836
+ */
4837
+ function mergeEnv(settings, incoming, report) {
4838
+ let changed = false;
4839
+ const existing = typeof settings["env"] === "object" && settings["env"] !== null ? settings["env"] : {};
4840
+ for (const [key, value] of Object.entries(incoming)) if (Object.prototype.hasOwnProperty.call(existing, key)) report.push(chalk.dim(` Skipped: env.${key} already set`));
4841
+ else {
4842
+ existing[key] = value;
4843
+ report.push(chalk.green(` Added env: ${key}`));
4844
+ changed = true;
4845
+ }
4846
+ settings["env"] = existing;
4847
+ return changed;
4848
+ }
4849
+ /**
4850
+ * Strip file extension from a command basename for extension-agnostic dedup.
4851
+ * This ensures that e.g. "context-compression-hook.ts" and
4852
+ * "context-compression-hook.mjs" are treated as the same hook.
4853
+ */
4854
+ function commandStem(cmd) {
4855
+ return (cmd.split("/").pop() ?? cmd).replace(/\.(mjs|ts|js|sh)$/, "");
4856
+ }
4857
+ /**
4858
+ * Collect every command string already registered for a given hookType.
4859
+ * Stores full command, basename, AND extension-stripped stem for flexible
4860
+ * matching (handles ${PAI_DIR}/Hooks/foo.sh vs /Users/.../Hooks/foo.sh,
4861
+ * and .ts → .mjs migrations).
4862
+ */
4863
+ function existingCommandsForHookType(rules) {
4864
+ const cmds = /* @__PURE__ */ new Set();
4865
+ for (const rule of rules) for (const entry of rule.hooks) {
4866
+ cmds.add(entry.command);
4867
+ const base = entry.command.split("/").pop();
4868
+ if (base) cmds.add(base);
4869
+ cmds.add(commandStem(entry.command));
4870
+ }
4871
+ return cmds;
4872
+ }
4873
+ /**
4874
+ * Find and remove an existing rule whose command has the same stem
4875
+ * (extension-agnostic) as the incoming command. Returns true if a
4876
+ * replacement was made. This handles .ts → .mjs migrations cleanly.
4877
+ */
4878
+ function replaceStaleHook(existingRules, incomingStem, incomingCommand, incomingMatcher) {
4879
+ for (let i = 0; i < existingRules.length; i++) {
4880
+ const rule = existingRules[i];
4881
+ for (let j = 0; j < rule.hooks.length; j++) if (commandStem(rule.hooks[j].command) === incomingStem && rule.hooks[j].command !== incomingCommand) {
4882
+ rule.hooks[j].command = incomingCommand;
4883
+ if (incomingMatcher !== void 0) rule.matcher = incomingMatcher;
4884
+ return true;
4885
+ }
4886
+ }
4887
+ return false;
4888
+ }
4889
+ /**
4890
+ * Merge hooks — append entries, deduplicating by command string.
4891
+ * Extension-agnostic: a .mjs hook replaces an existing .ts hook with the
4892
+ * same stem, ensuring clean .ts → .mjs migrations without duplicates.
4893
+ */
4894
+ function mergeHooks(settings, incoming, report) {
4895
+ let changed = false;
4896
+ const hooksSection = typeof settings["hooks"] === "object" && settings["hooks"] !== null ? settings["hooks"] : {};
4897
+ for (const entry of incoming) {
4898
+ const { hookType, matcher, command } = entry;
4899
+ const existingRules = Array.isArray(hooksSection[hookType]) ? hooksSection[hookType] : [];
4900
+ const basename = command.split("/").pop() ?? command;
4901
+ const stem = commandStem(command);
4902
+ const existingCmds = existingCommandsForHookType(existingRules);
4903
+ if (existingCmds.has(command) || existingCmds.has(basename)) {
4904
+ report.push(chalk.dim(` Skipped: hook ${hookType} → ${basename} already registered`));
4905
+ continue;
4906
+ }
4907
+ if (existingCmds.has(stem)) {
4908
+ if (replaceStaleHook(existingRules, stem, command, matcher)) {
4909
+ hooksSection[hookType] = existingRules;
4910
+ report.push(chalk.yellow(` Upgraded: hook ${hookType} → ${basename} (replaced stale extension)`));
4911
+ changed = true;
4912
+ continue;
4913
+ }
4914
+ }
4915
+ const newRule = { hooks: [{
4916
+ type: "command",
4917
+ command
4918
+ }] };
4919
+ if (matcher !== void 0) newRule.matcher = matcher;
4920
+ existingRules.push(newRule);
4921
+ hooksSection[hookType] = existingRules;
4922
+ report.push(chalk.green(` Added hook: ${hookType} → ${basename}`));
4923
+ changed = true;
4924
+ }
4925
+ settings["hooks"] = hooksSection;
4926
+ return changed;
4927
+ }
4928
+ /**
4929
+ * Merge statusLine — write only if the key is not already present.
4930
+ */
4931
+ function mergeStatusLine(settings, incoming, report) {
4932
+ if (Object.prototype.hasOwnProperty.call(settings, "statusLine")) {
4933
+ report.push(chalk.dim(" Skipped: statusLine already configured"));
4934
+ return false;
4935
+ }
4936
+ settings["statusLine"] = { ...incoming };
4937
+ report.push(chalk.green(" Added statusLine"));
4938
+ return true;
4939
+ }
4940
+ /**
4941
+ * Merge permissions — append allow/deny entries, deduplicating.
4942
+ */
4943
+ function mergePermissions(settings, incoming, report) {
4944
+ let changed = false;
4945
+ const perms = typeof settings["permissions"] === "object" && settings["permissions"] !== null ? settings["permissions"] : {};
4946
+ for (const list of ["allow", "deny"]) {
4947
+ const entries = incoming[list];
4948
+ if (!entries || entries.length === 0) continue;
4949
+ const existing = Array.isArray(perms[list]) ? perms[list] : [];
4950
+ const existingSet = new Set(existing);
4951
+ for (const entry of entries) if (existingSet.has(entry)) report.push(chalk.dim(` Skipped: permissions.${list} "${entry}" already present`));
4952
+ else {
4953
+ existing.push(entry);
4954
+ existingSet.add(entry);
4955
+ report.push(chalk.green(` Added permissions.${list}: ${entry}`));
4956
+ changed = true;
4957
+ }
4958
+ perms[list] = existing;
4959
+ }
4960
+ settings["permissions"] = perms;
4961
+ return changed;
4962
+ }
4963
+ /**
4964
+ * Merge flags — set keys only if not already present, never overwrite.
4965
+ */
4966
+ function mergeFlags(settings, incoming, report) {
4967
+ let changed = false;
4968
+ for (const [key, value] of Object.entries(incoming)) if (Object.prototype.hasOwnProperty.call(settings, key)) report.push(chalk.dim(` Skipped: ${key} already set`));
4969
+ else {
4970
+ settings[key] = value;
4971
+ report.push(chalk.green(` Added flag: ${key}`));
4972
+ changed = true;
4973
+ }
4974
+ return changed;
4975
+ }
4976
+ /**
4977
+ * Merge env vars, hooks, and/or a statusLine entry into ~/.claude/settings.json.
4978
+ * Never overwrites existing values — only adds what is missing.
4979
+ *
4980
+ * Returns { changed, report } where report contains human-readable lines.
4981
+ */
4982
+ function mergeSettings(opts) {
4983
+ const settings = readSettingsJson();
4984
+ const report = [];
4985
+ let changed = false;
4986
+ if (opts.env !== void 0 && Object.keys(opts.env).length > 0) {
4987
+ if (mergeEnv(settings, opts.env, report)) changed = true;
4988
+ }
4989
+ if (opts.hooks !== void 0 && opts.hooks.length > 0) {
4990
+ if (mergeHooks(settings, opts.hooks, report)) changed = true;
4991
+ }
4992
+ if (opts.statusLine !== void 0) {
4993
+ if (mergeStatusLine(settings, opts.statusLine, report)) changed = true;
4994
+ }
4995
+ if (opts.permissions !== void 0) {
4996
+ if (mergePermissions(settings, opts.permissions, report)) changed = true;
4997
+ }
4998
+ if (opts.flags !== void 0 && Object.keys(opts.flags).length > 0) {
4999
+ if (mergeFlags(settings, opts.flags, report)) changed = true;
5000
+ }
5001
+ if (changed) writeSettingsJson(settings);
5002
+ return {
5003
+ changed,
5004
+ report
5005
+ };
4952
5006
  }
4953
- /**
4954
- * Step 8b: Prompt for the DA (Digital Assistant) name
4955
- *
4956
- * The name appears in tab titles, session notes, and hook output.
4957
- * Stored in env.DA via settings merge.
4958
- */
5007
+
5008
+ //#endregion
5009
+ //#region src/cli/commands/setup/steps/10-settings.ts
5010
+ /** Steps 8b and 8: Assistant name prompt and settings.json patch. */
4959
5011
  async function stepDaName(rl) {
4960
5012
  section("Step 8b: Assistant Name");
4961
5013
  line$1();
@@ -4967,12 +5019,6 @@ async function stepDaName(rl) {
4967
5019
  console.log(c.ok(`Assistant name set to: ${daName}`));
4968
5020
  return daName;
4969
5021
  }
4970
- /**
4971
- * Step 8: Patch ~/.claude/settings.json with PAI hooks, env vars, permissions, and flags
4972
- *
4973
- * Registers all 17 hook entries across 8 event types, adds env vars including DA name,
4974
- * sets the statusline command, adds tool permissions (allow/deny), and sets flags.
4975
- */
4976
5022
  async function stepSettings(rl, daName) {
4977
5023
  section("Step 8: Settings Patch");
4978
5024
  line$1();
@@ -5131,9 +5177,10 @@ async function stepSettings(rl, daName) {
5131
5177
  if (!result.changed) console.log(c.dim(" Settings already up-to-date. No changes made."));
5132
5178
  return result.changed;
5133
5179
  }
5134
- /**
5135
- * Step 9: Daemon install (launchd plist)
5136
- */
5180
+
5181
+ //#endregion
5182
+ //#region src/cli/commands/setup/steps/11-daemon.ts
5183
+ /** Step 9: PAI daemon installation via launchd plist. */
5137
5184
  async function stepDaemon(rl) {
5138
5185
  section("Step 9: Daemon Install");
5139
5186
  line$1();
@@ -5158,9 +5205,10 @@ async function stepDaemon(rl) {
5158
5205
  console.log(c.ok("Daemon installed as com.pai.pai-daemon."));
5159
5206
  return true;
5160
5207
  }
5161
- /**
5162
- * Step 10: MCP registration in ~/.claude.json
5163
- */
5208
+
5209
+ //#endregion
5210
+ //#region src/cli/commands/setup/steps/12-mcp.ts
5211
+ /** Step 10: PAI MCP server registration in ~/.claude.json. */
5164
5212
  async function stepMcp(rl) {
5165
5213
  section("Step 10: MCP Registration");
5166
5214
  line$1();
@@ -5188,9 +5236,10 @@ async function stepMcp(rl) {
5188
5236
  console.log(c.ok("PAI MCP server registered in ~/.claude.json."));
5189
5237
  return true;
5190
5238
  }
5191
- /**
5192
- * Step 11: Directory scanning configuration
5193
- */
5239
+
5240
+ //#endregion
5241
+ //#region src/cli/commands/setup/steps/13-directories.ts
5242
+ /** Step 11: Directory scanning configuration and registry scan prompt. */
5194
5243
  async function stepDirectories(rl) {
5195
5244
  section("Step 11: Directories to Index");
5196
5245
  line$1();
@@ -5211,16 +5260,17 @@ async function stepDirectories(rl) {
5211
5260
  const runScan = await promptYesNo(rl, "Run `pai registry scan` to auto-detect projects after setup?", false);
5212
5261
  if (runScan) {
5213
5262
  line$1();
5214
- console.log(c.dim(" Registry scan will run after setup completes."));
5263
+ console.log(chalk.dim(" Registry scan will run after setup completes."));
5215
5264
  } else {
5216
- console.log(c.dim(" Add projects manually: pai project add <path>"));
5217
- console.log(c.dim(" Or discover them later: pai registry scan"));
5265
+ console.log(chalk.dim(" Add projects manually: pai project add <path>"));
5266
+ console.log(chalk.dim(" Or discover them later: pai registry scan"));
5218
5267
  }
5219
5268
  stepDirectories._runScan = runScan;
5220
5269
  }
5221
- /**
5222
- * Step 12: Initial index
5223
- */
5270
+
5271
+ //#endregion
5272
+ //#region src/cli/commands/setup/steps/14-initial-index.ts
5273
+ /** Step 12: Initial index — optionally starts the daemon and runs registry scan. */
5224
5274
  async function stepInitialIndex(rl) {
5225
5275
  section("Step 12: Initial Index");
5226
5276
  line$1();
@@ -5254,22 +5304,23 @@ async function stepInitialIndex(rl) {
5254
5304
  console.log(c.warn("Could not run registry scan. Run manually: pai registry scan"));
5255
5305
  }
5256
5306
  } else {
5257
- console.log(c.dim(" Start the daemon later: pai daemon serve"));
5258
- console.log(c.dim(" Scan projects later: pai registry scan"));
5307
+ console.log(chalk.dim(" Start the daemon later: pai daemon serve"));
5308
+ console.log(chalk.dim(" Scan projects later: pai registry scan"));
5259
5309
  }
5260
5310
  else {
5261
- console.log(c.dim(" Register projects with: pai project add <path>"));
5262
- console.log(c.dim(" Then index them with: pai memory index --all"));
5263
- console.log(c.dim(" Or start the daemon: pai daemon serve"));
5311
+ console.log(chalk.dim(" Register projects with: pai project add <path>"));
5312
+ console.log(chalk.dim(" Then index them with: pai memory index --all"));
5313
+ console.log(chalk.dim(" Or start the daemon: pai daemon serve"));
5264
5314
  }
5265
5315
  }
5266
- /**
5267
- * Step 13: Summary and next steps
5268
- */
5316
+
5317
+ //#endregion
5318
+ //#region src/cli/commands/setup/steps/15-verify.ts
5319
+ /** Step 13: Setup summary — displays all configuration choices made during setup. */
5269
5320
  function stepSummary(configUpdates, claudeMdGenerated, paiSkillInstalled, aiSteeringRulesInstalled, hooksInstalled, tsHooksInstalled, settingsPatched, daName, daemonInstalled, mcpRegistered) {
5270
5321
  section("Setup Complete");
5271
5322
  line$1();
5272
- console.log(c.ok("PAI Knowledge OS is configured!"));
5323
+ console.log(chalk.green(" PAI Knowledge OS is configured!"));
5273
5324
  line$1();
5274
5325
  const backend = configUpdates.storageBackend;
5275
5326
  const model = configUpdates.embeddingModel;
@@ -5315,11 +5366,14 @@ function stepSummary(configUpdates, claudeMdGenerated, paiSkillInstalled, aiStee
5315
5366
  console.log(chalk.cyan(" pai --help"));
5316
5367
  line$1();
5317
5368
  }
5369
+
5370
+ //#endregion
5371
+ //#region src/cli/commands/setup/index.ts
5318
5372
  async function runSetup() {
5319
5373
  const rl = createRl();
5320
5374
  try {
5321
5375
  if (existsSync(CONFIG_FILE$2)) {
5322
- const current = loadConfig$1();
5376
+ const current = loadConfig();
5323
5377
  line$1();
5324
5378
  console.log(chalk.yellow(" Note: PAI is already configured.") + chalk.dim(" Proceeding will update your existing configuration."));
5325
5379
  console.log(chalk.dim(` Config: ${CONFIG_FILE$2}`));
@@ -5327,7 +5381,7 @@ async function runSetup() {
5327
5381
  line$1();
5328
5382
  if (!await promptYesNo(rl, "Continue and update configuration?", true)) {
5329
5383
  rl.close();
5330
- line$1(c.dim(" Setup cancelled."));
5384
+ line$1(chalk.dim(" Setup cancelled."));
5331
5385
  line$1();
5332
5386
  return;
5333
5387
  }
@@ -5353,7 +5407,7 @@ async function runSetup() {
5353
5407
  };
5354
5408
  mergeConfig(allUpdates);
5355
5409
  line$1();
5356
- console.log(c.ok("Configuration saved."));
5410
+ console.log(chalk.green(" Configuration saved."));
5357
5411
  await stepInitialIndex(rl);
5358
5412
  stepSummary(allUpdates, claudeMdGenerated, paiSkillInstalled, aiSteeringRulesInstalled, hooksInstalled, tsHooksInstalled, settingsPatched, daName, daemonInstalled, mcpRegistered);
5359
5413
  } finally {
@@ -5367,11 +5421,9 @@ function registerSetupCommand(program) {
5367
5421
  }
5368
5422
 
5369
5423
  //#endregion
5370
- //#region src/obsidian/sync.ts
5371
- /**
5372
- * Find the project-root Notes directory for a project.
5373
- * Checks canonical location first, then .claude/Notes.
5374
- */
5424
+ //#region src/obsidian/sync/symlinks.ts
5425
+ /** Symlink management: create, validate, migrate, and clean vault project symlinks. */
5426
+ /** Find the project-root Notes directory for a project. */
5375
5427
  function findNotesDir(rootPath) {
5376
5428
  const canonical = join(rootPath, "Notes");
5377
5429
  if (existsSync(canonical)) return canonical;
@@ -5381,8 +5433,7 @@ function findNotesDir(rootPath) {
5381
5433
  }
5382
5434
  /**
5383
5435
  * Find the Claude Code session notes directory from the registry-stored value.
5384
- * Returns null if not set or does not exist on disk.
5385
- * Skips the dir if it is identical to the project-root notesDir (avoids double-linking).
5436
+ * Returns null if not set, missing on disk, or identical to notesDir.
5386
5437
  */
5387
5438
  function findClaudeNotesDir(claudeNotesDirFromRegistry, notesDir) {
5388
5439
  if (!claudeNotesDirFromRegistry) return null;
@@ -5390,9 +5441,7 @@ function findClaudeNotesDir(claudeNotesDirFromRegistry, notesDir) {
5390
5441
  if (notesDir && claudeNotesDirFromRegistry === notesDir) return null;
5391
5442
  return claudeNotesDirFromRegistry;
5392
5443
  }
5393
- /**
5394
- * Check whether a path exists via lstat (does not follow symlinks).
5395
- */
5444
+ /** Check whether a path exists via lstat (does not follow symlinks). */
5396
5445
  function lstatExists(p) {
5397
5446
  try {
5398
5447
  lstatSync(p);
@@ -5401,9 +5450,7 @@ function lstatExists(p) {
5401
5450
  return false;
5402
5451
  }
5403
5452
  }
5404
- /**
5405
- * Resolve slug collisions by appending -2, -3, etc.
5406
- */
5453
+ /** Resolve slug collisions by appending -2, -3, etc. */
5407
5454
  function uniqueSlug(base, taken) {
5408
5455
  if (!taken.has(base)) return base;
5409
5456
  let n = 2;
@@ -5432,12 +5479,7 @@ function cleanBrokenSymlinks(dir) {
5432
5479
  }
5433
5480
  /**
5434
5481
  * Ensure a sub-symlink inside a project directory is correct.
5435
- *
5436
- * If the symlink already points to the right target, nothing changes.
5437
- * If it points somewhere else, it is removed and recreated.
5438
- * If the path is a non-symlink, it is left alone (data-loss prevention).
5439
- *
5440
- * @returns true if a new symlink was created, false otherwise.
5482
+ * Returns true if a new symlink was created, false otherwise.
5441
5483
  */
5442
5484
  function ensureSubSymlink(linkPath, target, errors, label) {
5443
5485
  if (lstatExists(linkPath)) try {
@@ -5462,14 +5504,8 @@ function ensureSubSymlink(linkPath, target, errors, label) {
5462
5504
  }
5463
5505
  /**
5464
5506
  * Migrate a legacy flat symlink at `slugPath` to a real directory.
5465
- *
5466
- * The old structure was: {vault}/{slug} → {notesDir}
5467
- * The new structure is: {vault}/{slug}/ (real dir)
5468
- * notes → {notesDir}
5469
- * sessions → {claudeNotesDir}
5470
- *
5471
5507
  * If `slugPath` is already a real directory, this is a no-op.
5472
- * If it is a symlink (legacy), it is removed so `mkdirSync` can create the dir.
5508
+ * If it is a symlink (legacy), it is removed so mkdirSync can create the dir.
5473
5509
  */
5474
5510
  function migrateToProjectDir(slugPath, errors, slug) {
5475
5511
  if (!lstatExists(slugPath)) return true;
@@ -5490,13 +5526,11 @@ function migrateToProjectDir(slugPath, errors, slug) {
5490
5526
  /**
5491
5527
  * Sync all active project Notes directories into the Obsidian vault.
5492
5528
  *
5493
- * For each active project that has at least one Notes source, creates:
5494
- *
5529
+ * For each active project with at least one Notes source, creates:
5495
5530
  * {vault}/{slug}/ — real directory
5496
- * notes → {root}/Notes/ (if project root Notes exists)
5497
- * sessions → ~/.claude/projects/{enc}/Notes/ (if Claude Code session notes exist)
5531
+ * notes → {root}/Notes/
5532
+ * sessions → ~/.claude/projects/{enc}/Notes/ (if different)
5498
5533
  *
5499
- * Projects with neither source are skipped.
5500
5534
  * Archived projects get a stub markdown file in {vault}/_archive/.
5501
5535
  */
5502
5536
  function syncVault(vaultPath, db) {
@@ -5558,6 +5592,10 @@ function syncVault(vaultPath, db) {
5558
5592
  }
5559
5593
  return stats;
5560
5594
  }
5595
+
5596
+ //#endregion
5597
+ //#region src/obsidian/sync/generate.ts
5598
+ /** Index and topic page generation — writes _index.md and _topics/{tag}.md. */
5561
5599
  /**
5562
5600
  * Generate _index.md listing all projects with session counts, tags, and
5563
5601
  * indicators for which note sources are available (notes, sessions, or both).
@@ -5652,14 +5690,14 @@ function generateTopicPages(vaultPath, db) {
5652
5690
  function defaultVaultPath() {
5653
5691
  return join(homedir(), ".pai", "obsidian-vault");
5654
5692
  }
5693
+
5694
+ //#endregion
5695
+ //#region src/obsidian/sync/walk.ts
5696
+ /** Directory walking and session file discovery for vault note generation. */
5655
5697
  const SESSION_FILENAME_RE = /^(\d{4}) - (\d{4}-\d{2})-\d{2} - .+\.md$/;
5656
- /** Build the per-project master note filename. */
5657
- function masterFilename(slug) {
5658
- return `_${slug}-master.md`;
5659
- }
5660
5698
  /**
5661
- * Walk a directory (non-recursive, then one level of YYYY/MM subdirs).
5662
- * Returns absolute paths to all .md files found.
5699
+ * Walk a directory (non-recursive root level, then one level of YYYY/MM subdirs).
5700
+ * Returns absolute paths to all .md files found, skipping master note files.
5663
5701
  */
5664
5702
  function walkNotesDir(dir) {
5665
5703
  const results = [];
@@ -5707,7 +5745,7 @@ function walkNotesDir(dir) {
5707
5745
  }
5708
5746
  /**
5709
5747
  * Extract YYYY/MM from a session file path.
5710
- * Tries the path first (looks for /YYYY/MM/ pattern), then falls back to filename date.
5748
+ * Tries the path first (/YYYY/MM/ pattern), then falls back to filename date.
5711
5749
  */
5712
5750
  function extractYearMonth(filePath) {
5713
5751
  const pathMatch = filePath.match(/\/(\d{4})\/(\d{2})\//);
@@ -5717,8 +5755,50 @@ function extractYearMonth(filePath) {
5717
5755
  return "unknown";
5718
5756
  }
5719
5757
  /**
5758
+ * Collect all session .md files from the notes and sessions symlinks
5759
+ * inside a vault project directory.
5760
+ */
5761
+ function collectSessionFiles(slugPath) {
5762
+ const sessionFiles = [];
5763
+ for (const subLink of ["notes", "sessions"]) {
5764
+ const linkPath = join(slugPath, subLink);
5765
+ if (!existsSync(linkPath)) continue;
5766
+ let realDir;
5767
+ try {
5768
+ const stat = lstatSync(linkPath);
5769
+ if (stat.isSymbolicLink()) realDir = readlinkSync(linkPath);
5770
+ else if (stat.isDirectory()) realDir = linkPath;
5771
+ else continue;
5772
+ } catch {
5773
+ continue;
5774
+ }
5775
+ const files = walkNotesDir(realDir);
5776
+ for (const absPath of files) {
5777
+ const basename = absPath.split("/").pop() ?? "";
5778
+ if (!SESSION_FILENAME_RE.test(basename)) continue;
5779
+ const vaultRelPath = `${subLink}/${relative(realDir, absPath)}`;
5780
+ const wikilinkTarget = vaultRelPath.replace(/\.md$/, "");
5781
+ sessionFiles.push({
5782
+ absPath,
5783
+ vaultRelPath,
5784
+ wikilinkTarget,
5785
+ yearMonth: extractYearMonth(absPath),
5786
+ basename: basename.replace(/\.md$/, "")
5787
+ });
5788
+ }
5789
+ }
5790
+ return sessionFiles;
5791
+ }
5792
+
5793
+ //#endregion
5794
+ //#region src/obsidian/sync/master.ts
5795
+ /** Master note generation and session tag fixing for vault projects. */
5796
+ /** Build the per-project master note filename. */
5797
+ function masterFilename(slug) {
5798
+ return `_${slug}-master.md`;
5799
+ }
5800
+ /**
5720
5801
  * Remove any old/broken backlink footer from a session file.
5721
- * Matches the old [[../_master|...]] pattern as well as any [[_{slug}-master|...]] footer.
5722
5802
  * Returns true if the file was modified.
5723
5803
  */
5724
5804
  function removeOldBacklink(filePath) {
@@ -5739,8 +5819,7 @@ function removeOldBacklink(filePath) {
5739
5819
  }
5740
5820
  /**
5741
5821
  * Append the master note backlink footer to a session file, idempotently.
5742
- * Uses Obsidian filename-based wikilink (resolves from anywhere in the vault).
5743
- * Only writes if the sentinel string is not already present in the file.
5822
+ * Only writes if the sentinel string is not already present.
5744
5823
  */
5745
5824
  function appendBacklinkIfMissing(filePath, slug, displayName) {
5746
5825
  let content;
@@ -5761,12 +5840,10 @@ function appendBacklinkIfMissing(filePath, slug, displayName) {
5761
5840
  }
5762
5841
  }
5763
5842
  /**
5764
- * Generate _master.md files for projects that have enough sessions.
5843
+ * Generate _master.md files for projects that have >= threshold session files.
5765
5844
  *
5766
- * For each project directory in the vault that has >= threshold session files,
5767
- * writes a {vaultPath}/{slug}/_master.md containing:
5768
- * - Project title
5769
- * - Session count + date range
5845
+ * For each qualifying project, writes a {vaultPath}/{slug}/_master.md containing:
5846
+ * - Project title, session count + date range
5770
5847
  * - Sessions grouped by YYYY/MM with Obsidian [[wikilinks]]
5771
5848
  *
5772
5849
  * Also appends a backlink footer to each session file (idempotently).
@@ -5790,34 +5867,7 @@ function generateMasterNotes(vaultPath, db, threshold = 5) {
5790
5867
  if (existsSync(legacyMaster)) try {
5791
5868
  unlinkSync(legacyMaster);
5792
5869
  } catch {}
5793
- const sessionFiles = [];
5794
- for (const subLink of ["notes", "sessions"]) {
5795
- const linkPath = join(slugPath, subLink);
5796
- if (!existsSync(linkPath)) continue;
5797
- let realDir;
5798
- try {
5799
- const stat = lstatSync(linkPath);
5800
- if (stat.isSymbolicLink()) realDir = readlinkSync(linkPath);
5801
- else if (stat.isDirectory()) realDir = linkPath;
5802
- else continue;
5803
- } catch {
5804
- continue;
5805
- }
5806
- const files = walkNotesDir(realDir);
5807
- for (const absPath of files) {
5808
- const basename = absPath.split("/").pop() ?? "";
5809
- if (!SESSION_FILENAME_RE.test(basename)) continue;
5810
- const vaultRelPath = `${subLink}/${relative(realDir, absPath)}`;
5811
- const wikilinkTarget = vaultRelPath.replace(/\.md$/, "");
5812
- sessionFiles.push({
5813
- absPath,
5814
- vaultRelPath,
5815
- wikilinkTarget,
5816
- yearMonth: extractYearMonth(absPath),
5817
- basename: basename.replace(/\.md$/, "")
5818
- });
5819
- }
5820
- }
5870
+ const sessionFiles = collectSessionFiles(slugPath);
5821
5871
  if (sessionFiles.length < threshold) continue;
5822
5872
  sessionFiles.sort((a, b) => {
5823
5873
  if (a.yearMonth !== b.yearMonth) return a.yearMonth.localeCompare(b.yearMonth);
@@ -5867,12 +5917,8 @@ function generateMasterNotes(vaultPath, db, threshold = 5) {
5867
5917
  /**
5868
5918
  * Remove the generic #Session tag from session note files across all projects.
5869
5919
  *
5870
- * Session notes are written with `**Tags:** #Session ...` but the generic #Session
5871
- * tag is not useful each note already belongs to a project. This function scans
5872
- * all session and notes directories for every active project and removes #Session
5873
- * (with or without a trailing space) from any `**Tags:**` line.
5874
- *
5875
- * The project-specific tags that follow #Session are preserved untouched.
5920
+ * Scans all session and notes directories for every active project and removes
5921
+ * #Session (with or without a trailing space) from any `**Tags:**` line.
5876
5922
  *
5877
5923
  * @param db Registry SQLite database
5878
5924
  * @returns Object with counts: { filesScanned, filesModified, errors }
@@ -5889,7 +5935,9 @@ function fixSessionTags(db) {
5889
5935
  ORDER BY slug ASC`).all();
5890
5936
  for (const project of projects) {
5891
5937
  const dirsToScan = [];
5892
- const notesDir = findNotesDir(project.root_path);
5938
+ const canonical = join(project.root_path, "Notes");
5939
+ const alt = join(project.root_path, ".claude", "Notes");
5940
+ const notesDir = existsSync(canonical) ? canonical : existsSync(alt) ? alt : null;
5893
5941
  if (notesDir) dirsToScan.push(notesDir);
5894
5942
  if (project.claude_notes_dir && existsSync(project.claude_notes_dir) && project.claude_notes_dir !== notesDir) dirsToScan.push(project.claude_notes_dir);
5895
5943
  for (const dir of dirsToScan) {
@@ -6225,8 +6273,13 @@ function registerObsidianCommands(obsidianCmd, getDb) {
6225
6273
  }
6226
6274
 
6227
6275
  //#endregion
6228
- //#region src/cli/commands/zettel.ts
6276
+ //#region src/cli/commands/zettel/utils.ts
6277
+ /** Shorten a vault path to just the last 2-3 components for display. */
6278
+ function shortPath(p, parts = 3) {
6279
+ return p.split("/").filter(Boolean).slice(-parts).join("/");
6280
+ }
6229
6281
  let _fedDb = null;
6282
+ /** Get (or lazily open) the PAI federation database. */
6230
6283
  function getFedDb() {
6231
6284
  if (!_fedDb) try {
6232
6285
  _fedDb = openFederation();
@@ -6236,15 +6289,14 @@ function getFedDb() {
6236
6289
  }
6237
6290
  return _fedDb;
6238
6291
  }
6239
- /** Shorten a vault path to just the last 2-3 components for display. */
6240
- function shortPath(p, parts = 3) {
6241
- return p.split("/").filter(Boolean).slice(-parts).join("/");
6242
- }
6292
+
6293
+ //#endregion
6294
+ //#region src/cli/commands/zettel/explore.ts
6243
6295
  async function cmdExplore(note, opts) {
6244
6296
  const depth = parseInt(opts.depth ?? "3", 10);
6245
6297
  const direction = opts.direction ?? "both";
6246
6298
  const mode = opts.mode ?? "all";
6247
- const { zettelExplore } = await import("../zettelkasten-e-a4rW_6.mjs");
6299
+ const { zettelExplore } = await import("../zettelkasten-cdajbnPr.mjs");
6248
6300
  const result = zettelExplore(getFedDb(), {
6249
6301
  startNote: note,
6250
6302
  depth,
@@ -6290,12 +6342,25 @@ async function cmdExplore(note, opts) {
6290
6342
  if (result.maxDepthReached) console.log(warn(" Max depth reached — use --depth to explore further"));
6291
6343
  console.log();
6292
6344
  }
6345
+ function registerExploreCommand(parent) {
6346
+ parent.command("explore <note>").description("Follow link chains from a starting note").option("--depth <n>", "Maximum traversal depth (1-10)", "3").option("--direction <d>", "Link direction: forward | backward | both", "both").option("--mode <m>", "Edge mode: sequential | associative | all", "all").action(async (note, opts) => {
6347
+ try {
6348
+ await cmdExplore(note, opts);
6349
+ } catch (e) {
6350
+ console.error(err(` Error: ${e}`));
6351
+ process.exit(1);
6352
+ }
6353
+ });
6354
+ }
6355
+
6356
+ //#endregion
6357
+ //#region src/cli/commands/zettel/health.ts
6293
6358
  async function cmdHealth(opts) {
6294
6359
  const scope = opts.scope ?? "full";
6295
6360
  const projectPath = opts.project;
6296
6361
  const recentDays = parseInt(opts.days ?? "30", 10);
6297
6362
  const includeTypes = opts.include ? opts.include.split(",").map((s) => s.trim()) : void 0;
6298
- const { zettelHealth } = await import("../zettelkasten-e-a4rW_6.mjs");
6363
+ const { zettelHealth } = await import("../zettelkasten-cdajbnPr.mjs");
6299
6364
  const result = zettelHealth(getFedDb(), {
6300
6365
  scope,
6301
6366
  projectPath,
@@ -6342,6 +6407,19 @@ async function cmdHealth(opts) {
6342
6407
  }
6343
6408
  console.log();
6344
6409
  }
6410
+ function registerHealthCommand(parent) {
6411
+ parent.command("health").description("Vault structural health audit: dead links, orphans, connectivity").option("--scope <s>", "Scope: full | recent | project", "full").option("--project <path>", "Project path prefix (requires --scope project)").option("--days <n>", "Look-back window in days (requires --scope recent)", "30").option("--include <types>", "Comma-separated subset: dead_links,orphans,disconnected,low_connectivity").action(async (opts) => {
6412
+ try {
6413
+ await cmdHealth(opts);
6414
+ } catch (e) {
6415
+ console.error(err(` Error: ${e}`));
6416
+ process.exit(1);
6417
+ }
6418
+ });
6419
+ }
6420
+
6421
+ //#endregion
6422
+ //#region src/cli/commands/zettel/surprise.ts
6345
6423
  async function cmdSurprise(note, opts) {
6346
6424
  if (!opts.vaultProjectId) {
6347
6425
  console.error(err(" --vault-project-id is required"));
@@ -6351,7 +6429,7 @@ async function cmdSurprise(note, opts) {
6351
6429
  const limit = parseInt(opts.limit ?? "10", 10);
6352
6430
  const minSimilarity = parseFloat(opts.minSimilarity ?? "0.3");
6353
6431
  const minGraphDistance = parseInt(opts.minDistance ?? "3", 10);
6354
- const { zettelSurprise } = await import("../zettelkasten-e-a4rW_6.mjs");
6432
+ const { zettelSurprise } = await import("../zettelkasten-cdajbnPr.mjs");
6355
6433
  const db = getFedDb();
6356
6434
  console.log();
6357
6435
  console.log(header(" PAI Zettel Surprise"));
@@ -6383,6 +6461,19 @@ async function cmdSurprise(note, opts) {
6383
6461
  console.log();
6384
6462
  }
6385
6463
  }
6464
+ function registerSurpriseCommand(parent) {
6465
+ parent.command("surprise <note>").description("Find semantically similar but graph-distant notes (surprising connections)").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--limit <n>", "Maximum results", "10").option("--min-similarity <f>", "Minimum cosine similarity (0–1)", "0.3").option("--min-distance <n>", "Minimum graph distance", "3").action(async (note, opts) => {
6466
+ try {
6467
+ await cmdSurprise(note, opts);
6468
+ } catch (e) {
6469
+ console.error(err(` Error: ${e}`));
6470
+ process.exit(1);
6471
+ }
6472
+ });
6473
+ }
6474
+
6475
+ //#endregion
6476
+ //#region src/cli/commands/zettel/suggest.ts
6386
6477
  async function cmdSuggest(note, opts) {
6387
6478
  if (!opts.vaultProjectId) {
6388
6479
  console.error(err(" --vault-project-id is required"));
@@ -6391,7 +6482,7 @@ async function cmdSuggest(note, opts) {
6391
6482
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
6392
6483
  const limit = parseInt(opts.limit ?? "5", 10);
6393
6484
  const excludeLinked = opts.excludeLinked !== false;
6394
- const { zettelSuggest } = await import("../zettelkasten-e-a4rW_6.mjs");
6485
+ const { zettelSuggest } = await import("../zettelkasten-cdajbnPr.mjs");
6395
6486
  const db = getFedDb();
6396
6487
  console.log();
6397
6488
  console.log(header(" PAI Zettel Suggest"));
@@ -6421,6 +6512,19 @@ async function cmdSuggest(note, opts) {
6421
6512
  console.log();
6422
6513
  }
6423
6514
  }
6515
+ function registerSuggestCommand(parent) {
6516
+ parent.command("suggest <note>").description("Suggest new wikilink connections for a note").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--limit <n>", "Maximum suggestions", "5").option("--no-exclude-linked", "Include notes already linked from this one").action(async (note, opts) => {
6517
+ try {
6518
+ await cmdSuggest(note, opts);
6519
+ } catch (e) {
6520
+ console.error(err(` Error: ${e}`));
6521
+ process.exit(1);
6522
+ }
6523
+ });
6524
+ }
6525
+
6526
+ //#endregion
6527
+ //#region src/cli/commands/zettel/converse.ts
6424
6528
  async function cmdConverse(question, opts) {
6425
6529
  if (!opts.vaultProjectId) {
6426
6530
  console.error(err(" --vault-project-id is required"));
@@ -6429,7 +6533,7 @@ async function cmdConverse(question, opts) {
6429
6533
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
6430
6534
  const depth = parseInt(opts.depth ?? "2", 10);
6431
6535
  const limit = parseInt(opts.limit ?? "15", 10);
6432
- const { zettelConverse } = await import("../zettelkasten-e-a4rW_6.mjs");
6536
+ const { zettelConverse } = await import("../zettelkasten-cdajbnPr.mjs");
6433
6537
  const db = getFedDb();
6434
6538
  console.log();
6435
6539
  console.log(header(" PAI Zettel Converse"));
@@ -6467,6 +6571,19 @@ async function cmdConverse(question, opts) {
6467
6571
  for (const line of promptLines) console.log(` ${dim(line)}`);
6468
6572
  console.log();
6469
6573
  }
6574
+ function registerConverseCommand(parent) {
6575
+ parent.command("converse <question>").description("Ask the vault a question and get a synthesis prompt with relevant notes").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--depth <n>", "Graph expansion depth around matched notes", "2").option("--limit <n>", "Maximum relevant notes to include", "15").action(async (question, opts) => {
6576
+ try {
6577
+ await cmdConverse(question, opts);
6578
+ } catch (e) {
6579
+ console.error(err(` Error: ${e}`));
6580
+ process.exit(1);
6581
+ }
6582
+ });
6583
+ }
6584
+
6585
+ //#endregion
6586
+ //#region src/cli/commands/zettel/themes.ts
6470
6587
  async function cmdThemes(opts) {
6471
6588
  if (!opts.vaultProjectId) {
6472
6589
  console.error(err(" --vault-project-id is required"));
@@ -6477,7 +6594,7 @@ async function cmdThemes(opts) {
6477
6594
  const minClusterSize = parseInt(opts.minSize ?? "3", 10);
6478
6595
  const maxThemes = parseInt(opts.maxThemes ?? "10", 10);
6479
6596
  const similarityThreshold = parseFloat(opts.threshold ?? "0.65");
6480
- const { zettelThemes } = await import("../zettelkasten-e-a4rW_6.mjs");
6597
+ const { zettelThemes } = await import("../zettelkasten-cdajbnPr.mjs");
6481
6598
  const db = getFedDb();
6482
6599
  console.log();
6483
6600
  console.log(header(" PAI Zettel Themes"));
@@ -6515,50 +6632,260 @@ async function cmdThemes(opts) {
6515
6632
  console.log();
6516
6633
  }
6517
6634
  }
6518
- function registerZettelCommands(parent, _getDb) {
6519
- parent.command("explore <note>").description("Follow link chains from a starting note").option("--depth <n>", "Maximum traversal depth (1-10)", "3").option("--direction <d>", "Link direction: forward | backward | both", "both").option("--mode <m>", "Edge mode: sequential | associative | all", "all").action(async (note, opts) => {
6635
+ function registerThemesCommand(parent) {
6636
+ parent.command("themes").description("Detect emerging theme clusters in recently edited notes").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--days <n>", "Look-back window in days", "30").option("--min-size <n>", "Minimum notes per cluster", "3").option("--max-themes <n>", "Maximum themes to return", "10").option("--threshold <f>", "Similarity threshold for clustering (0–1)", "0.65").action(async (opts) => {
6520
6637
  try {
6521
- await cmdExplore(note, opts);
6638
+ await cmdThemes(opts);
6522
6639
  } catch (e) {
6523
6640
  console.error(err(` Error: ${e}`));
6524
6641
  process.exit(1);
6525
6642
  }
6526
6643
  });
6527
- parent.command("health").description("Vault structural health audit: dead links, orphans, connectivity").option("--scope <s>", "Scope: full | recent | project", "full").option("--project <path>", "Project path prefix (requires --scope project)").option("--days <n>", "Look-back window in days (requires --scope recent)", "30").option("--include <types>", "Comma-separated subset: dead_links,orphans,disconnected,low_connectivity").action(async (opts) => {
6528
- try {
6529
- await cmdHealth(opts);
6530
- } catch (e) {
6531
- console.error(err(` Error: ${e}`));
6532
- process.exit(1);
6533
- }
6644
+ }
6645
+
6646
+ //#endregion
6647
+ //#region src/cli/commands/zettel/index.ts
6648
+ function registerZettelCommands(parent, _getDb) {
6649
+ registerExploreCommand(parent);
6650
+ registerHealthCommand(parent);
6651
+ registerSurpriseCommand(parent);
6652
+ registerSuggestCommand(parent);
6653
+ registerConverseCommand(parent);
6654
+ registerThemesCommand(parent);
6655
+ }
6656
+
6657
+ //#endregion
6658
+ //#region src/cli/commands/observation.ts
6659
+ function ipcCall(method, params) {
6660
+ return new Promise((resolve, reject) => {
6661
+ let settled = false;
6662
+ const settle = (fn) => {
6663
+ if (!settled) {
6664
+ settled = true;
6665
+ fn();
6666
+ }
6667
+ };
6668
+ const client = createConnection("/tmp/pai.sock", () => {
6669
+ client.write(JSON.stringify({
6670
+ id: 1,
6671
+ method,
6672
+ params
6673
+ }) + "\n");
6674
+ });
6675
+ let data = "";
6676
+ client.on("data", (chunk) => {
6677
+ data += chunk.toString();
6678
+ try {
6679
+ const resp = JSON.parse(data);
6680
+ client.end();
6681
+ settle(() => resp.ok ? resolve(resp.result) : reject(new Error(resp.error ?? "IPC call failed")));
6682
+ } catch {}
6683
+ });
6684
+ client.on("end", () => {
6685
+ settle(() => {
6686
+ try {
6687
+ const resp = JSON.parse(data);
6688
+ resp.ok ? resolve(resp.result) : reject(new Error(resp.error ?? "IPC call failed"));
6689
+ } catch (e) {
6690
+ reject(e);
6691
+ }
6692
+ });
6693
+ });
6694
+ client.on("error", (e) => settle(() => reject(e)));
6695
+ setTimeout(() => {
6696
+ client.destroy();
6697
+ settle(() => reject(/* @__PURE__ */ new Error("IPC timeout")));
6698
+ }, 1e4);
6534
6699
  });
6535
- parent.command("surprise <note>").description("Find semantically similar but graph-distant notes (surprising connections)").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--limit <n>", "Maximum results", "10").option("--min-similarity <f>", "Minimum cosine similarity (0–1)", "0.3").option("--min-distance <n>", "Minimum graph distance", "3").action(async (note, opts) => {
6536
- try {
6537
- await cmdSurprise(note, opts);
6538
- } catch (e) {
6539
- console.error(err(` Error: ${e}`));
6540
- process.exit(1);
6700
+ }
6701
+ function typeColor(type) {
6702
+ switch (type) {
6703
+ case "decision": return chalk.cyan(type);
6704
+ case "bugfix": return chalk.red(type);
6705
+ case "feature": return chalk.green(type);
6706
+ case "refactor": return chalk.yellow(type);
6707
+ case "discovery": return chalk.blue(type);
6708
+ case "change": return chalk.magenta(type);
6709
+ default: return chalk.white(type);
6710
+ }
6711
+ }
6712
+ function fmtTs(ts) {
6713
+ if (!ts) return dim("—");
6714
+ try {
6715
+ const d = new Date(ts);
6716
+ return `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 16)}`;
6717
+ } catch {
6718
+ return dim("—");
6719
+ }
6720
+ }
6721
+ function trunc(s, maxLen) {
6722
+ if (!s) return "";
6723
+ if (s.length <= maxLen) return s;
6724
+ return s.slice(0, maxLen - 1) + "…";
6725
+ }
6726
+ async function cmdList(opts) {
6727
+ const limit = parseInt(opts.limit ?? "20", 10);
6728
+ const params = { limit };
6729
+ if (opts.project) params.project_slug = opts.project;
6730
+ if (opts.type) params.type = opts.type;
6731
+ if (opts.session) params.session_id = opts.session;
6732
+ let observations;
6733
+ try {
6734
+ observations = await ipcCall("observation_list", params);
6735
+ } catch (e) {
6736
+ console.error(err(` Failed to reach PAI daemon: ${e}`));
6737
+ console.error(dim(" Is the daemon running? Try: pai daemon status"));
6738
+ process.exit(1);
6739
+ }
6740
+ console.log();
6741
+ console.log(header(" PAI Observations"));
6742
+ const filterParts = [];
6743
+ if (opts.project) filterParts.push(`project: ${opts.project}`);
6744
+ if (opts.type) filterParts.push(`type: ${opts.type}`);
6745
+ if (opts.session) filterParts.push(`session: ${opts.session}`);
6746
+ filterParts.push(`limit: ${limit}`);
6747
+ console.log(dim(` ${filterParts.join(" | ")}`));
6748
+ console.log();
6749
+ if (!observations || observations.length === 0) {
6750
+ console.log(warn(" No observations found."));
6751
+ console.log();
6752
+ return;
6753
+ }
6754
+ const ID_W = 4;
6755
+ const TYPE_W = 10;
6756
+ const TITLE_W = 42;
6757
+ const PROJ_W = 14;
6758
+ const TS_W = 16;
6759
+ console.log(" " + bold("id".padEnd(ID_W)) + " " + bold("type".padEnd(TYPE_W)) + " " + bold("title".padEnd(TITLE_W)) + " " + bold("project".padEnd(PROJ_W)) + " " + bold("created_at"));
6760
+ console.log(dim(" " + "-".repeat(ID_W) + " " + "-".repeat(TYPE_W) + " " + "-".repeat(TITLE_W) + " " + "-".repeat(PROJ_W) + " " + "-".repeat(TS_W)));
6761
+ for (const obs of observations) {
6762
+ const idStr = String(obs.id).padStart(ID_W, " ");
6763
+ const typeStr = typeColor(obs.type ?? "").padEnd(TYPE_W + (typeColor(obs.type ?? "").length - (obs.type ?? "").length));
6764
+ const titleStr = trunc(obs.title ?? "", TITLE_W).padEnd(TITLE_W);
6765
+ const projStr = trunc(obs.project_slug ?? "—", PROJ_W).padEnd(PROJ_W);
6766
+ const tsStr = fmtTs(obs.created_at);
6767
+ console.log(` ${idStr} ${typeStr} ${titleStr} ${projStr} ${dim(tsStr)}`);
6768
+ }
6769
+ console.log();
6770
+ console.log(dim(` ${observations.length} observation(s)`));
6771
+ console.log();
6772
+ }
6773
+ async function cmdSearch(query, opts) {
6774
+ const limit = parseInt(opts.limit ?? "20", 10);
6775
+ const params = {
6776
+ query,
6777
+ limit
6778
+ };
6779
+ if (opts.project) params.project_slug = opts.project;
6780
+ if (opts.type) params.type = opts.type;
6781
+ let allObservations;
6782
+ try {
6783
+ allObservations = await ipcCall("observation_query", {
6784
+ ...params,
6785
+ limit: limit * 5,
6786
+ query: void 0
6787
+ });
6788
+ } catch (e) {
6789
+ console.error(err(` Failed to reach PAI daemon: ${e}`));
6790
+ console.error(dim(" Is the daemon running? Try: pai daemon status"));
6791
+ process.exit(1);
6792
+ }
6793
+ const q = query.toLowerCase();
6794
+ const observations = allObservations.filter((o) => (o.title ?? "").toLowerCase().includes(q) || (o.narrative ?? "").toLowerCase().includes(q) || (o.tool_input_summary ?? "").toLowerCase().includes(q)).slice(0, limit);
6795
+ console.log();
6796
+ console.log(header(" PAI Observation Search"));
6797
+ console.log(dim(` Query: "${query}"${opts.project ? ` project: ${opts.project}` : ""}${opts.type ? ` type: ${opts.type}` : ""} limit: ${limit}`));
6798
+ console.log();
6799
+ if (!observations || observations.length === 0) {
6800
+ console.log(warn(` No observations matching "${query}".`));
6801
+ console.log();
6802
+ return;
6803
+ }
6804
+ for (let i = 0; i < observations.length; i++) {
6805
+ const obs = observations[i];
6806
+ const idx = chalk.dim(String(i + 1).padStart(3, " "));
6807
+ const type = typeColor(obs.type ?? "");
6808
+ const title = bold(obs.title ?? "(untitled)");
6809
+ const proj = obs.project_slug ? dim(`[${obs.project_slug}]`) : dim("[—]");
6810
+ const ts = dim(fmtTs(obs.created_at));
6811
+ const id = dim(`#${obs.id}`);
6812
+ console.log(` ${idx} ${type.padEnd(12)} ${title}`);
6813
+ console.log(` ${proj} ${ts} ${id}`);
6814
+ if (obs.narrative) {
6815
+ const snippet = trunc(obs.narrative.replace(/\n/g, " "), 160);
6816
+ console.log(` ${dim(snippet)}`);
6541
6817
  }
6542
- });
6543
- parent.command("suggest <note>").description("Suggest new wikilink connections for a note").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--limit <n>", "Maximum suggestions", "5").option("--no-exclude-linked", "Include notes already linked from this one").action(async (note, opts) => {
6818
+ console.log();
6819
+ }
6820
+ console.log(dim(` ${observations.length} result(s)`));
6821
+ console.log();
6822
+ }
6823
+ async function cmdStats() {
6824
+ let stats;
6825
+ try {
6826
+ stats = await ipcCall("observation_stats", {});
6827
+ } catch (e) {
6828
+ console.error(err(` Failed to reach PAI daemon: ${e}`));
6829
+ console.error(dim(" Is the daemon running? Try: pai daemon status"));
6830
+ process.exit(1);
6831
+ }
6832
+ console.log();
6833
+ console.log(header(" PAI Observation Statistics"));
6834
+ console.log();
6835
+ console.log(` ${bold("Total observations:")} ${chalk.cyan(String(stats.total ?? 0))}`);
6836
+ if (stats.most_recent) console.log(` ${bold("Most recent:")} ${dim(fmtTs(stats.most_recent))}`);
6837
+ console.log();
6838
+ if (stats.by_type && stats.by_type.length > 0) {
6839
+ console.log(bold(" By type:"));
6840
+ const maxCount = Math.max(...stats.by_type.map((r) => r.count));
6841
+ for (const row of stats.by_type) {
6842
+ const barWidth = 20;
6843
+ const filled = Math.round(row.count / maxCount * barWidth);
6844
+ const bar = chalk.cyan("█".repeat(filled)) + dim("░".repeat(barWidth - filled));
6845
+ const label = typeColor(row.type).padEnd(12 + (typeColor(row.type).length - row.type.length));
6846
+ console.log(` ${label} ${bar} ${String(row.count).padStart(5)}`);
6847
+ }
6848
+ console.log();
6849
+ }
6850
+ if (stats.by_project && stats.by_project.length > 0) {
6851
+ console.log(bold(" By project:"));
6852
+ const maxCount = Math.max(...stats.by_project.map((r) => r.count));
6853
+ const show = stats.by_project.slice(0, 15);
6854
+ for (const row of show) {
6855
+ const barWidth = 20;
6856
+ const filled = Math.round(row.count / maxCount * barWidth);
6857
+ const bar = chalk.green("█".repeat(filled)) + dim("░".repeat(barWidth - filled));
6858
+ const label = (row.project_slug ?? "—").padEnd(20);
6859
+ console.log(` ${dim(label)} ${bar} ${String(row.count).padStart(5)}`);
6860
+ }
6861
+ if (stats.by_project.length > 15) console.log(dim(` ... and ${stats.by_project.length - 15} more project(s)`));
6862
+ console.log();
6863
+ }
6864
+ if ((!stats.by_type || stats.by_type.length === 0) && (!stats.by_project || stats.by_project.length === 0)) {
6865
+ console.log(warn(" No observation data yet."));
6866
+ console.log();
6867
+ }
6868
+ }
6869
+ function registerObservationCommands(parent) {
6870
+ parent.command("list").description("List recent observations").option("--project <slug>", "Filter by project slug").option("--type <type>", "Filter by type (decision, bugfix, feature, refactor, discovery, change)").option("--session <id>", "Filter by session ID").option("--limit <n>", "Maximum results", "20").action(async (opts) => {
6544
6871
  try {
6545
- await cmdSuggest(note, opts);
6872
+ await cmdList(opts);
6546
6873
  } catch (e) {
6547
6874
  console.error(err(` Error: ${e}`));
6548
6875
  process.exit(1);
6549
6876
  }
6550
6877
  });
6551
- parent.command("converse <question>").description("Ask the vault a question and get a synthesis prompt with relevant notes").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--depth <n>", "Graph expansion depth around matched notes", "2").option("--limit <n>", "Maximum relevant notes to include", "15").action(async (question, opts) => {
6878
+ parent.command("search <query>").description("Search observations by title or narrative text").option("--project <slug>", "Filter by project slug").option("--type <type>", "Filter by type").option("--limit <n>", "Maximum results", "20").action(async (query, opts) => {
6552
6879
  try {
6553
- await cmdConverse(question, opts);
6880
+ await cmdSearch(query, opts);
6554
6881
  } catch (e) {
6555
6882
  console.error(err(` Error: ${e}`));
6556
6883
  process.exit(1);
6557
6884
  }
6558
6885
  });
6559
- parent.command("themes").description("Detect emerging theme clusters in recently edited notes").requiredOption("--vault-project-id <n>", "Project ID for the vault in the federation DB").option("--days <n>", "Look-back window in days", "30").option("--min-size <n>", "Minimum notes per cluster", "3").option("--max-themes <n>", "Maximum themes to return", "10").option("--threshold <f>", "Similarity threshold for clustering (0–1)", "0.65").action(async (opts) => {
6886
+ parent.command("stats").description("Show observation statistics: totals, by type, by project").action(async () => {
6560
6887
  try {
6561
- await cmdThemes(opts);
6888
+ await cmdStats();
6562
6889
  } catch (e) {
6563
6890
  console.error(err(` Error: ${e}`));
6564
6891
  process.exit(1);
@@ -6870,7 +7197,7 @@ function registerUpdateCommand(program) {
6870
7197
  //#endregion
6871
7198
  //#region src/cli/commands/notify.ts
6872
7199
  function makeClient$1() {
6873
- return new PaiClient(loadConfig$1().socketPath);
7200
+ return new PaiClient(loadConfig().socketPath);
6874
7201
  }
6875
7202
  function modeColor(mode) {
6876
7203
  switch (mode) {
@@ -7052,7 +7379,7 @@ function registerNotifyCommands(notifyCmd) {
7052
7379
  //#endregion
7053
7380
  //#region src/cli/commands/topic.ts
7054
7381
  function makeClient() {
7055
- return new PaiClient(loadConfig$1().socketPath);
7382
+ return new PaiClient(loadConfig().socketPath);
7056
7383
  }
7057
7384
  function confidenceBar(confidence, width = 20) {
7058
7385
  const filled = Math.round(confidence * width);
@@ -7177,6 +7504,7 @@ registerNotifyCommands(program.command("notify").description("Notification confi
7177
7504
  registerTopicCommands(program.command("topic").description("Topic shift detection: check whether context has drifted to a different project"));
7178
7505
  registerObsidianCommands(program.command("obsidian").description("Obsidian vault: sync project notes, view status, open in Obsidian"), getDb);
7179
7506
  registerZettelCommands(program.command("zettel").description("Zettelkasten intelligence: explore, surprise, converse, themes, health, suggest"), getDb);
7507
+ registerObservationCommands(program.command("observation").description("Observation capture: list, search, and stats"));
7180
7508
  program.command("go <query>").description("Jump to a project directory by slug or partial name.\nPrints the root path to stdout — use with: cd $(pai go <query>)\nExample shell function in ~/.zshrc:\n pcd() { cd \"$(pai go \"$@\")\" }").action((query) => {
7181
7509
  cmdGo(getDb(), query);
7182
7510
  });