@tekmidian/pai 0.5.7 → 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 +87 -1
  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 +1482 -1628
  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 +11 -8
  12. package/dist/daemon/index.mjs.map +1 -1
  13. package/dist/{daemon-2ND5WO2j.mjs → daemon-D3hYb5_C.mjs} +669 -218
  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-DdUperSl.mjs +110 -0
  18. package/dist/db-DdUperSl.mjs.map +1 -0
  19. package/dist/{detect-BU3Nx_2L.mjs → detect-CdaA48EI.mjs} +1 -1
  20. package/dist/{detect-BU3Nx_2L.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  21. package/dist/{detector-Bp-2SM3x.mjs → detector-jGBuYQJM.mjs} +2 -2
  22. package/dist/{detector-Bp-2SM3x.mjs.map → detector-jGBuYQJM.mjs.map} +1 -1
  23. package/dist/{factory-Bzcy70G9.mjs → factory-Ygqe_bVZ.mjs} +7 -5
  24. package/dist/{factory-Bzcy70G9.mjs.map → factory-Ygqe_bVZ.mjs.map} +1 -1
  25. package/dist/helpers-BEST-4Gx.mjs +420 -0
  26. package/dist/helpers-BEST-4Gx.mjs.map +1 -0
  27. package/dist/hooks/capture-all-events.mjs +2 -2
  28. package/dist/hooks/capture-all-events.mjs.map +3 -3
  29. package/dist/hooks/capture-session-summary.mjs +38 -0
  30. package/dist/hooks/capture-session-summary.mjs.map +3 -3
  31. package/dist/hooks/cleanup-session-files.mjs +6 -12
  32. package/dist/hooks/cleanup-session-files.mjs.map +4 -4
  33. package/dist/hooks/context-compression-hook.mjs +93 -104
  34. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  35. package/dist/hooks/initialize-session.mjs +14 -11
  36. package/dist/hooks/initialize-session.mjs.map +4 -4
  37. package/dist/hooks/inject-observations.mjs +220 -0
  38. package/dist/hooks/inject-observations.mjs.map +7 -0
  39. package/dist/hooks/load-core-context.mjs +2 -2
  40. package/dist/hooks/load-core-context.mjs.map +3 -3
  41. package/dist/hooks/load-project-context.mjs +90 -91
  42. package/dist/hooks/load-project-context.mjs.map +4 -4
  43. package/dist/hooks/observe.mjs +354 -0
  44. package/dist/hooks/observe.mjs.map +7 -0
  45. package/dist/hooks/stop-hook.mjs +94 -107
  46. package/dist/hooks/stop-hook.mjs.map +4 -4
  47. package/dist/hooks/sync-todo-to-md.mjs +31 -33
  48. package/dist/hooks/sync-todo-to-md.mjs.map +4 -4
  49. package/dist/index.d.mts +30 -7
  50. package/dist/index.d.mts.map +1 -1
  51. package/dist/index.mjs +5 -8
  52. package/dist/indexer-D53l5d1U.mjs +1 -0
  53. package/dist/{indexer-backend-CIMXedqk.mjs → indexer-backend-jcJFsmB4.mjs} +37 -127
  54. package/dist/indexer-backend-jcJFsmB4.mjs.map +1 -0
  55. package/dist/{ipc-client-Bjg_a1dc.mjs → ipc-client-CoyUHPod.mjs} +2 -7
  56. package/dist/{ipc-client-Bjg_a1dc.mjs.map → ipc-client-CoyUHPod.mjs.map} +1 -1
  57. package/dist/latent-ideas-bTJo6Omd.mjs +191 -0
  58. package/dist/latent-ideas-bTJo6Omd.mjs.map +1 -0
  59. package/dist/neighborhood-BYYbEkUJ.mjs +135 -0
  60. package/dist/neighborhood-BYYbEkUJ.mjs.map +1 -0
  61. package/dist/note-context-BK24bX8Y.mjs +126 -0
  62. package/dist/note-context-BK24bX8Y.mjs.map +1 -0
  63. package/dist/postgres-CKf-EDtS.mjs +846 -0
  64. package/dist/postgres-CKf-EDtS.mjs.map +1 -0
  65. package/dist/{reranker-D7bRAHi6.mjs → reranker-CMNZcfVx.mjs} +1 -1
  66. package/dist/{reranker-D7bRAHi6.mjs.map → reranker-CMNZcfVx.mjs.map} +1 -1
  67. package/dist/{search-_oHfguA5.mjs → search-DC1qhkKn.mjs} +2 -58
  68. package/dist/search-DC1qhkKn.mjs.map +1 -0
  69. package/dist/{sqlite-WWBq7_2C.mjs → sqlite-l-s9xPjY.mjs} +160 -3
  70. package/dist/sqlite-l-s9xPjY.mjs.map +1 -0
  71. package/dist/state-C6_vqz7w.mjs +102 -0
  72. package/dist/state-C6_vqz7w.mjs.map +1 -0
  73. package/dist/stop-words-BaMEGVeY.mjs +326 -0
  74. package/dist/stop-words-BaMEGVeY.mjs.map +1 -0
  75. package/dist/{indexer-CMPOiY1r.mjs → sync-BOsnEj2-.mjs} +14 -216
  76. package/dist/sync-BOsnEj2-.mjs.map +1 -0
  77. package/dist/themes-BvYF0W8T.mjs +148 -0
  78. package/dist/themes-BvYF0W8T.mjs.map +1 -0
  79. package/dist/{tools-DV_lsiCc.mjs → tools-DcaJlYDN.mjs} +162 -273
  80. package/dist/tools-DcaJlYDN.mjs.map +1 -0
  81. package/dist/trace-CRx9lPuc.mjs +137 -0
  82. package/dist/trace-CRx9lPuc.mjs.map +1 -0
  83. package/dist/{vault-indexer-k-kUlaZ-.mjs → vault-indexer-Bi2cRmn7.mjs} +134 -132
  84. package/dist/vault-indexer-Bi2cRmn7.mjs.map +1 -0
  85. package/dist/zettelkasten-cdajbnPr.mjs +708 -0
  86. package/dist/zettelkasten-cdajbnPr.mjs.map +1 -0
  87. package/package.json +1 -2
  88. package/src/hooks/ts/lib/project-utils/index.ts +50 -0
  89. package/src/hooks/ts/lib/project-utils/notify.ts +75 -0
  90. package/src/hooks/ts/lib/project-utils/paths.ts +218 -0
  91. package/src/hooks/ts/lib/project-utils/session-notes.ts +363 -0
  92. package/src/hooks/ts/lib/project-utils/todo.ts +178 -0
  93. package/src/hooks/ts/lib/project-utils/tokens.ts +39 -0
  94. package/src/hooks/ts/lib/project-utils.ts +40 -1018
  95. package/src/hooks/ts/post-tool-use/observe.ts +327 -0
  96. package/src/hooks/ts/session-end/capture-session-summary.ts +41 -0
  97. package/src/hooks/ts/session-start/inject-observations.ts +254 -0
  98. package/dist/chunker-CbnBe0s0.mjs +0 -191
  99. package/dist/chunker-CbnBe0s0.mjs.map +0 -1
  100. package/dist/config-Cf92lGX_.mjs.map +0 -1
  101. package/dist/daemon-2ND5WO2j.mjs.map +0 -1
  102. package/dist/db-Dp8VXIMR.mjs +0 -212
  103. package/dist/db-Dp8VXIMR.mjs.map +0 -1
  104. package/dist/indexer-CMPOiY1r.mjs.map +0 -1
  105. package/dist/indexer-backend-CIMXedqk.mjs.map +0 -1
  106. package/dist/mcp/index.d.mts +0 -1
  107. package/dist/mcp/index.mjs +0 -500
  108. package/dist/mcp/index.mjs.map +0 -1
  109. package/dist/postgres-FXrHDPcE.mjs +0 -358
  110. package/dist/postgres-FXrHDPcE.mjs.map +0 -1
  111. package/dist/schemas-BFIgGntb.mjs +0 -3405
  112. package/dist/schemas-BFIgGntb.mjs.map +0 -1
  113. package/dist/search-_oHfguA5.mjs.map +0 -1
  114. package/dist/sqlite-WWBq7_2C.mjs.map +0 -1
  115. package/dist/tools-DV_lsiCc.mjs.map +0 -1
  116. package/dist/vault-indexer-k-kUlaZ-.mjs.map +0 -1
  117. package/dist/zettelkasten-e-a4rW_6.mjs +0 -901
  118. package/dist/zettelkasten-e-a4rW_6.mjs.map +0 -1
  119. package/templates/README.md +0 -181
  120. package/templates/skills/CORE/Aesthetic.md +0 -333
  121. package/templates/skills/CORE/CONSTITUTION.md +0 -1502
  122. package/templates/skills/CORE/HistorySystem.md +0 -427
  123. package/templates/skills/CORE/HookSystem.md +0 -1082
  124. package/templates/skills/CORE/Prompting.md +0 -509
  125. package/templates/skills/CORE/ProsodyAgentTemplate.md +0 -53
  126. package/templates/skills/CORE/ProsodyGuide.md +0 -416
  127. package/templates/skills/CORE/SKILL.md +0 -741
  128. package/templates/skills/CORE/SkillSystem.md +0 -213
  129. package/templates/skills/CORE/TerminalTabs.md +0 -119
  130. package/templates/skills/CORE/VOICE.md +0 -106
  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
@@ -3,14 +3,17 @@ 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);
@@ -188,8 +235,7 @@ function cmdAdd(db, rawPath, opts) {
188
235
  process.exit(1);
189
236
  }
190
237
  const dirName = basename(rootPath).toLowerCase();
191
- const matches = db.prepare(`SELECT slug, root_path FROM projects
192
- WHERE status = 'active' AND slug != ?`).all(slug).filter((s) => basename(s.root_path).toLowerCase() === dirName || s.slug.replace(/-\d+$/, "") === slug.replace(/-\d+$/, ""));
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+$/, ""));
193
239
  if (matches.length > 0) {
194
240
  console.log(warn(`Similar project(s) already registered:`));
195
241
  for (const m of matches) console.log(dim(` ${bold(m.slug)} ${shortenPath(m.root_path, 50)}`));
@@ -210,7 +256,7 @@ function cmdAdd(db, rawPath, opts) {
210
256
  console.log(dim(` Encoded dir: ${encodedDir}`));
211
257
  console.log(dim(` Type: ${type}`));
212
258
  }
213
- function cmdList$1(db, opts) {
259
+ function cmdList$2(db, opts) {
214
260
  let query = `
215
261
  SELECT p.*,
216
262
  (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
@@ -362,6 +408,160 @@ function cmdAlias(db, slug, alias) {
362
408
  process.exit(1);
363
409
  }
364
410
  }
411
+ function cmdEdit(db, slug, opts) {
412
+ const project = requireProject(db, slug);
413
+ if (!opts.displayName && !opts.type) {
414
+ console.log(warn("Nothing to update. Use --display-name or --type."));
415
+ return;
416
+ }
417
+ const validTypes = [
418
+ "local",
419
+ "central",
420
+ "obsidian-linked",
421
+ "external"
422
+ ];
423
+ if (opts.type && !validTypes.includes(opts.type)) {
424
+ console.error(err(`Invalid type "${opts.type}". Valid: ${validTypes.join(", ")}`));
425
+ process.exit(1);
426
+ }
427
+ const ts = now();
428
+ if (opts.displayName) {
429
+ db.prepare("UPDATE projects SET display_name = ?, updated_at = ? WHERE id = ?").run(opts.displayName, ts, project.id);
430
+ console.log(ok(`Display name updated: ${bold(opts.displayName)}`));
431
+ }
432
+ if (opts.type) {
433
+ db.prepare("UPDATE projects SET type = ?, updated_at = ? WHERE id = ?").run(opts.type, ts, project.id);
434
+ console.log(ok(`Type updated: ${bold(opts.type)}`));
435
+ }
436
+ }
437
+ function cmdDetect(db, pathArg, opts) {
438
+ const cwd = pathArg ? resolvePath(pathArg) : process.cwd();
439
+ const detection = detectProject(db, cwd);
440
+ if (!detection) {
441
+ if (opts.json) console.log(JSON.stringify({
442
+ error: "no_match",
443
+ cwd
444
+ }, null, 2));
445
+ else {
446
+ console.log(warn(`No registered project found for: ${cwd}`));
447
+ console.log(dim(" Run 'pai project add .' to register this directory."));
448
+ }
449
+ process.exit(0);
450
+ return;
451
+ }
452
+ if (opts.json) {
453
+ console.log(formatDetectionJson(detection));
454
+ return;
455
+ }
456
+ console.log();
457
+ console.log(header(" Project Detection Result"));
458
+ console.log();
459
+ console.log(formatDetection(detection).split("\n").map((l) => " " + l).join("\n"));
460
+ console.log();
461
+ }
462
+ function cmdConsolidate(db, identifier, opts) {
463
+ const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
464
+ console.log();
465
+ console.log(header(` Consolidate: ${project.slug}`));
466
+ console.log(` Target: ${project.root_path}`);
467
+ console.log();
468
+ const dirs = findProjectNotesDirs(project);
469
+ if (dirs.length === 0) {
470
+ console.log(warn(" No scattered notes directories found for this project."));
471
+ return;
472
+ }
473
+ const canonicalNotes = join(project.root_path, "Notes");
474
+ const toMerge = dirs.filter((d) => d.notesPath !== canonicalNotes);
475
+ if (toMerge.length === 0) {
476
+ console.log(ok(" All notes are already in the canonical location."));
477
+ console.log(dim(` ${canonicalNotes}`));
478
+ return;
479
+ }
480
+ console.log(` Found ${toMerge.length} scattered Notes directory(ies) to consolidate:`);
481
+ console.log();
482
+ for (const d of toMerge) {
483
+ console.log(` ${bold(d.encodedDir)}`);
484
+ console.log(dim(` Notes: ${d.notesPath} (${d.noteCount} file(s))`));
485
+ }
486
+ console.log();
487
+ console.log(` Destination: ${canonicalNotes}`);
488
+ console.log();
489
+ if (opts.dryRun) {
490
+ console.log(warn(" Dry run — no changes made. Remove --dry-run to proceed."));
491
+ return;
492
+ }
493
+ if (!opts.yes) {
494
+ console.log(warn(" Run with --yes to perform consolidation, or --dry-run to preview changes."));
495
+ return;
496
+ }
497
+ mkdirSync(canonicalNotes, { recursive: true });
498
+ let movedCount = 0;
499
+ for (const d of toMerge) try {
500
+ const files = readdirSync(d.notesPath);
501
+ for (const f of files) {
502
+ if (!f.endsWith(".md") && !f.endsWith(".txt")) continue;
503
+ const src = join(d.notesPath, f);
504
+ const dest = join(canonicalNotes, f);
505
+ if (!existsSync(dest)) {
506
+ renameSync(src, dest);
507
+ console.log(ok(` Moved: ${f}`));
508
+ movedCount++;
509
+ } else console.log(warn(` Skipped (exists): ${f}`));
510
+ }
511
+ } catch (e) {
512
+ console.error(err(` Error reading ${d.notesPath}: ${e}`));
513
+ }
514
+ console.log();
515
+ console.log(ok(` Consolidated ${movedCount} file(s) into ${canonicalNotes}`));
516
+ }
517
+ function cmdGo(db, query) {
518
+ const all = db.prepare("SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC").all();
519
+ if (!all.length) {
520
+ console.error(err("No active projects registered. Run: pai project add <path>"));
521
+ process.exit(1);
522
+ }
523
+ const q = query.trim().toLowerCase();
524
+ const exact = getProject$2(db, query);
525
+ if (exact) {
526
+ process.stdout.write(exact.root_path + "\n");
527
+ return;
528
+ }
529
+ const partial = all.filter((p) => containsIgnoreCase(p.slug, q) || containsIgnoreCase(p.display_name, q) || containsIgnoreCase(basename(p.root_path), q));
530
+ if (partial.length === 1) {
531
+ process.stdout.write(partial[0].root_path + "\n");
532
+ return;
533
+ }
534
+ if (partial.length > 1) {
535
+ console.error(err(`Ambiguous: "${query}" matches ${partial.length} projects:\n`));
536
+ partial.forEach((p, i) => {
537
+ console.error(` ${dim(String(i + 1).padStart(2))} ${bold(p.slug.padEnd(30))} ${dim(shortenPath(p.root_path, 50))}`);
538
+ });
539
+ console.error();
540
+ console.error(dim(" Use a more specific name or the exact slug."));
541
+ process.exit(1);
542
+ }
543
+ const scored = all.map((p) => {
544
+ const distSlug = levenshtein(q, p.slug.toLowerCase());
545
+ const distName = levenshtein(q, p.display_name.toLowerCase());
546
+ return {
547
+ project: p,
548
+ dist: Math.min(distSlug, distName)
549
+ };
550
+ }).sort((a, b) => a.dist - b.dist);
551
+ const threshold = 4;
552
+ const suggestions = scored.filter((s) => s.dist <= threshold).length > 0 ? scored.filter((s) => s.dist <= threshold).slice(0, 3) : scored.slice(0, 3);
553
+ console.error(err(`Project not found: "${query}"\n`));
554
+ if (suggestions.length) {
555
+ console.error(warn(" Did you mean?"));
556
+ for (const s of suggestions) console.error(` ${bold(s.project.slug.padEnd(30))} ${dim(shortenPath(s.project.root_path, 50))}`);
557
+ console.error();
558
+ console.error(dim(" Run: pai project list (to see all projects)"));
559
+ }
560
+ process.exit(1);
561
+ }
562
+
563
+ //#endregion
564
+ //#region src/cli/commands/project/session-config.ts
365
565
  const CONFIG_OPTIONS = [
366
566
  {
367
567
  key: "permission",
@@ -762,36 +962,9 @@ function cmdConfig(db, identifier, opts) {
762
962
  }
763
963
  console.log();
764
964
  }
765
- function cmdEdit(db, slug, opts) {
766
- const project = requireProject(db, slug);
767
- if (!opts.displayName && !opts.type) {
768
- console.log(warn("Nothing to update. Use --display-name or --type."));
769
- return;
770
- }
771
- const validTypes = [
772
- "local",
773
- "central",
774
- "obsidian-linked",
775
- "external"
776
- ];
777
- if (opts.type && !validTypes.includes(opts.type)) {
778
- console.error(err(`Invalid type "${opts.type}". Valid: ${validTypes.join(", ")}`));
779
- process.exit(1);
780
- }
781
- const ts = now();
782
- if (opts.displayName) {
783
- db.prepare("UPDATE projects SET display_name = ?, updated_at = ? WHERE id = ?").run(opts.displayName, ts, project.id);
784
- console.log(ok(`Display name updated: ${bold(opts.displayName)}`));
785
- }
786
- if (opts.type) {
787
- db.prepare("UPDATE projects SET type = ?, updated_at = ? WHERE id = ?").run(opts.type, ts, project.id);
788
- console.log(ok(`Type updated: ${bold(opts.type)}`));
789
- }
790
- }
791
- /**
792
- * Find Claude project dirs (~/.claude/projects/) that look like they belong
793
- * to a project based on encoded_dir prefix matching.
794
- */
965
+
966
+ //#endregion
967
+ //#region src/cli/commands/project/health.ts
795
968
  function findOrphanedNotesDirs(project) {
796
969
  const claudeProjects = join(homedir(), ".claude", "projects");
797
970
  if (!existsSync(claudeProjects)) return [];
@@ -813,10 +986,6 @@ function findOrphanedNotesDirs(project) {
813
986
  } catch {}
814
987
  return results;
815
988
  }
816
- /**
817
- * Try to find a moved project by looking for a directory with the same name
818
- * as the last path component in common nearby locations.
819
- */
820
989
  function suggestMovedPath(project) {
821
990
  const name = basename(project.root_path);
822
991
  const candidates = [
@@ -825,7 +994,7 @@ function suggestMovedPath(project) {
825
994
  join(homedir(), "Desktop", name),
826
995
  join(homedir(), "Projects", name)
827
996
  ];
828
- for (const c of candidates) if (existsSync(c)) return c;
997
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
829
998
  }
830
999
  function cmdHealth$1(db, opts) {
831
1000
  const rows = db.prepare(`SELECT p.*,
@@ -842,12 +1011,11 @@ function cmdHealth$1(db, opts) {
842
1011
  suggestedPath = suggestMovedPath(project);
843
1012
  category = suggestedPath ? "stale" : "dead";
844
1013
  }
845
- const claudeNotesExists = orphaned.length > 0;
846
1014
  return {
847
1015
  project,
848
1016
  category,
849
1017
  suggestedPath,
850
- claudeNotesExists,
1018
+ claudeNotesExists: orphaned.length > 0,
851
1019
  orphanedNotesDirs: orphaned
852
1020
  };
853
1021
  });
@@ -917,194 +1085,21 @@ function cmdHealth$1(db, opts) {
917
1085
  }
918
1086
  console.log();
919
1087
  }
920
- const summary = ` ${rows.length} total: ${active.length} active, ${stale.length} stale, ${dead.length} dead`;
921
- console.log(dim(summary));
1088
+ console.log(dim(` ${rows.length} total: ${active.length} active, ${stale.length} stale, ${dead.length} dead`));
922
1089
  if (!opts.fix && (stale.length > 0 || dead.length > 0)) {
923
1090
  console.log();
924
1091
  console.log(warn(" Run with --fix to auto-remediate where possible."));
925
1092
  }
926
1093
  }
927
- function cmdDetect(db, pathArg, opts) {
928
- const cwd = pathArg ? resolvePath(pathArg) : process.cwd();
929
- const detection = detectProject(db, cwd);
930
- if (!detection) {
931
- if (opts.json) console.log(JSON.stringify({
932
- error: "no_match",
933
- cwd
934
- }, null, 2));
935
- else {
936
- console.log(warn(`No registered project found for: ${cwd}`));
937
- console.log(dim(" Run 'pai project add .' to register this directory."));
938
- }
939
- process.exit(0);
940
- return;
941
- }
942
- if (opts.json) {
943
- console.log(formatDetectionJson(detection));
944
- return;
945
- }
946
- console.log();
947
- console.log(header(" Project Detection Result"));
948
- console.log();
949
- console.log(formatDetection(detection).split("\n").map((l) => " " + l).join("\n"));
950
- console.log();
951
- }
952
- /**
953
- * Find all ~/.claude/projects/ encoded dirs whose name encodes to a path
954
- * that is a child-of or exact-match of the given project's root_path.
955
- */
956
- function findProjectNotesDirs(project) {
957
- const claudeProjects = join(homedir(), ".claude", "projects");
958
- if (!existsSync(claudeProjects)) return [];
959
- const results = [];
960
- const rootEncoded = encodeDir(project.root_path);
961
- try {
962
- for (const entry of readdirSync(claudeProjects)) {
963
- const full = join(claudeProjects, entry);
964
- try {
965
- if (!statSync(full).isDirectory()) continue;
966
- } catch {
967
- continue;
968
- }
969
- if (entry !== rootEncoded && !entry.startsWith(rootEncoded)) continue;
970
- const notesPath = join(full, "Notes");
971
- if (!existsSync(notesPath)) continue;
972
- let noteCount = 0;
973
- try {
974
- noteCount = readdirSync(notesPath).filter((f) => f.endsWith(".md") || f.endsWith(".txt")).length;
975
- } catch {}
976
- results.push({
977
- encodedDir: entry,
978
- fullPath: full,
979
- notesPath,
980
- noteCount
981
- });
982
- }
983
- } catch {}
984
- return results;
985
- }
986
- function cmdConsolidate(db, identifier, opts) {
987
- const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
988
- console.log();
989
- console.log(header(` Consolidate: ${project.slug}`));
990
- console.log(` Target: ${project.root_path}`);
991
- console.log();
992
- const dirs = findProjectNotesDirs(project);
993
- if (dirs.length === 0) {
994
- console.log(warn(" No scattered notes directories found for this project."));
995
- return;
996
- }
997
- const canonicalNotes = join(project.root_path, "Notes");
998
- const toMerge = dirs.filter((d) => d.notesPath !== canonicalNotes);
999
- if (toMerge.length === 0) {
1000
- console.log(ok(" All notes are already in the canonical location."));
1001
- console.log(dim(` ${canonicalNotes}`));
1002
- return;
1003
- }
1004
- console.log(` Found ${toMerge.length} scattered Notes directory(ies) to consolidate:`);
1005
- console.log();
1006
- for (const d of toMerge) {
1007
- console.log(` ${bold(d.encodedDir)}`);
1008
- console.log(dim(` Notes: ${d.notesPath} (${d.noteCount} file(s))`));
1009
- }
1010
- console.log();
1011
- console.log(` Destination: ${canonicalNotes}`);
1012
- console.log();
1013
- if (opts.dryRun) {
1014
- console.log(warn(" Dry run — no changes made. Remove --dry-run to proceed."));
1015
- return;
1016
- }
1017
- if (!opts.yes) {
1018
- console.log(warn(" Run with --yes to perform consolidation, or --dry-run to preview changes."));
1019
- return;
1020
- }
1021
- mkdirSync(canonicalNotes, { recursive: true });
1022
- let movedCount = 0;
1023
- for (const d of toMerge) try {
1024
- const files = readdirSync(d.notesPath);
1025
- for (const f of files) {
1026
- if (!f.endsWith(".md") && !f.endsWith(".txt")) continue;
1027
- const src = join(d.notesPath, f);
1028
- const dest = join(canonicalNotes, f);
1029
- if (!existsSync(dest)) {
1030
- renameSync(src, dest);
1031
- console.log(ok(` Moved: ${f}`));
1032
- movedCount++;
1033
- } else console.log(warn(` Skipped (exists): ${f}`));
1034
- }
1035
- } catch (e) {
1036
- console.error(err(` Error reading ${d.notesPath}: ${e}`));
1037
- }
1038
- console.log();
1039
- console.log(ok(` Consolidated ${movedCount} file(s) into ${canonicalNotes}`));
1040
- }
1041
- /**
1042
- * Simple Levenshtein distance for "did you mean?" suggestions.
1043
- */
1044
- function levenshtein(a, b) {
1045
- const m = a.length;
1046
- const n = b.length;
1047
- const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
1048
- 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]);
1049
- return dp[m][n];
1050
- }
1051
- /**
1052
- * Check if `needle` appears as a substring (case-insensitive) in `haystack`.
1053
- */
1054
- function containsIgnoreCase(haystack, needle) {
1055
- return haystack.toLowerCase().includes(needle.toLowerCase());
1056
- }
1057
- function cmdGo(db, query) {
1058
- const all = db.prepare("SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC").all();
1059
- if (!all.length) {
1060
- console.error(err("No active projects registered. Run: pai project add <path>"));
1061
- process.exit(1);
1062
- }
1063
- const q = query.trim().toLowerCase();
1064
- const exact = getProject$2(db, query);
1065
- if (exact) {
1066
- process.stdout.write(exact.root_path + "\n");
1067
- return;
1068
- }
1069
- const partial = all.filter((p) => containsIgnoreCase(p.slug, q) || containsIgnoreCase(p.display_name, q) || containsIgnoreCase(basename(p.root_path), q));
1070
- if (partial.length === 1) {
1071
- process.stdout.write(partial[0].root_path + "\n");
1072
- return;
1073
- }
1074
- if (partial.length > 1) {
1075
- console.error(err(`Ambiguous: "${query}" matches ${partial.length} projects:\n`));
1076
- partial.forEach((p, i) => {
1077
- console.error(` ${dim(String(i + 1).padStart(2))} ${bold(p.slug.padEnd(30))} ${dim(shortenPath(p.root_path, 50))}`);
1078
- });
1079
- console.error();
1080
- console.error(dim(" Use a more specific name or the exact slug."));
1081
- process.exit(1);
1082
- }
1083
- const scored = all.map((p) => {
1084
- const distSlug = levenshtein(q, p.slug.toLowerCase());
1085
- const distName = levenshtein(q, p.display_name.toLowerCase());
1086
- return {
1087
- project: p,
1088
- dist: Math.min(distSlug, distName)
1089
- };
1090
- }).sort((a, b) => a.dist - b.dist);
1091
- const threshold = 4;
1092
- const suggestions = scored.filter((s) => s.dist <= threshold).length > 0 ? scored.filter((s) => s.dist <= threshold).slice(0, 3) : scored.slice(0, 3);
1093
- console.error(err(`Project not found: "${query}"\n`));
1094
- if (suggestions.length) {
1095
- console.error(warn(" Did you mean?"));
1096
- for (const s of suggestions) console.error(` ${bold(s.project.slug.padEnd(30))} ${dim(shortenPath(s.project.root_path, 50))}`);
1097
- console.error();
1098
- console.error(dim(" Run: pai project list (to see all projects)"));
1099
- }
1100
- process.exit(1);
1101
- }
1094
+
1095
+ //#endregion
1096
+ //#region src/cli/commands/project/index.ts
1102
1097
  function registerProjectCommands(projectCmd, getDb) {
1103
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) => {
1104
1099
  cmdAdd(getDb(), rawPath, opts);
1105
1100
  });
1106
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) => {
1107
- cmdList$1(getDb(), opts);
1102
+ cmdList$2(getDb(), opts);
1108
1103
  });
1109
1104
  projectCmd.command("info <slug>").description("Show full details for a project").action((slug) => {
1110
1105
  cmdInfo$1(getDb(), slug);
@@ -1176,275 +1171,6 @@ function registerProjectCommands(projectCmd, getDb) {
1176
1171
  * - type "user": { type: "user", message: { role: "user", content: string | [{ type, text }] } }
1177
1172
  * - type "assistant": { type: "assistant", message: { role: "assistant", content: [{ type, text }] } }
1178
1173
  */
1179
- const STOP_WORDS = new Set([
1180
- "the",
1181
- "a",
1182
- "an",
1183
- "is",
1184
- "are",
1185
- "was",
1186
- "were",
1187
- "be",
1188
- "been",
1189
- "being",
1190
- "have",
1191
- "has",
1192
- "had",
1193
- "do",
1194
- "does",
1195
- "did",
1196
- "will",
1197
- "would",
1198
- "could",
1199
- "should",
1200
- "may",
1201
- "might",
1202
- "can",
1203
- "shall",
1204
- "this",
1205
- "that",
1206
- "these",
1207
- "those",
1208
- "it",
1209
- "its",
1210
- "i",
1211
- "you",
1212
- "we",
1213
- "they",
1214
- "he",
1215
- "she",
1216
- "my",
1217
- "your",
1218
- "our",
1219
- "their",
1220
- "what",
1221
- "which",
1222
- "who",
1223
- "whom",
1224
- "how",
1225
- "when",
1226
- "where",
1227
- "why",
1228
- "not",
1229
- "no",
1230
- "yes",
1231
- "just",
1232
- "also",
1233
- "very",
1234
- "really",
1235
- "about",
1236
- "after",
1237
- "before",
1238
- "from",
1239
- "into",
1240
- "with",
1241
- "without",
1242
- "for",
1243
- "and",
1244
- "or",
1245
- "but",
1246
- "if",
1247
- "then",
1248
- "else",
1249
- "so",
1250
- "because",
1251
- "as",
1252
- "at",
1253
- "by",
1254
- "in",
1255
- "on",
1256
- "of",
1257
- "to",
1258
- "up",
1259
- "out",
1260
- "off",
1261
- "over",
1262
- "under",
1263
- "more",
1264
- "most",
1265
- "some",
1266
- "any",
1267
- "all",
1268
- "each",
1269
- "every",
1270
- "both",
1271
- "few",
1272
- "many",
1273
- "much",
1274
- "other",
1275
- "another",
1276
- "such",
1277
- "only",
1278
- "own",
1279
- "same",
1280
- "than",
1281
- "too",
1282
- "let",
1283
- "me",
1284
- "us",
1285
- "ok",
1286
- "okay",
1287
- "sure",
1288
- "please",
1289
- "thanks",
1290
- "thank",
1291
- "here",
1292
- "there",
1293
- "now",
1294
- "well",
1295
- "like",
1296
- "want",
1297
- "need",
1298
- "know",
1299
- "think",
1300
- "see",
1301
- "look",
1302
- "make",
1303
- "get",
1304
- "go",
1305
- "come",
1306
- "take",
1307
- "use",
1308
- "find",
1309
- "give",
1310
- "tell",
1311
- "say",
1312
- "said",
1313
- "try",
1314
- "keep",
1315
- "run",
1316
- "set",
1317
- "put",
1318
- "add",
1319
- "show",
1320
- "check",
1321
- "new",
1322
- "file",
1323
- "code",
1324
- "going",
1325
- "done",
1326
- "got",
1327
- "https",
1328
- "http",
1329
- "www",
1330
- "com",
1331
- "org",
1332
- "net",
1333
- "io",
1334
- "null",
1335
- "undefined",
1336
- "true",
1337
- "false",
1338
- "ll",
1339
- "ve",
1340
- "re",
1341
- "don",
1342
- "thats",
1343
- "its",
1344
- "heres",
1345
- "theres",
1346
- "youre",
1347
- "theyre",
1348
- "didnt",
1349
- "dont",
1350
- "doesnt",
1351
- "havent",
1352
- "hasnt",
1353
- "wont",
1354
- "cant",
1355
- "shouldnt",
1356
- "wouldnt",
1357
- "couldnt",
1358
- "isnt",
1359
- "arent",
1360
- "wasnt",
1361
- "werent",
1362
- "never",
1363
- "ever",
1364
- "still",
1365
- "already",
1366
- "yet",
1367
- "back",
1368
- "away",
1369
- "down",
1370
- "right",
1371
- "left",
1372
- "next",
1373
- "last",
1374
- "first",
1375
- "second",
1376
- "third",
1377
- "one",
1378
- "two",
1379
- "three",
1380
- "four",
1381
- "five",
1382
- "six",
1383
- "seven",
1384
- "eight",
1385
- "nine",
1386
- "ten",
1387
- "time",
1388
- "way",
1389
- "thing",
1390
- "something",
1391
- "anything",
1392
- "nothing",
1393
- "everything",
1394
- "someone",
1395
- "anyone",
1396
- "everyone",
1397
- "then",
1398
- "again",
1399
- "once",
1400
- "twice",
1401
- "since",
1402
- "while",
1403
- "though",
1404
- "although",
1405
- "however",
1406
- "therefore",
1407
- "thus",
1408
- "hence",
1409
- "meanwhile",
1410
- "moreover",
1411
- "furthermore",
1412
- "otherwise",
1413
- "instead",
1414
- "anyway",
1415
- "actually",
1416
- "basically",
1417
- "literally",
1418
- "simply",
1419
- "exactly",
1420
- "probably",
1421
- "possibly",
1422
- "maybe",
1423
- "perhaps",
1424
- "certainly",
1425
- "definitely",
1426
- "absolutely",
1427
- "completely",
1428
- "totally",
1429
- "quite",
1430
- "rather",
1431
- "fairly",
1432
- "nearly",
1433
- "almost",
1434
- "barely",
1435
- "hardly",
1436
- "quickly",
1437
- "slowly",
1438
- "easily",
1439
- "likely",
1440
- "unlikely",
1441
- "via",
1442
- "per",
1443
- "etc",
1444
- "ie",
1445
- "eg",
1446
- "vs"
1447
- ]);
1448
1174
  /**
1449
1175
  * Extract plain text from a parsed JSONL message object.
1450
1176
  */
@@ -1570,7 +1296,7 @@ function generateSlug(messages, opts) {
1570
1296
  }
1571
1297
 
1572
1298
  //#endregion
1573
- //#region src/cli/commands/session.ts
1299
+ //#region src/cli/commands/session/helpers.ts
1574
1300
  function getProject$1(db, slug) {
1575
1301
  return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(slug);
1576
1302
  }
@@ -1581,36 +1307,19 @@ function statusColor(status) {
1581
1307
  default: return chalk.yellow(status);
1582
1308
  }
1583
1309
  }
1584
- /**
1585
- * Convert a slug to a title-cased display name suitable for filenames.
1586
- * "memory-engine" → "Memory Engine"
1587
- * "slug-generator" → "Slug Generator"
1588
- * "session-slug-fix" → "Session Slug Fix"
1589
- */
1310
+ /** Convert a slug to title-cased display name: "memory-engine" → "Memory Engine" */
1590
1311
  function toTitleCase$1(slug) {
1591
1312
  return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1592
1313
  }
1593
- /**
1594
- * Find the Notes directory for a project.
1595
- *
1596
- * Notes live inside the Claude-managed project directory:
1597
- * ~/.claude/projects/<encoded_dir>/Notes/
1598
- */
1314
+ /** Notes directory for a project: ~/.claude/projects/<encoded_dir>/Notes/ */
1599
1315
  function getNotesDir(project) {
1600
1316
  return join(homedir(), ".claude", "projects", project.encoded_dir, "Notes");
1601
1317
  }
1602
- /**
1603
- * Format a session filename from its parts.
1604
- * number=27, date="2026-02-23", titleSlug="Memory Engine"
1605
- * → "0027 - 2026-02-23 - Memory Engine.md"
1606
- */
1318
+ /** Format a session filename: number=27, date="2026-02-23" → "0027 - 2026-02-23 - Title.md" */
1607
1319
  function formatFilename(number, date, titleSlug) {
1608
1320
  return `${String(number).padStart(4, "0")} - ${date} - ${titleSlug}.md`;
1609
1321
  }
1610
- /**
1611
- * Look up a session by project + number OR "latest".
1612
- * Returns the session row or exits with an error.
1613
- */
1322
+ /** Resolve a session by project + number or "latest". Exits on failure. */
1614
1323
  function resolveSession(db, project, numberOrLatest) {
1615
1324
  let session;
1616
1325
  if (numberOrLatest === "latest") session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
@@ -1628,7 +1337,20 @@ function resolveSession(db, project, numberOrLatest) {
1628
1337
  }
1629
1338
  return session;
1630
1339
  }
1631
- 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) {
1632
1354
  const limit = parseInt(opts.limit ?? "20", 10);
1633
1355
  const params = [];
1634
1356
  let query = `
@@ -1723,10 +1445,6 @@ function cmdInfo(db, projectSlug, sessionNumber) {
1723
1445
  if (session.closed_at) console.log(` ${bold("Closed:")} ${fmtDate(session.closed_at)}`);
1724
1446
  console.log();
1725
1447
  }
1726
- /**
1727
- * Rename a session note: updates the database (slug + title), renames the
1728
- * file on disk, and updates the H1 title inside the Markdown file.
1729
- */
1730
1448
  function cmdRename(db, projectSlug, numberOrLatest, newSlug) {
1731
1449
  const project = getProject$1(db, projectSlug);
1732
1450
  if (!project) {
@@ -1779,10 +1497,6 @@ function cmdRename(db, projectSlug, numberOrLatest, newSlug) {
1779
1497
  console.log(` ${bold("Title:")} ${titleSlug}`);
1780
1498
  console.log();
1781
1499
  }
1782
- /**
1783
- * Generate (and optionally apply) a slug for a session by analysing its
1784
- * Claude Code JSONL transcript.
1785
- */
1786
1500
  function cmdSlug(db, projectSlug, numberOrLatest, opts) {
1787
1501
  const project = getProject$1(db, projectSlug);
1788
1502
  if (!project) {
@@ -1810,25 +1524,6 @@ function cmdSlug(db, projectSlug, numberOrLatest, opts) {
1810
1524
  cmdRename(db, projectSlug, String(session.number), generatedSlug);
1811
1525
  }
1812
1526
  }
1813
- function upsertTag(db, tagName) {
1814
- db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tagName);
1815
- return db.prepare("SELECT id FROM tags WHERE name = ?").get(tagName).id;
1816
- }
1817
- function getSessionTags(db, sessionId) {
1818
- return db.prepare(`SELECT t.name FROM tags t
1819
- JOIN session_tags st ON st.tag_id = t.id
1820
- WHERE st.session_id = ?
1821
- ORDER BY t.name`).all(sessionId).map((r) => r.name);
1822
- }
1823
- /**
1824
- * Set or show tags on a session.
1825
- *
1826
- * With no tags supplied, prints the current tags.
1827
- * Tags can be supplied as separate args or as a single comma-separated string.
1828
- * pai session tag 20-webseiten 81 — show current tags
1829
- * pai session tag 20-webseiten 81 docker migration server
1830
- * pai session tag 20-webseiten 81 docker,migration,server
1831
- */
1832
1527
  function cmdTag(db, projectSlug, sessionNumber, rawTags) {
1833
1528
  const project = getProject$1(db, projectSlug);
1834
1529
  if (!project) {
@@ -1866,12 +1561,6 @@ function cmdTag(db, projectSlug, sessionNumber, rawTags) {
1866
1561
  console.log(` ${bold("All tags:")} ${allTags.map((t) => chalk.cyan(t)).join(", ")}`);
1867
1562
  console.log();
1868
1563
  }
1869
- /**
1870
- * Create a cross-reference link from a session to a target project.
1871
- *
1872
- * pai session route <project-slug> <session-number> <target-project>
1873
- * pai session route <project-slug> <session-number> <target-project> --type follow-up
1874
- */
1875
1564
  function cmdRoute(db, projectSlug, sessionNumber, targetProjectSlug, opts) {
1876
1565
  const project = getProject$1(db, projectSlug);
1877
1566
  if (!project) {
@@ -1906,60 +1595,178 @@ function cmdRoute(db, projectSlug, sessionNumber, targetProjectSlug, opts) {
1906
1595
  console.log(dim(` Link type: ${linkType}`));
1907
1596
  console.log();
1908
1597
  }
1909
- /**
1910
- * Find the Notes directory for the current working directory by scanning
1911
- * ~/.claude/projects/ for a matching encoded-dir entry.
1912
- *
1913
- * Returns the Notes dir path if found, or null if the CWD has no Claude
1914
- * project directory yet.
1915
- */
1916
- function findNotesDirForCwd() {
1917
- const cwd = process.cwd();
1598
+ function cmdActive(db, opts) {
1599
+ const minutes = parseInt(opts.minutes ?? "60", 10);
1600
+ const cutoff = Date.now() - minutes * 60 * 1e3;
1918
1601
  const claudeProjectsDir = join(homedir(), ".claude", "projects");
1919
- if (!existsSync(claudeProjectsDir)) return null;
1920
- const expectedEncoded = cwd.replace(/[/\s.\-]/g, "-");
1921
- let encodedDir = null;
1922
- try {
1923
- const entries = readdirSync(claudeProjectsDir);
1924
- if (entries.includes(expectedEncoded)) encodedDir = expectedEncoded;
1925
- else for (const entry of entries) {
1926
- const full = join(claudeProjectsDir, entry);
1927
- try {
1928
- if (!statSync(full).isDirectory()) continue;
1929
- } catch {
1930
- continue;
1931
- }
1932
- if (entry.replace(/-+$/, "") === expectedEncoded.replace(/-+$/, "")) {
1933
- encodedDir = entry;
1934
- break;
1935
- }
1936
- }
1937
- } catch {
1938
- return null;
1939
- }
1940
- if (!encodedDir) return null;
1941
- const notesDir = join(claudeProjectsDir, encodedDir, "Notes");
1942
- return existsSync(notesDir) ? notesDir : null;
1943
- }
1944
- /**
1945
- * Find the most recently modified .md file in a directory.
1946
- */
1947
- function findLatestNoteFile(notesDir) {
1948
- let entries;
1949
- try {
1950
- entries = readdirSync(notesDir);
1951
- } catch {
1952
- return null;
1602
+ if (!existsSync(claudeProjectsDir)) {
1603
+ console.log(err("Claude projects directory not found."));
1604
+ return;
1953
1605
  }
1954
- const mdFiles = entries.filter((e) => e.endsWith(".md"));
1955
- if (mdFiles.length === 0) return null;
1956
- let latestPath = null;
1957
- let latestMtime = 0;
1958
- for (const file of mdFiles) {
1959
- const full = join(notesDir, file);
1960
- try {
1961
- const { mtimeMs } = statSync(full);
1962
- if (mtimeMs > latestMtime) {
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));
1660
+ return;
1661
+ }
1662
+ if (deduped.length === 0) {
1663
+ console.log(dim(`No active sessions in the last ${minutes} minutes.`));
1664
+ return;
1665
+ }
1666
+ console.log(header(`Currently Active Sessions`) + dim(` (modified in last ${minutes}min)`));
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));
1682
+ }
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;
1702
+ }
1703
+ if (opts.json) {
1704
+ console.log(formatAutoRouteJson(result));
1705
+ return;
1706
+ }
1707
+ console.log();
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));
1717
+ console.log();
1718
+ }
1719
+
1720
+ //#endregion
1721
+ //#region src/cli/commands/session/checkpoint.ts
1722
+ /**
1723
+ * Session checkpoint command — appends a timestamped block to the active
1724
+ * session note. Designed for use in hooks; fast, silent, rate-limited.
1725
+ */
1726
+ function findNotesDirForCwd() {
1727
+ const cwd = process.cwd();
1728
+ const claudeProjectsDir = join(homedir(), ".claude", "projects");
1729
+ if (!existsSync(claudeProjectsDir)) return null;
1730
+ const expectedEncoded = cwd.replace(/[/\s.\-]/g, "-");
1731
+ let encodedDir = null;
1732
+ try {
1733
+ const entries = readdirSync(claudeProjectsDir);
1734
+ if (entries.includes(expectedEncoded)) encodedDir = expectedEncoded;
1735
+ else for (const entry of entries) {
1736
+ const full = join(claudeProjectsDir, entry);
1737
+ try {
1738
+ if (!statSync(full).isDirectory()) continue;
1739
+ } catch {
1740
+ continue;
1741
+ }
1742
+ if (entry.replace(/-+$/, "") === expectedEncoded.replace(/-+$/, "")) {
1743
+ encodedDir = entry;
1744
+ break;
1745
+ }
1746
+ }
1747
+ } catch {
1748
+ return null;
1749
+ }
1750
+ if (!encodedDir) return null;
1751
+ const notesDir = join(claudeProjectsDir, encodedDir, "Notes");
1752
+ return existsSync(notesDir) ? notesDir : null;
1753
+ }
1754
+ function findLatestNoteFile(notesDir) {
1755
+ let entries;
1756
+ try {
1757
+ entries = readdirSync(notesDir);
1758
+ } catch {
1759
+ return null;
1760
+ }
1761
+ const mdFiles = entries.filter((e) => e.endsWith(".md"));
1762
+ if (mdFiles.length === 0) return null;
1763
+ let latestPath = null;
1764
+ let latestMtime = 0;
1765
+ for (const file of mdFiles) {
1766
+ const full = join(notesDir, file);
1767
+ try {
1768
+ const { mtimeMs } = statSync(full);
1769
+ if (mtimeMs > latestMtime) {
1963
1770
  latestMtime = mtimeMs;
1964
1771
  latestPath = full;
1965
1772
  }
@@ -1967,10 +1774,6 @@ function findLatestNoteFile(notesDir) {
1967
1774
  }
1968
1775
  return latestPath;
1969
1776
  }
1970
- /**
1971
- * Rate-limit guard: returns true if the last checkpoint was written less
1972
- * than `minGapSeconds` ago, using a temp file keyed to the notes directory.
1973
- */
1974
1777
  function checkpointTooRecent(notesDir, minGapSeconds) {
1975
1778
  const safeKey = notesDir.replace(/[^a-zA-Z0-9]/g, "-").slice(-80);
1976
1779
  const tmpFile = join(tmpdir(), `pai-checkpoint-${safeKey}`);
@@ -1982,9 +1785,6 @@ function checkpointTooRecent(notesDir, minGapSeconds) {
1982
1785
  return false;
1983
1786
  }
1984
1787
  }
1985
- /**
1986
- * Touch the rate-limit sentinel file.
1987
- */
1988
1788
  function touchCheckpointSentinel(notesDir) {
1989
1789
  const safeKey = notesDir.replace(/[^a-zA-Z0-9]/g, "-").slice(-80);
1990
1790
  const tmpFile = join(tmpdir(), `pai-checkpoint-${safeKey}`);
@@ -1992,12 +1792,6 @@ function touchCheckpointSentinel(notesDir) {
1992
1792
  writeFileSync(tmpFile, String(Date.now()), "utf8");
1993
1793
  } catch {}
1994
1794
  }
1995
- /**
1996
- * Append a timestamped checkpoint block to the active session note.
1997
- *
1998
- * Designed to be called from Claude Code hooks (PostToolUse,
1999
- * UserPromptSubmit). Fast, silent, exit 0 on success or skip.
2000
- */
2001
1795
  function cmdCheckpoint(message, opts) {
2002
1796
  const minGapSeconds = parseInt(opts.minGap ?? "300", 10);
2003
1797
  const notesDir = findNotesDirForCwd();
@@ -2019,19 +1813,15 @@ function cmdCheckpoint(message, opts) {
2019
1813
  touchCheckpointSentinel(notesDir);
2020
1814
  process.exit(0);
2021
1815
  }
2022
- /**
2023
- * TODO candidate locations searched in priority order (mirrors toolProjectTodo).
2024
- */
1816
+
1817
+ //#endregion
1818
+ //#region src/cli/commands/session/handover.ts
2025
1819
  const HANDOVER_TODO_LOCATIONS = [
2026
1820
  "Notes/TODO.md",
2027
1821
  ".claude/Notes/TODO.md",
2028
1822
  "tasks/todo.md",
2029
1823
  "TODO.md"
2030
1824
  ];
2031
- /**
2032
- * Find the TODO.md for a given project root path.
2033
- * Returns { path, content } for the first location that exists, or null.
2034
- */
2035
1825
  function findProjectTodo(rootPath) {
2036
1826
  for (const rel of HANDOVER_TODO_LOCATIONS) {
2037
1827
  const full = join(rootPath, rel);
@@ -2044,11 +1834,6 @@ function findProjectTodo(rootPath) {
2044
1834
  }
2045
1835
  return null;
2046
1836
  }
2047
- /**
2048
- * Strip any existing `## Continue` section (up to but not including the
2049
- * first `---` separator or next `##` heading that follows it).
2050
- * Returns the content with that section removed.
2051
- */
2052
1837
  function stripContinueSection(content) {
2053
1838
  const lines = content.split("\n");
2054
1839
  const startIdx = lines.findIndex((l) => l.trim() === "## Continue");
@@ -2068,18 +1853,10 @@ function stripContinueSection(content) {
2068
1853
  while (after.length > 0 && after[0].trim() === "") after.shift();
2069
1854
  return [...before, ...after].join("\n");
2070
1855
  }
2071
- /**
2072
- * Write (or overwrite) the `## Continue` section at the TOP of the TODO file.
2073
- *
2074
- * pai session handover [project-slug] [session-id|"latest"]
2075
- *
2076
- * Called from hooks (session-stop, pre-compact) with project-slug + "latest".
2077
- * Falls back to auto-detecting the project from cwd when no slug is supplied.
2078
- */
2079
1856
  function cmdHandover(db, projectSlug, numberOrLatest) {
2080
1857
  let project;
2081
1858
  if (projectSlug) {
2082
- project = getProject$1(db, projectSlug);
1859
+ project = db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(projectSlug);
2083
1860
  if (!project) process.exit(0);
2084
1861
  } else {
2085
1862
  const cwd = process.cwd();
@@ -2145,130 +1922,12 @@ function cmdHandover(db, projectSlug, numberOrLatest) {
2145
1922
  }
2146
1923
  process.exit(0);
2147
1924
  }
2148
- function cmdActive(db, opts) {
2149
- const minutes = parseInt(opts.minutes ?? "60", 10);
2150
- const cutoff = Date.now() - minutes * 60 * 1e3;
2151
- const claudeProjectsDir = join(homedir(), ".claude", "projects");
2152
- if (!existsSync(claudeProjectsDir)) {
2153
- console.log(err("Claude projects directory not found."));
2154
- return;
2155
- }
2156
- const active = [];
2157
- const entries = readdirSync(claudeProjectsDir);
2158
- for (const entry of entries) {
2159
- const projectDir = join(claudeProjectsDir, entry);
2160
- try {
2161
- if (!statSync(projectDir).isDirectory()) continue;
2162
- } catch {
2163
- continue;
2164
- }
2165
- let latestJsonl = null;
2166
- let latestMtime = 0;
2167
- try {
2168
- for (const file of readdirSync(projectDir)) {
2169
- if (!file.endsWith(".jsonl")) continue;
2170
- const filePath = join(projectDir, file);
2171
- try {
2172
- const mtime = statSync(filePath).mtimeMs;
2173
- if (mtime > latestMtime) {
2174
- latestMtime = mtime;
2175
- latestJsonl = filePath;
2176
- }
2177
- } catch {
2178
- continue;
2179
- }
2180
- }
2181
- } catch {
2182
- continue;
2183
- }
2184
- if (!latestJsonl || latestMtime < cutoff) continue;
2185
- const project = db.prepare("SELECT slug, display_name, root_path FROM projects WHERE encoded_dir = ?").get(entry);
2186
- active.push({
2187
- slug: project?.slug ?? entry,
2188
- displayName: project?.display_name ?? project?.slug ?? entry,
2189
- rootPath: project?.root_path ?? "",
2190
- encodedDir: entry,
2191
- lastModified: new Date(latestMtime),
2192
- jsonlFile: latestJsonl
2193
- });
2194
- }
2195
- active.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
2196
- const seen = /* @__PURE__ */ new Set();
2197
- const deduped = active.filter((a) => {
2198
- const key = a.slug.replace(/-\d+$/, "");
2199
- if (seen.has(key)) return false;
2200
- seen.add(key);
2201
- return true;
2202
- });
2203
- if (opts.json) {
2204
- console.log(JSON.stringify(deduped.map((a) => ({
2205
- slug: a.slug,
2206
- display_name: a.displayName,
2207
- root_path: a.rootPath,
2208
- last_modified: a.lastModified.toISOString()
2209
- })), null, 2));
2210
- return;
2211
- }
2212
- if (deduped.length === 0) {
2213
- console.log(dim(`No active sessions in the last ${minutes} minutes.`));
2214
- return;
2215
- }
2216
- console.log(header(`Currently Active Sessions`) + dim(` (modified in last ${minutes}min)`));
2217
- console.log();
2218
- const rows = deduped.map((a) => {
2219
- const time = a.lastModified.toTimeString().slice(0, 5);
2220
- const dirName = a.rootPath ? a.rootPath.replace(homedir(), "~").split("/").pop() ?? a.slug : a.slug;
2221
- return [
2222
- chalk.cyan(dirName),
2223
- dim(a.slug),
2224
- chalk.green(time)
2225
- ];
2226
- });
2227
- console.log(renderTable([
2228
- "Directory",
2229
- "Project",
2230
- "Last Active"
2231
- ], rows));
2232
- }
2233
- async function cmdAutoRoute(opts) {
2234
- const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("../auto-route-BG6I_4B1.mjs");
2235
- const { openRegistry } = await import("../db-BtuN768f.mjs").then((n) => n.t);
2236
- const { createStorageBackend } = await import("../factory-Bzcy70G9.mjs").then((n) => n.n);
2237
- const { loadConfig } = await import("../config-Cf92lGX_.mjs").then((n) => n.r);
2238
- const config = loadConfig();
2239
- const registryDb = openRegistry();
2240
- const federation = await createStorageBackend(config);
2241
- const targetCwd = opts.cwd ?? process.cwd();
2242
- const result = await autoRoute(registryDb, federation, targetCwd, opts.context);
2243
- if (!result) {
2244
- console.log();
2245
- console.log(warn(" No project match found for: " + targetCwd));
2246
- console.log();
2247
- console.log(dim(" Tried: path match, PAI.md marker walk") + (opts.context ? dim(", topic detection") : ""));
2248
- console.log();
2249
- console.log(dim(" Run 'pai project add .' to register this directory."));
2250
- console.log();
2251
- return;
2252
- }
2253
- if (opts.json) {
2254
- console.log(formatAutoRouteJson(result));
2255
- return;
2256
- }
2257
- console.log();
2258
- console.log(header(" PAI Auto-Route"));
2259
- console.log();
2260
- console.log(` ${bold("Project:")} ${result.display_name}`);
2261
- console.log(` ${bold("Slug:")} ${result.slug}`);
2262
- console.log(` ${bold("Root path:")} ${result.root_path}`);
2263
- console.log(` ${bold("Method:")} ${result.method}`);
2264
- console.log(` ${bold("Confidence:")} ${(result.confidence * 100).toFixed(0)}%`);
2265
- console.log();
2266
- console.log(ok(" Routed to: ") + bold(result.slug));
2267
- console.log();
2268
- }
1925
+
1926
+ //#endregion
1927
+ //#region src/cli/commands/session/index.ts
2269
1928
  function registerSessionCommands(sessionCmd, getDb) {
2270
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) => {
2271
- cmdList(getDb(), projectSlug, opts);
1930
+ cmdList$1(getDb(), projectSlug, opts);
2272
1931
  });
2273
1932
  sessionCmd.command("info <project-slug> <number>").description("Show full details for a specific session").action((projectSlug, number) => {
2274
1933
  cmdInfo(getDb(), projectSlug, number);
@@ -2300,7 +1959,7 @@ function registerSessionCommands(sessionCmd, getDb) {
2300
1959
  }
2301
1960
 
2302
1961
  //#endregion
2303
- //#region src/cli/commands/session-cleanup.ts
1962
+ //#region src/cli/commands/session-cleanup/types.ts
2304
1963
  const TEMPLATE_INDICATORS = [
2305
1964
  "<!-- PAI will add completed work here during session -->",
2306
1965
  "<!-- PAI will add completed work here -->",
@@ -2309,82 +1968,13 @@ const TEMPLATE_INDICATORS = [
2309
1968
  ];
2310
1969
  const MODERN_PATTERN = /^(\d{4}) - (\d{4}-\d{2}-\d{2}) - (.+)\.md$/;
2311
1970
  const LEGACY_PATTERN = /^(\d{4})_(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
2312
- function getAllProjects(db) {
2313
- return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE status = 'active' ORDER BY slug").all();
2314
- }
2315
- function getProject(db, slug) {
2316
- return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE slug = ?").get(slug);
2317
- }
2318
- function getProjectSessions(db, projectId) {
2319
- return db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number ASC").all(projectId);
2320
- }
2321
- /**
2322
- * Find the project-root Notes directory (e.g. {root}/Notes or {root}/.claude/Notes).
2323
- * Returns null if neither exists on disk.
2324
- */
2325
- function findRootNotesDir(rootPath) {
2326
- const canonical = join(rootPath, "Notes");
2327
- if (existsSync(canonical)) return canonical;
2328
- const alt = join(rootPath, ".claude", "Notes");
2329
- if (existsSync(alt)) return alt;
2330
- return null;
2331
- }
2332
- /**
2333
- * Find the Claude Code session notes directory for a project.
2334
- * Falls back to computing the path from encoded_dir if claude_notes_dir is not set.
2335
- * Returns null if the directory does not exist on disk, or if it is identical to
2336
- * rootNotesDir (to avoid processing the same directory twice).
2337
- */
2338
- function findClaudeNotesDir$1(project, rootNotesDir) {
2339
- const candidate = project.claude_notes_dir ?? join(homedir(), ".claude", "projects", project.encoded_dir, "Notes");
2340
- if (!existsSync(candidate)) return null;
2341
- if (rootNotesDir && candidate === rootNotesDir) return null;
2342
- return candidate;
2343
- }
2344
- /**
2345
- * Collect up to two distinct Notes/ directories for a project.
2346
- * Returns an array of existing, distinct paths in the order:
2347
- * 1. Root Notes/ (from project root_path)
2348
- * 2. Claude Code Notes/ (from claude_notes_dir or encoded_dir)
2349
- */
2350
- function findAllNotesDirs(project) {
2351
- const rootDir = findRootNotesDir(project.root_path);
2352
- const claudeDir = findClaudeNotesDir$1(project, rootDir);
2353
- const dirs = [];
2354
- if (rootDir) dirs.push(rootDir);
2355
- if (claudeDir) dirs.push(claudeDir);
2356
- return dirs;
2357
- }
2358
- /**
2359
- * Determine if a file's content is essentially empty (just the template).
2360
- *
2361
- * A file is template-only if:
2362
- * - It contains a template placeholder marker AND
2363
- * - The "Work Done" section has no real content after the placeholder
2364
- * (i.e., no lines with actual text beyond the placeholder comment itself)
2365
- */
2366
- function isTemplateOnly(content) {
2367
- if (!TEMPLATE_INDICATORS.some((ind) => content.includes(ind))) return false;
2368
- const lines = content.split("\n");
2369
- let inWorkDone = false;
2370
- for (const line of lines) {
2371
- const trimmed = line.trim();
2372
- if (trimmed === "## Work Done") {
2373
- inWorkDone = true;
2374
- continue;
2375
- }
2376
- if (trimmed.startsWith("## ") && inWorkDone) break;
2377
- if (!inWorkDone) continue;
2378
- if (!trimmed) continue;
2379
- if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) continue;
2380
- if (trimmed.startsWith("<!--")) continue;
2381
- if (trimmed === "-->") continue;
2382
- if (trimmed === "Session completed.") continue;
2383
- if (trimmed === "#Session" || trimmed === "**Tags:** #Session") continue;
2384
- return false;
2385
- }
2386
- return true;
2387
- }
1971
+
1972
+ //#endregion
1973
+ //#region src/cli/commands/session-cleanup/rename.ts
1974
+ /**
1975
+ * Auto-name extraction and string helpers for session-cleanup.
1976
+ * Derives a meaningful session title from Markdown content.
1977
+ */
2388
1978
  const META_PHRASE_PATTERNS = [
2389
1979
  /session initialized and ready for your instructions/i,
2390
1980
  /fresh session with no pending tasks/i,
@@ -2418,10 +2008,6 @@ const TITLE_CASE_MINOR_WORDS = new Set([
2418
2008
  "as",
2419
2009
  "nor"
2420
2010
  ]);
2421
- /**
2422
- * Strip markdown checkbox syntax, bullets, and inline formatting from a line.
2423
- * Returns the cleaned plain text, or null if the result is too short to be useful.
2424
- */
2425
2011
  function cleanMarkdownLine(raw) {
2426
2012
  let s = raw.trim();
2427
2013
  s = s.replace(/^[-*+]\s+\[[ xX]\]\s*/, "");
@@ -2435,17 +2021,9 @@ function cleanMarkdownLine(raw) {
2435
2021
  s = s.replace(/\s+/g, " ").trim();
2436
2022
  return s.length >= 4 ? s : null;
2437
2023
  }
2438
- /**
2439
- * Return true if the line contains only meta-status text that should not
2440
- * be used as a session title.
2441
- */
2442
2024
  function isMetaPhrase(text) {
2443
2025
  return META_PHRASE_PATTERNS.some((re) => re.test(text));
2444
2026
  }
2445
- /**
2446
- * Convert a string to Title Case, skipping minor words (articles, prepositions)
2447
- * except as the very first word.
2448
- */
2449
2027
  function toTitleCase(text) {
2450
2028
  return text.split(" ").map((word, i) => {
2451
2029
  const lower = word.toLowerCase();
@@ -2453,11 +2031,6 @@ function toTitleCase(text) {
2453
2031
  return word.charAt(0).toUpperCase() + word.slice(1);
2454
2032
  }).join(" ");
2455
2033
  }
2456
- /**
2457
- * Sanitize a string into a valid filename component.
2458
- * Strips chars that don't belong in filenames, collapses spaces, trims to
2459
- * 60 chars at a word boundary, then applies Title Case.
2460
- */
2461
2034
  function sanitizeName(raw) {
2462
2035
  let s = raw.replace(/[\/\\:*?"<>|#`]/g, "");
2463
2036
  s = s.replace(/\s+/g, " ").trim();
@@ -2469,16 +2042,6 @@ function sanitizeName(raw) {
2469
2042
  s = s.trim();
2470
2043
  return toTitleCase(s);
2471
2044
  }
2472
- /**
2473
- * Extract a meaningful auto-name from session content.
2474
- *
2475
- * Strategy (in priority order):
2476
- * 1. H2 content sections (## Work Done, ## Summary, etc.):
2477
- * look at the CONTENT under them for the first real work bullet.
2478
- * 2. Other descriptive H2 headings that aren't structural section names.
2479
- * 3. H1 heading — only if it is not a plain session-number line.
2480
- * 5. Fallback: "Unnamed Session".
2481
- */
2482
2045
  function extractAutoName(content) {
2483
2046
  const lines = content.split("\n");
2484
2047
  const CONTENT_SECTION_HEADINGS = new Set([
@@ -2562,28 +2125,65 @@ function extractAutoName(content) {
2562
2125
  }
2563
2126
  return "Unnamed Session";
2564
2127
  }
2565
- /**
2566
- * Format a 4-digit padded session number.
2567
- */
2128
+ /** Format a 4-digit padded session number. */
2568
2129
  function padNum(n) {
2569
2130
  return String(n).padStart(4, "0");
2570
2131
  }
2571
- /**
2572
- * Given the sessions still alive after deletion, build a renumber map.
2573
- * Returns a Map of oldNumber → newNumber.
2574
- */
2575
- function buildRenumberMap(survivors) {
2576
- const map = /* @__PURE__ */ new Map();
2577
- [...survivors].sort((a, b) => a.number - b.number).forEach((s, idx) => {
2578
- const newNum = idx + 1;
2579
- if (s.number !== newNum) map.set(s.number, newNum);
2580
- });
2581
- 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;
2582
2186
  }
2583
- /**
2584
- * Scan a single Notes/ directory and return all session candidates found in it.
2585
- * Looks in both the flat top-level and any YYYY/MM/ sub-directories.
2586
- */
2587
2187
  function scanNotesDir(notesDir, dbByFilename) {
2588
2188
  const candidates = [];
2589
2189
  let flatFiles = [];
@@ -2658,6 +2258,14 @@ function scanNotesDir(notesDir, dbByFilename) {
2658
2258
  }
2659
2259
  return candidates;
2660
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
+ }
2661
2269
  function analyzeProject(db, project) {
2662
2270
  const notesDirPaths = findAllNotesDirs(project);
2663
2271
  if (notesDirPaths.length === 0) return null;
@@ -2687,6 +2295,67 @@ function analyzeProject(db, project) {
2687
2295
  renumberMap: buildRenumberMap(allSurvivors)
2688
2296
  };
2689
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;
2357
+ }
2358
+ }
2690
2359
  async function displayDryRun(plans) {
2691
2360
  let totalDelete = 0;
2692
2361
  let totalRename = 0;
@@ -2755,74 +2424,6 @@ async function displayDryRun(plans) {
2755
2424
  console.log(warn(" This is a dry-run. Add --execute to apply changes."));
2756
2425
  console.log();
2757
2426
  }
2758
- /**
2759
- * Count how many files in the vector DB match the given old paths.
2760
- * Used for dry-run reporting. Returns 0 if Postgres is unavailable.
2761
- */
2762
- async function countVectorDbPaths(oldPaths) {
2763
- if (oldPaths.length === 0) return 0;
2764
- try {
2765
- const { loadConfig } = await import("../config-Cf92lGX_.mjs").then((n) => n.r);
2766
- const { PostgresBackend } = await import("../postgres-FXrHDPcE.mjs");
2767
- const config = loadConfig();
2768
- if (config.storageBackend !== "postgres") return 0;
2769
- const pgBackend = new PostgresBackend(config.postgres ?? {});
2770
- if (await pgBackend.testConnection()) {
2771
- await pgBackend.close();
2772
- return 0;
2773
- }
2774
- const pool = pgBackend.pool;
2775
- const placeholders = oldPaths.map((_, i) => `$${i + 1}`).join(", ");
2776
- const result = await pool.query(`SELECT COUNT(*)::text AS n FROM pai_files WHERE path IN (${placeholders})`, oldPaths);
2777
- await pgBackend.close();
2778
- return parseInt(result.rows[0]?.n ?? "0", 10);
2779
- } catch {
2780
- return 0;
2781
- }
2782
- }
2783
- /**
2784
- * Update file paths in pai_files and pai_chunks for all moved session notes.
2785
- * Returns the number of pai_files rows updated, or -1 on error.
2786
- *
2787
- * Both tables store path directly (no FK between them), so both must be updated.
2788
- */
2789
- async function updateVectorDbPaths(moves) {
2790
- if (moves.length === 0) return 0;
2791
- try {
2792
- const { loadConfig } = await import("../config-Cf92lGX_.mjs").then((n) => n.r);
2793
- const { PostgresBackend } = await import("../postgres-FXrHDPcE.mjs");
2794
- const config = loadConfig();
2795
- if (config.storageBackend !== "postgres") return 0;
2796
- const pgBackend = new PostgresBackend(config.postgres ?? {});
2797
- const connErr = await pgBackend.testConnection();
2798
- if (connErr) {
2799
- process.stderr.write(`[session-cleanup] Postgres unavailable (${connErr}). Skipping vector DB path update.\n`);
2800
- await pgBackend.close();
2801
- return 0;
2802
- }
2803
- const client = await pgBackend.pool.connect();
2804
- let filesUpdated = 0;
2805
- try {
2806
- await client.query("BEGIN", []);
2807
- for (const { oldPath, newPath } of moves) {
2808
- const filesResult = await client.query("UPDATE pai_files SET path = $1 WHERE path = $2", [newPath, oldPath]);
2809
- filesUpdated += filesResult.rowCount ?? 0;
2810
- await client.query("UPDATE pai_chunks SET path = $1 WHERE path = $2", [newPath, oldPath]);
2811
- }
2812
- await client.query("COMMIT", []);
2813
- } catch (e) {
2814
- await client.query("ROLLBACK", []);
2815
- throw e;
2816
- } finally {
2817
- client.release();
2818
- }
2819
- await pgBackend.close();
2820
- return filesUpdated;
2821
- } catch (e) {
2822
- process.stderr.write(`[session-cleanup] Failed to update vector DB paths: ${e}\n`);
2823
- return -1;
2824
- }
2825
- }
2826
2427
  async function executeCleanup(db, plans, skipReindex) {
2827
2428
  let deleted = 0;
2828
2429
  let renamed = 0;
@@ -3008,6 +2609,9 @@ async function executeCleanup(db, plans, skipReindex) {
3008
2609
  }
3009
2610
  console.log();
3010
2611
  }
2612
+
2613
+ //#endregion
2614
+ //#region src/cli/commands/session-cleanup/index.ts
3011
2615
  function registerSessionCleanupCommand(sessionCmd, getDb) {
3012
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) => {
3013
2617
  const db = getDb();
@@ -3043,45 +2647,9 @@ function registerSessionCleanupCommand(sessionCmd, getDb) {
3043
2647
  }
3044
2648
 
3045
2649
  //#endregion
3046
- //#region src/cli/commands/registry.ts
3047
- /**
3048
- * Recursively find all .md files in a directory, including YYYY/MM subdirectories
3049
- * created by session cleanup. Returns filenames (basename only).
3050
- */
3051
- function findNoteFiles(dir) {
3052
- const results = [];
3053
- if (!existsSync(dir)) return results;
3054
- for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".md")) results.push(entry.name);
3055
- else if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
3056
- const yearDir = join(dir, entry.name);
3057
- for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) if (monthEntry.isDirectory() && /^\d{2}$/.test(monthEntry.name)) {
3058
- const monthDir = join(yearDir, monthEntry.name);
3059
- for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) if (noteEntry.isFile() && noteEntry.name.endsWith(".md")) results.push(noteEntry.name);
3060
- }
3061
- }
3062
- return results;
3063
- }
3064
- const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
3065
- const PAI_CONFIG_DIR = join(homedir(), ".pai");
3066
- const PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, "config.json");
3067
- function loadConfig() {
3068
- if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };
3069
- try {
3070
- return JSON.parse(readFileSync(PAI_CONFIG_FILE, "utf8"));
3071
- } catch {
3072
- return { scan_dirs: [] };
3073
- }
3074
- }
3075
- function saveConfig(config) {
3076
- mkdirSync(PAI_CONFIG_DIR, { recursive: true });
3077
- writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
3078
- }
3079
- function resolveHome(p) {
3080
- if (p.startsWith("~/")) return join(homedir(), p.slice(2));
3081
- return resolve(p);
3082
- }
2650
+ //#region src/cli/commands/registry/utils.ts
3083
2651
  /**
3084
- * Upsert a project row. Returns { id, isNew }.
2652
+ * Upsert a project row. Returns { id, isNew }.
3085
2653
  *
3086
2654
  * Matching priority:
3087
2655
  * 1. root_path — most reliable; handles slug collisions
@@ -3131,9 +2699,7 @@ function upsertProject(db, slug, rootPath, encodedDir) {
3131
2699
  isNew: true
3132
2700
  };
3133
2701
  }
3134
- /**
3135
- * Upsert a session note. Returns true if newly inserted.
3136
- */
2702
+ /** Upsert a session note. Returns true if newly inserted. */
3137
2703
  function upsertSession(db, projectId, number, date, slug, title, filename) {
3138
2704
  if (db.prepare("SELECT id FROM sessions WHERE project_id = ? AND number = ?").get(projectId, number)) return false;
3139
2705
  const ts = now();
@@ -3142,6 +2708,46 @@ function upsertSession(db, projectId, number, date, slug, title, filename) {
3142
2708
  VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`).run(projectId, number, date, slug, title, filename, ts);
3143
2709
  return true;
3144
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
+ }
3145
2751
  function performScan(db) {
3146
2752
  const result = {
3147
2753
  projectsScanned: 0,
@@ -3173,7 +2779,7 @@ function performScan(db) {
3173
2779
  } catch {}
3174
2780
  const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, "Notes");
3175
2781
  if (existsSync(claudeNotesDir)) {
3176
- 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);
3177
2783
  }
3178
2784
  if (!existsSync(claudeNotesDir)) continue;
3179
2785
  const noteFiles = findNoteFiles(claudeNotesDir);
@@ -3203,7 +2809,7 @@ function performScan(db) {
3203
2809
  }
3204
2810
  }
3205
2811
  }
3206
- const config = loadConfig();
2812
+ const config = loadScanConfig();
3207
2813
  if (config.scan_dirs.length) for (const rawDir of config.scan_dirs) {
3208
2814
  const scanDir = resolveHome(rawDir);
3209
2815
  if (!existsSync(scanDir)) {
@@ -3281,7 +2887,7 @@ function performScan(db) {
3281
2887
  return result;
3282
2888
  }
3283
2889
  function cmdScan(db) {
3284
- const config = loadConfig();
2890
+ const config = loadScanConfig();
3285
2891
  console.log(dim("Scanning ~/.claude/projects/ ..."));
3286
2892
  if (config.scan_dirs.length) console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(", ")}`));
3287
2893
  console.log(dim("Scanning project-root Notes/ directories ..."));
@@ -3302,6 +2908,10 @@ function cmdScan(db) {
3302
2908
  if (result.skipped.length > 10) console.log(dim(` ... and ${result.skipped.length - 10} more`));
3303
2909
  }
3304
2910
  }
2911
+
2912
+ //#endregion
2913
+ //#region src/cli/commands/registry/migrate.ts
2914
+ /** Registry migrate command: import data from ~/.claude/session-registry.json. */
3305
2915
  const SESSION_REGISTRY_PATH = join(homedir(), ".claude", "session-registry.json");
3306
2916
  function cmdMigrate$1(db) {
3307
2917
  if (!existsSync(SESSION_REGISTRY_PATH)) {
@@ -3358,7 +2968,10 @@ function cmdMigrate$1(db) {
3358
2968
  if (errors.length > 5) console.log(dim(` ... and ${errors.length - 5} more`));
3359
2969
  }
3360
2970
  }
3361
- function cmdStats(db) {
2971
+
2972
+ //#endregion
2973
+ //#region src/cli/commands/registry/index.ts
2974
+ function cmdStats$1(db) {
3362
2975
  const totalProjects = db.prepare("SELECT COUNT(*) AS n FROM projects").get().n;
3363
2976
  const activeProjects = db.prepare("SELECT COUNT(*) AS n FROM projects WHERE status = 'active'").get().n;
3364
2977
  const archivedProjects = db.prepare("SELECT COUNT(*) AS n FROM projects WHERE status = 'archived'").get().n;
@@ -3394,10 +3007,6 @@ function cmdRebuild(db) {
3394
3007
  console.log(dim("Registry cleared. Re-scanning ..."));
3395
3008
  cmdScan(db);
3396
3009
  }
3397
- /**
3398
- * Print the project slug whose root_path matches the given filesystem path.
3399
- * Exits 0 on success, 1 if not found. Output is plain (for use in scripts).
3400
- */
3401
3010
  function cmdLookup(db, fsPath) {
3402
3011
  const resolved = resolve(fsPath);
3403
3012
  const row = db.prepare("SELECT slug FROM projects WHERE root_path = ?").get(resolved);
@@ -3405,9 +3014,9 @@ function cmdLookup(db, fsPath) {
3405
3014
  process.stdout.write(row.slug + "\n");
3406
3015
  }
3407
3016
  function registerRegistryCommands(registryCmd, getDb) {
3408
- 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) => {
3409
3018
  if (opts.showDirs) {
3410
- const config = loadConfig();
3019
+ const config = loadScanConfig();
3411
3020
  if (!config.scan_dirs.length) {
3412
3021
  console.log(dim(" No extra scan directories configured."));
3413
3022
  console.log(dim(" Use --add-dir <path> to add one."));
@@ -3418,7 +3027,7 @@ function registerRegistryCommands(registryCmd, getDb) {
3418
3027
  return;
3419
3028
  }
3420
3029
  if (opts.addDir) {
3421
- const config = loadConfig();
3030
+ const config = loadScanConfig();
3422
3031
  const resolved = resolveHome(opts.addDir);
3423
3032
  if (!existsSync(resolved)) {
3424
3033
  console.error(err(`Directory not found: ${resolved}`));
@@ -3428,18 +3037,18 @@ function registerRegistryCommands(registryCmd, getDb) {
3428
3037
  if (config.scan_dirs.includes(display) || config.scan_dirs.includes(resolved)) console.log(warn(`Already configured: ${display}`));
3429
3038
  else {
3430
3039
  config.scan_dirs.push(display);
3431
- saveConfig(config);
3040
+ saveScanConfig(config);
3432
3041
  console.log(ok(`Added scan directory: ${bold(display)}`));
3433
3042
  }
3434
3043
  }
3435
3044
  if (opts.removeDir) {
3436
- const config = loadConfig();
3045
+ const config = loadScanConfig();
3437
3046
  const resolved = resolveHome(opts.removeDir);
3438
3047
  const display = resolved.startsWith(homedir()) ? "~" + resolved.slice(homedir().length) : resolved;
3439
3048
  const before = config.scan_dirs.length;
3440
3049
  config.scan_dirs = config.scan_dirs.filter((d) => resolveHome(d) !== resolved);
3441
3050
  if (config.scan_dirs.length < before) {
3442
- saveConfig(config);
3051
+ saveScanConfig(config);
3443
3052
  console.log(ok(`Removed scan directory: ${bold(display)}`));
3444
3053
  } else console.log(warn(`Not found in config: ${display}`));
3445
3054
  }
@@ -3449,7 +3058,7 @@ function registerRegistryCommands(registryCmd, getDb) {
3449
3058
  cmdMigrate$1(getDb());
3450
3059
  });
3451
3060
  registryCmd.command("stats").description("Show summary statistics for the registry").action(() => {
3452
- cmdStats(getDb());
3061
+ cmdStats$1(getDb());
3453
3062
  });
3454
3063
  registryCmd.command("rebuild").description("Erase all registry data and rebuild from the filesystem (destructive)").action(() => {
3455
3064
  cmdRebuild(getDb());
@@ -3460,25 +3069,7 @@ function registerRegistryCommands(registryCmd, getDb) {
3460
3069
  }
3461
3070
 
3462
3071
  //#endregion
3463
- //#region src/cli/commands/memory.ts
3464
- /**
3465
- * CLI commands for the PAI memory engine (Phase 2 / Phase 2.5).
3466
- *
3467
- * Commands:
3468
- * pai memory index [project-slug] — index one or all projects
3469
- * pai memory embed [project-slug] — generate embeddings for un-embedded chunks
3470
- * pai memory search <query> — BM25/semantic/hybrid search across federation.db
3471
- * pai memory status [project-slug] — show index stats
3472
- */
3473
- function tierColor(tier) {
3474
- switch (tier) {
3475
- case "evergreen": return chalk.green(tier);
3476
- case "daily": return chalk.yellow(tier);
3477
- case "topic": return chalk.blue(tier);
3478
- case "session": return chalk.dim(tier);
3479
- default: return tier;
3480
- }
3481
- }
3072
+ //#region src/cli/commands/memory/embed.ts
3482
3073
  async function runEmbed(federation, projectId, projectSlug, batchSize = 50) {
3483
3074
  const label = projectSlug ? `project ${projectSlug}` : "all projects";
3484
3075
  console.log(dim(`Generating embeddings for ${label} (this may take a while on first run)...`));
@@ -3488,11 +3079,34 @@ async function runEmbed(federation, projectId, projectSlug, batchSize = 50) {
3488
3079
  process.stdout.write("\r");
3489
3080
  console.log(ok(`Done.`) + ` ${bold(String(chunksEmbedded))} chunks embedded`);
3490
3081
  }
3491
- 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) {
3492
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) => {
3493
3107
  const registryDb = getDb();
3494
3108
  if (!opts.direct && !projectSlug) try {
3495
- await new PaiClient(loadConfig$1().socketPath).triggerIndex();
3109
+ await new PaiClient(loadConfig().socketPath).triggerIndex();
3496
3110
  console.log(ok("Index triggered in daemon.") + dim(" Check daemon logs for progress."));
3497
3111
  console.log(dim(" Run `pai daemon logs` to watch progress."));
3498
3112
  return;
@@ -3523,24 +3137,20 @@ function registerMemoryCommands(memoryCmd, getDb) {
3523
3137
  if (opts.embed) await runEmbed(federation);
3524
3138
  }
3525
3139
  });
3526
- 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) => {
3527
- const registryDb = getDb();
3528
- let federation;
3529
- try {
3530
- federation = openFederation();
3531
- } catch (e) {
3532
- console.error(err(`Failed to open federation database: ${e}`));
3533
- process.exit(1);
3534
- }
3535
- if (projectSlug) {
3536
- const project = registryDb.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(projectSlug);
3537
- if (!project) {
3538
- console.error(err(`Project not found: ${projectSlug}`));
3539
- process.exit(1);
3540
- }
3541
- await runEmbed(federation, project.id, project.slug, parseInt(opts.batchSize ?? "50", 10));
3542
- } else await runEmbed(federation, void 0, void 0, parseInt(opts.batchSize ?? "50", 10));
3543
- });
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) {
3544
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) => {
3545
3155
  const registryDb = getDb();
3546
3156
  let federation;
@@ -3550,7 +3160,8 @@ function registerMemoryCommands(memoryCmd, getDb) {
3550
3160
  console.error(err(`Failed to open federation database: ${e}`));
3551
3161
  process.exit(1);
3552
3162
  }
3553
- const searchConfig = loadConfig$1().search;
3163
+ const config = loadConfig();
3164
+ const searchConfig = config.search;
3554
3165
  const maxResults = parseInt(opts.limit ?? String(searchConfig.defaultLimit), 10);
3555
3166
  const mode = opts.mode ?? searchConfig.mode;
3556
3167
  if (![
@@ -3576,7 +3187,7 @@ function registerMemoryCommands(memoryCmd, getDb) {
3576
3187
  let results;
3577
3188
  if (mode === "keyword") results = searchMemory(federation, query, searchOpts);
3578
3189
  else if (mode === "semantic" || mode === "hybrid") {
3579
- const backend = await createStorageBackend(loadConfig$1());
3190
+ const backend = await createStorageBackend(config);
3580
3191
  try {
3581
3192
  const { generateEmbedding } = await import("../embeddings-DGRAPAYb.mjs").then((n) => n.i);
3582
3193
  console.log(dim("Generating query embedding..."));
@@ -3627,13 +3238,13 @@ function registerMemoryCommands(memoryCmd, getDb) {
3627
3238
  return;
3628
3239
  }
3629
3240
  if (opts.rerank !== false) {
3630
- const { rerankResults } = await import("../reranker-D7bRAHi6.mjs").then((n) => n.r);
3241
+ const { rerankResults } = await import("../reranker-CMNZcfVx.mjs").then((n) => n.r);
3631
3242
  console.log(dim("Reranking with cross-encoder..."));
3632
3243
  results = await rerankResults(query, results, { topK: maxResults });
3633
3244
  }
3634
3245
  const recencyDays = parseInt(opts.recency ?? String(searchConfig.recencyBoostDays), 10);
3635
3246
  if (recencyDays > 0) {
3636
- const { applyRecencyBoost } = await import("../search-_oHfguA5.mjs").then((n) => n.o);
3247
+ const { applyRecencyBoost } = await import("../search-DC1qhkKn.mjs").then((n) => n.o);
3637
3248
  console.log(dim(`Applying recency boost (half-life: ${recencyDays} days)...`));
3638
3249
  results = applyRecencyBoost(results, recencyDays);
3639
3250
  }
@@ -3643,7 +3254,7 @@ function registerMemoryCommands(memoryCmd, getDb) {
3643
3254
  console.log(`\n ${bold(`Search results for: "${query}"`)}${modeLabel} ${dim(`(${withSlugs.length} found)`)}\n`);
3644
3255
  for (const result of withSlugs) {
3645
3256
  const projectLabel = result.projectSlug ? chalk.cyan(result.projectSlug) : chalk.cyan(String(result.projectId));
3646
- const tierLabel = tierColor(result.tier);
3257
+ const tierLabel = tierColor$1(result.tier);
3647
3258
  const scoreLabel = dim(`score: ${result.score.toFixed(4)}`);
3648
3259
  const locationLabel = dim(`${result.path}:${result.startLine}-${result.endLine}`);
3649
3260
  console.log(` ${projectLabel} ${tierLabel} ${locationLabel} ${scoreLabel}`);
@@ -3652,6 +3263,20 @@ function registerMemoryCommands(memoryCmd, getDb) {
3652
3263
  console.log();
3653
3264
  }
3654
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) {
3655
3280
  memoryCmd.command("status [project-slug]").description("Show memory index statistics").action((projectSlug) => {
3656
3281
  const registryDb = getDb();
3657
3282
  let federation;
@@ -3722,7 +3347,7 @@ function registerMemoryCommands(memoryCmd, getDb) {
3722
3347
  }
3723
3348
  });
3724
3349
  memoryCmd.command("settings [key] [value]").description("View or modify search settings in ~/.config/pai/config.json").action((key, value) => {
3725
- const search = loadConfig$1().search;
3350
+ const search = loadConfig().search;
3726
3351
  if (!key) {
3727
3352
  console.log(`\n ${bold("PAI Memory — Search Settings")}\n`);
3728
3353
  console.log(` ${bold("mode:")} ${search.mode}`);
@@ -3798,6 +3423,15 @@ function registerMemoryCommands(memoryCmd, getDb) {
3798
3423
  });
3799
3424
  }
3800
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
+
3801
3435
  //#endregion
3802
3436
  //#region src/cli/commands/mcp.ts
3803
3437
  const CLAUDE_JSON_PATH$1 = join(homedir(), ".claude.json");
@@ -3969,7 +3603,7 @@ function generatePlist(daemonBin) {
3969
3603
  `;
3970
3604
  }
3971
3605
  async function cmdStatus$2() {
3972
- const client = new PaiClient(loadConfig$1().socketPath);
3606
+ const client = new PaiClient(loadConfig().socketPath);
3973
3607
  try {
3974
3608
  const s = await client.status();
3975
3609
  console.log();
@@ -4159,8 +3793,8 @@ function cmdLogs(opts) {
4159
3793
  }
4160
3794
  function registerDaemonCommands(daemonCmd) {
4161
3795
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
4162
- const { serve } = await import("../daemon-2ND5WO2j.mjs").then((n) => n.t);
4163
- 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);
4164
3798
  ensureConfigDir();
4165
3799
  await serve(lc());
4166
3800
  });
@@ -4444,286 +4078,92 @@ function registerRestoreCommands(program) {
4444
4078
  status: err(`failed: ${e}`)
4445
4079
  });
4446
4080
  }
4447
- else results.push({
4448
- label: "Config",
4449
- status: warn("missing in backup — skipped")
4450
- });
4451
- if (hasFed) try {
4452
- copyFileSync(join(resolvedDir, "federation.db"), join(HOME, ".pai", "federation.db"));
4453
- results.push({
4454
- label: "Federation DB (legacy)",
4455
- status: ok("restored")
4456
- });
4457
- } catch (e) {
4458
- results.push({
4459
- label: "Federation DB (legacy)",
4460
- status: warn(`skipped: ${e}`)
4461
- });
4462
- }
4463
- if (hasSql && opts.postgres) {
4464
- console.log(dim(" Restoring Postgres database (this may take a while)..."));
4465
- try {
4466
- execSync(`docker inspect ${DOCKER_CONTAINER} --format='{{.State.Status}}'`, { stdio: "pipe" });
4467
- execSync(`docker exec ${DOCKER_CONTAINER} psql -U ${PG_USER} -c "DROP DATABASE IF EXISTS ${PG_DATABASE}; CREATE DATABASE ${PG_DATABASE} OWNER ${PG_USER};"`, {
4468
- stdio: "pipe",
4469
- shell: true
4470
- });
4471
- const sqlContent = readFileSync(join(resolvedDir, "postgres-pai.sql"), "utf8");
4472
- const psqlResult = spawnSync("docker", [
4473
- "exec",
4474
- "-i",
4475
- DOCKER_CONTAINER,
4476
- "psql",
4477
- "-U",
4478
- PG_USER,
4479
- PG_DATABASE
4480
- ], {
4481
- input: sqlContent,
4482
- encoding: "utf8",
4483
- stdio: [
4484
- "pipe",
4485
- "pipe",
4486
- "pipe"
4487
- ]
4488
- });
4489
- if (psqlResult.status !== 0) {
4490
- const errMsg = psqlResult.stderr?.split("\n")[0] ?? "unknown error";
4491
- results.push({
4492
- label: "Postgres DB",
4493
- status: err(`failed: ${errMsg}`)
4494
- });
4495
- } else results.push({
4496
- label: "Postgres DB",
4497
- status: ok("restored")
4498
- });
4499
- } catch (e) {
4500
- const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
4501
- results.push({
4502
- label: "Postgres DB",
4503
- status: err(`failed: ${msg}`)
4504
- });
4505
- console.log(warn(` Is Docker running with container '${DOCKER_CONTAINER}'?`));
4506
- }
4507
- } else if (!hasSql || !opts.postgres) {
4508
- const reason = !hasSql ? "no SQL dump in backup" : "--no-postgres";
4509
- results.push({
4510
- label: "Postgres DB",
4511
- status: dim(`skipped (${reason})`)
4512
- });
4513
- }
4514
- console.log(`\n${bold("Restore complete:")}\n`);
4515
- const labelWidth = Math.max(...results.map((r) => r.label.length)) + 2;
4516
- for (const r of results) console.log(` ${bold(r.label.padEnd(labelWidth))} ${r.status}`);
4517
- if (!results.some((r) => r.status.includes("\x1B[31m"))) {
4518
- console.log(`\n ${ok("All done.")} You may need to restart the PAI daemon.\n`);
4519
- console.log(` ${dim("Restart:")} pai daemon restart\n`);
4520
- } else {
4521
- console.log(`\n ${warn("Some items failed — check output above.")}\n`);
4522
- process.exit(1);
4523
- }
4524
- });
4525
- }
4526
-
4527
- //#endregion
4528
- //#region src/cli/commands/settings-manager.ts
4529
- /**
4530
- * settings-manager — merge-not-overwrite utility for ~/.claude/settings.json
4531
- *
4532
- * Provides safe, idempotent writes to Claude Code's settings.json:
4533
- * - env vars: added only if the key is absent (never overwrites)
4534
- * - hooks: appended per hookType, deduplicated by command string
4535
- * - statusLine: written only if the key is not already present
4536
- */
4537
- const CLAUDE_DIR = join(homedir(), ".claude");
4538
- const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
4539
- function readSettingsJson() {
4540
- if (!existsSync(SETTINGS_FILE)) return {};
4541
- try {
4542
- return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
4543
- } catch {
4544
- return {};
4545
- }
4546
- }
4547
- function writeSettingsJson(data) {
4548
- if (!existsSync(CLAUDE_DIR)) mkdirSync(CLAUDE_DIR, { recursive: true });
4549
- writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n", "utf-8");
4550
- }
4551
- /**
4552
- * Merge env vars — add keys that are absent, never overwrite existing ones.
4553
- */
4554
- function mergeEnv(settings, incoming, report) {
4555
- let changed = false;
4556
- const existing = typeof settings["env"] === "object" && settings["env"] !== null ? settings["env"] : {};
4557
- for (const [key, value] of Object.entries(incoming)) if (Object.prototype.hasOwnProperty.call(existing, key)) report.push(chalk.dim(` Skipped: env.${key} already set`));
4558
- else {
4559
- existing[key] = value;
4560
- report.push(chalk.green(` Added env: ${key}`));
4561
- changed = true;
4562
- }
4563
- settings["env"] = existing;
4564
- return changed;
4565
- }
4566
- /**
4567
- * Strip file extension from a command basename for extension-agnostic dedup.
4568
- * This ensures that e.g. "context-compression-hook.ts" and
4569
- * "context-compression-hook.mjs" are treated as the same hook.
4570
- */
4571
- function commandStem(cmd) {
4572
- return (cmd.split("/").pop() ?? cmd).replace(/\.(mjs|ts|js|sh)$/, "");
4573
- }
4574
- /**
4575
- * Collect every command string already registered for a given hookType.
4576
- * Stores full command, basename, AND extension-stripped stem for flexible
4577
- * matching (handles ${PAI_DIR}/Hooks/foo.sh vs /Users/.../Hooks/foo.sh,
4578
- * and .ts → .mjs migrations).
4579
- */
4580
- function existingCommandsForHookType(rules) {
4581
- const cmds = /* @__PURE__ */ new Set();
4582
- for (const rule of rules) for (const entry of rule.hooks) {
4583
- cmds.add(entry.command);
4584
- const base = entry.command.split("/").pop();
4585
- if (base) cmds.add(base);
4586
- cmds.add(commandStem(entry.command));
4587
- }
4588
- return cmds;
4589
- }
4590
- /**
4591
- * Find and remove an existing rule whose command has the same stem
4592
- * (extension-agnostic) as the incoming command. Returns true if a
4593
- * replacement was made. This handles .ts → .mjs migrations cleanly.
4594
- */
4595
- function replaceStaleHook(existingRules, incomingStem, incomingCommand, incomingMatcher) {
4596
- for (let i = 0; i < existingRules.length; i++) {
4597
- const rule = existingRules[i];
4598
- for (let j = 0; j < rule.hooks.length; j++) if (commandStem(rule.hooks[j].command) === incomingStem && rule.hooks[j].command !== incomingCommand) {
4599
- rule.hooks[j].command = incomingCommand;
4600
- if (incomingMatcher !== void 0) rule.matcher = incomingMatcher;
4601
- return true;
4602
- }
4603
- }
4604
- return false;
4605
- }
4606
- /**
4607
- * Merge hooks — append entries, deduplicating by command string.
4608
- * Extension-agnostic: a .mjs hook replaces an existing .ts hook with the
4609
- * same stem, ensuring clean .ts → .mjs migrations without duplicates.
4610
- */
4611
- function mergeHooks(settings, incoming, report) {
4612
- let changed = false;
4613
- const hooksSection = typeof settings["hooks"] === "object" && settings["hooks"] !== null ? settings["hooks"] : {};
4614
- for (const entry of incoming) {
4615
- const { hookType, matcher, command } = entry;
4616
- const existingRules = Array.isArray(hooksSection[hookType]) ? hooksSection[hookType] : [];
4617
- const basename = command.split("/").pop() ?? command;
4618
- const stem = commandStem(command);
4619
- const existingCmds = existingCommandsForHookType(existingRules);
4620
- if (existingCmds.has(command) || existingCmds.has(basename)) {
4621
- report.push(chalk.dim(` Skipped: hook ${hookType} → ${basename} already registered`));
4622
- continue;
4081
+ else results.push({
4082
+ label: "Config",
4083
+ status: warn("missing in backup — skipped")
4084
+ });
4085
+ if (hasFed) try {
4086
+ copyFileSync(join(resolvedDir, "federation.db"), join(HOME, ".pai", "federation.db"));
4087
+ results.push({
4088
+ label: "Federation DB (legacy)",
4089
+ status: ok("restored")
4090
+ });
4091
+ } catch (e) {
4092
+ results.push({
4093
+ label: "Federation DB (legacy)",
4094
+ status: warn(`skipped: ${e}`)
4095
+ });
4623
4096
  }
4624
- if (existingCmds.has(stem)) {
4625
- if (replaceStaleHook(existingRules, stem, command, matcher)) {
4626
- hooksSection[hookType] = existingRules;
4627
- report.push(chalk.yellow(` Upgraded: hook ${hookType} → ${basename} (replaced stale extension)`));
4628
- changed = true;
4629
- continue;
4097
+ if (hasSql && opts.postgres) {
4098
+ console.log(dim(" Restoring Postgres database (this may take a while)..."));
4099
+ try {
4100
+ execSync(`docker inspect ${DOCKER_CONTAINER} --format='{{.State.Status}}'`, { stdio: "pipe" });
4101
+ execSync(`docker exec ${DOCKER_CONTAINER} psql -U ${PG_USER} -c "DROP DATABASE IF EXISTS ${PG_DATABASE}; CREATE DATABASE ${PG_DATABASE} OWNER ${PG_USER};"`, {
4102
+ stdio: "pipe",
4103
+ shell: true
4104
+ });
4105
+ const sqlContent = readFileSync(join(resolvedDir, "postgres-pai.sql"), "utf8");
4106
+ const psqlResult = spawnSync("docker", [
4107
+ "exec",
4108
+ "-i",
4109
+ DOCKER_CONTAINER,
4110
+ "psql",
4111
+ "-U",
4112
+ PG_USER,
4113
+ PG_DATABASE
4114
+ ], {
4115
+ input: sqlContent,
4116
+ encoding: "utf8",
4117
+ stdio: [
4118
+ "pipe",
4119
+ "pipe",
4120
+ "pipe"
4121
+ ]
4122
+ });
4123
+ if (psqlResult.status !== 0) {
4124
+ const errMsg = psqlResult.stderr?.split("\n")[0] ?? "unknown error";
4125
+ results.push({
4126
+ label: "Postgres DB",
4127
+ status: err(`failed: ${errMsg}`)
4128
+ });
4129
+ } else results.push({
4130
+ label: "Postgres DB",
4131
+ status: ok("restored")
4132
+ });
4133
+ } catch (e) {
4134
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
4135
+ results.push({
4136
+ label: "Postgres DB",
4137
+ status: err(`failed: ${msg}`)
4138
+ });
4139
+ console.log(warn(` Is Docker running with container '${DOCKER_CONTAINER}'?`));
4630
4140
  }
4141
+ } else if (!hasSql || !opts.postgres) {
4142
+ const reason = !hasSql ? "no SQL dump in backup" : "--no-postgres";
4143
+ results.push({
4144
+ label: "Postgres DB",
4145
+ status: dim(`skipped (${reason})`)
4146
+ });
4631
4147
  }
4632
- const newRule = { hooks: [{
4633
- type: "command",
4634
- command
4635
- }] };
4636
- if (matcher !== void 0) newRule.matcher = matcher;
4637
- existingRules.push(newRule);
4638
- hooksSection[hookType] = existingRules;
4639
- report.push(chalk.green(` Added hook: ${hookType} ${basename}`));
4640
- changed = true;
4641
- }
4642
- settings["hooks"] = hooksSection;
4643
- return changed;
4644
- }
4645
- /**
4646
- * Merge statusLine — write only if the key is not already present.
4647
- */
4648
- function mergeStatusLine(settings, incoming, report) {
4649
- if (Object.prototype.hasOwnProperty.call(settings, "statusLine")) {
4650
- report.push(chalk.dim(" Skipped: statusLine already configured"));
4651
- return false;
4652
- }
4653
- settings["statusLine"] = { ...incoming };
4654
- report.push(chalk.green(" Added statusLine"));
4655
- return true;
4656
- }
4657
- /**
4658
- * Merge permissions — append allow/deny entries, deduplicating.
4659
- */
4660
- function mergePermissions(settings, incoming, report) {
4661
- let changed = false;
4662
- const perms = typeof settings["permissions"] === "object" && settings["permissions"] !== null ? settings["permissions"] : {};
4663
- for (const list of ["allow", "deny"]) {
4664
- const entries = incoming[list];
4665
- if (!entries || entries.length === 0) continue;
4666
- const existing = Array.isArray(perms[list]) ? perms[list] : [];
4667
- const existingSet = new Set(existing);
4668
- for (const entry of entries) if (existingSet.has(entry)) report.push(chalk.dim(` Skipped: permissions.${list} "${entry}" already present`));
4669
- else {
4670
- existing.push(entry);
4671
- existingSet.add(entry);
4672
- report.push(chalk.green(` Added permissions.${list}: ${entry}`));
4673
- changed = true;
4148
+ console.log(`\n${bold("Restore complete:")}\n`);
4149
+ const labelWidth = Math.max(...results.map((r) => r.label.length)) + 2;
4150
+ for (const r of results) console.log(` ${bold(r.label.padEnd(labelWidth))} ${r.status}`);
4151
+ if (!results.some((r) => r.status.includes("\x1B[31m"))) {
4152
+ console.log(`\n ${ok("All done.")} You may need to restart the PAI daemon.\n`);
4153
+ console.log(` ${dim("Restart:")} pai daemon restart\n`);
4154
+ } else {
4155
+ console.log(`\n ${warn("Some items failed — check output above.")}\n`);
4156
+ process.exit(1);
4674
4157
  }
4675
- perms[list] = existing;
4676
- }
4677
- settings["permissions"] = perms;
4678
- return changed;
4679
- }
4680
- /**
4681
- * Merge flags — set keys only if not already present, never overwrite.
4682
- */
4683
- function mergeFlags(settings, incoming, report) {
4684
- let changed = false;
4685
- for (const [key, value] of Object.entries(incoming)) if (Object.prototype.hasOwnProperty.call(settings, key)) report.push(chalk.dim(` Skipped: ${key} already set`));
4686
- else {
4687
- settings[key] = value;
4688
- report.push(chalk.green(` Added flag: ${key}`));
4689
- changed = true;
4690
- }
4691
- return changed;
4692
- }
4693
- /**
4694
- * Merge env vars, hooks, and/or a statusLine entry into ~/.claude/settings.json.
4695
- * Never overwrites existing values — only adds what is missing.
4696
- *
4697
- * Returns { changed, report } where report contains human-readable lines.
4698
- */
4699
- function mergeSettings(opts) {
4700
- const settings = readSettingsJson();
4701
- const report = [];
4702
- let changed = false;
4703
- if (opts.env !== void 0 && Object.keys(opts.env).length > 0) {
4704
- if (mergeEnv(settings, opts.env, report)) changed = true;
4705
- }
4706
- if (opts.hooks !== void 0 && opts.hooks.length > 0) {
4707
- if (mergeHooks(settings, opts.hooks, report)) changed = true;
4708
- }
4709
- if (opts.statusLine !== void 0) {
4710
- if (mergeStatusLine(settings, opts.statusLine, report)) changed = true;
4711
- }
4712
- if (opts.permissions !== void 0) {
4713
- if (mergePermissions(settings, opts.permissions, report)) changed = true;
4714
- }
4715
- if (opts.flags !== void 0 && Object.keys(opts.flags).length > 0) {
4716
- if (mergeFlags(settings, opts.flags, report)) changed = true;
4717
- }
4718
- if (changed) writeSettingsJson(settings);
4719
- return {
4720
- changed,
4721
- report
4722
- };
4158
+ });
4723
4159
  }
4724
4160
 
4725
4161
  //#endregion
4726
- //#region src/cli/commands/setup.ts
4162
+ //#region src/cli/commands/setup/utils.ts
4163
+ /**
4164
+ * Shared helpers for the PAI setup wizard: chalk colour shortcuts,
4165
+ * readline prompts, config read/write, and filesystem path finders.
4166
+ */
4727
4167
  const c = {
4728
4168
  bold: (s) => chalk.bold(s),
4729
4169
  dim: (s) => chalk.dim(s),
@@ -4764,10 +4204,7 @@ async function prompt(rl, question) {
4764
4204
  });
4765
4205
  });
4766
4206
  }
4767
- /**
4768
- * Prompt for a numbered menu selection.
4769
- * Returns the 0-based index of the selected option, or defaultIdx if empty input.
4770
- */
4207
+ /** Prompt for a numbered menu selection. Returns 0-based index. */
4771
4208
  async function promptMenu(rl, options, defaultIdx = 0) {
4772
4209
  for (let i = 0; i < options.length; i++) {
4773
4210
  const num = chalk.bold(` ${i + 1}.`);
@@ -4785,9 +4222,7 @@ async function promptMenu(rl, options, defaultIdx = 0) {
4785
4222
  console.log(c.warn(`Please enter a number between 1 and ${options.length}.`));
4786
4223
  }
4787
4224
  }
4788
- /**
4789
- * Prompt for a yes/no answer. Returns true for yes.
4790
- */
4225
+ /** Prompt for a yes/no answer. Returns true for yes. */
4791
4226
  async function promptYesNo(rl, question, defaultYes = true) {
4792
4227
  const hint = defaultYes ? "[Y/n]" : "[y/N]";
4793
4228
  const answer = await prompt(rl, ` ${question} ${chalk.dim(hint)}: `);
@@ -4831,16 +4266,27 @@ function getDockerDir() {
4831
4266
  join(homedir(), "dev", "ai", "PAI", "docker"),
4832
4267
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "docker")
4833
4268
  ];
4834
- 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;
4835
4270
  return join(process.cwd(), "docker");
4836
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
+ }
4837
4283
  function getTemplatesDir$1() {
4838
4284
  const candidates = [
4839
4285
  join(process.cwd(), "templates"),
4840
4286
  join(homedir(), "dev", "ai", "PAI", "templates"),
4841
4287
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "templates")
4842
4288
  ];
4843
- 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;
4844
4290
  return join(process.cwd(), "templates");
4845
4291
  }
4846
4292
  function getHooksDir() {
@@ -4849,12 +4295,12 @@ function getHooksDir() {
4849
4295
  join(homedir(), "dev", "ai", "PAI", "src", "hooks"),
4850
4296
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "src", "hooks")
4851
4297
  ];
4852
- 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;
4853
4299
  return join(process.cwd(), "src", "hooks");
4854
4300
  }
4855
4301
  function getDistHooksDir() {
4856
4302
  const moduleDir = new URL(".", import.meta.url).pathname;
4857
- const fromModule = join(moduleDir, "..", "hooks");
4303
+ const fromModule = join(moduleDir, "..", "..", "hooks");
4858
4304
  const candidates = [
4859
4305
  fromModule,
4860
4306
  join(process.cwd(), "dist", "hooks"),
@@ -4870,7 +4316,7 @@ function getStatuslineScript() {
4870
4316
  join(homedir(), "dev", "ai", "PAI", "statusline-command.sh"),
4871
4317
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "statusline-command.sh")
4872
4318
  ];
4873
- for (const c of candidates) if (existsSync(c)) return c;
4319
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
4874
4320
  return null;
4875
4321
  }
4876
4322
  function getTabColorScript() {
@@ -4879,9 +4325,30 @@ function getTabColorScript() {
4879
4325
  join(homedir(), "dev", "ai", "PAI", "tab-color-command.sh"),
4880
4326
  join("/", "usr", "local", "lib", "node_modules", "@tekmidian", "pai", "tab-color-command.sh")
4881
4327
  ];
4882
- for (const c of candidates) if (existsSync(c)) return c;
4328
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
4883
4329
  return null;
4884
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. */
4885
4352
  async function startDocker(rl) {
4886
4353
  const dockerDir = getDockerDir();
4887
4354
  if (!existsSync(join(dockerDir, "docker-compose.yml"))) {
@@ -4903,44 +4370,14 @@ async function startDocker(rl) {
4903
4370
  console.log(c.warn(" Docker compose failed. You can start it manually:"));
4904
4371
  console.log(c.dim(` cd ${dockerDir} && docker compose up -d`));
4905
4372
  return false;
4906
- }
4907
- console.log(c.ok("PostgreSQL container started."));
4908
- return true;
4909
- } catch (e) {
4910
- console.log(c.warn(` Could not run docker compose: ${e}`));
4911
- return false;
4912
- }
4913
- }
4914
- async function testPostgresConnection(connectionString) {
4915
- try {
4916
- const pgModule = await import("pg");
4917
- const client = new (pgModule.default ?? pgModule).Client({ connectionString });
4918
- await client.connect();
4919
- await client.end();
4920
- return true;
4921
- } catch {
4922
- return false;
4923
- }
4924
- }
4925
- /**
4926
- * Step 1: Welcome banner and overview
4927
- */
4928
- function stepWelcome() {
4929
- line$1();
4930
- line$1(chalk.bold.cyan(" ╔════════════════════════════════════════╗"));
4931
- line$1(chalk.bold.cyan(" ║ PAI Knowledge OS — Setup Wizard ║"));
4932
- line$1(chalk.bold.cyan(" ╚════════════════════════════════════════╝"));
4933
- line$1();
4934
- line$1(" PAI is a personal knowledge system that indexes your files, generates");
4935
- line$1(" semantic embeddings for intelligent search, and stores everything in a");
4936
- line$1(" local database so you can search your knowledge base with natural language.");
4937
- line$1();
4938
- line$1(c.dim(" This wizard will guide you through the initial configuration."));
4939
- line$1(c.dim(" Press Ctrl+C at any time to cancel."));
4373
+ }
4374
+ console.log(c.ok("PostgreSQL container started."));
4375
+ return true;
4376
+ } catch (e) {
4377
+ console.log(c.warn(` Could not run docker compose: ${e}`));
4378
+ return false;
4379
+ }
4940
4380
  }
4941
- /**
4942
- * Step 2: Storage backend selection
4943
- */
4944
4381
  async function stepStorage(rl) {
4945
4382
  section("Step 2: Storage Backend");
4946
4383
  const existing = readConfigRaw();
@@ -5000,20 +4437,15 @@ async function stepStorage(rl) {
5000
4437
  await new Promise((r) => setTimeout(r, 3e3));
5001
4438
  const connStr = "postgresql://pai:pai@localhost:5432/pai";
5002
4439
  console.log(c.dim(` Testing connection to ${connStr}...`));
5003
- if (await testPostgresConnection(connStr)) {
5004
- console.log(c.ok("Connection successful!"));
5005
- return {
5006
- storageBackend: "postgres",
5007
- postgres: { connectionString: connStr }
5008
- };
5009
- } else {
4440
+ if (await testPostgresConnection(connStr)) console.log(c.ok("Connection successful!"));
4441
+ else {
5010
4442
  console.log(c.warn("Connection test failed. The container may still be starting."));
5011
4443
  console.log(c.dim(" Using default connection string — you can verify with `pai daemon status`."));
5012
- return {
5013
- storageBackend: "postgres",
5014
- postgres: { connectionString: connStr }
5015
- };
5016
4444
  }
4445
+ return {
4446
+ storageBackend: "postgres",
4447
+ postgres: { connectionString: connStr }
4448
+ };
5017
4449
  }
5018
4450
  } else console.log(c.dim(" Docker not found. Using manual connection string entry."));
5019
4451
  line$1();
@@ -5044,9 +4476,10 @@ async function stepStorage(rl) {
5044
4476
  postgres: { connectionString: connStr }
5045
4477
  };
5046
4478
  }
5047
- /**
5048
- * Step 3: Embedding model selection
5049
- */
4479
+
4480
+ //#endregion
4481
+ //#region src/cli/commands/setup/steps/03-embedding.ts
4482
+ /** Step 3: Embedding model selection for semantic search. */
5050
4483
  async function stepEmbedding(rl) {
5051
4484
  section("Step 3: Embedding Model");
5052
4485
  const existing = readConfigRaw();
@@ -5092,9 +4525,10 @@ async function stepEmbedding(rl) {
5092
4525
  }
5093
4526
  return { embeddingModel: selectedModel ?? "none" };
5094
4527
  }
5095
- /**
5096
- * Step 4: Agent configuration — generate ~/.claude/CLAUDE.md from template
5097
- */
4528
+
4529
+ //#endregion
4530
+ //#region src/cli/commands/setup/steps/04-claude-md.ts
4531
+ /** Step 4: CLAUDE.md generation from PAI template. */
5098
4532
  async function stepClaudeMd(rl) {
5099
4533
  section("Step 4: Agent Configuration (CLAUDE.md)");
5100
4534
  line$1();
@@ -5155,9 +4589,10 @@ async function stepClaudeMd(rl) {
5155
4589
  } else console.log(c.dim(" Personal preferences: " + agentPrefs));
5156
4590
  return true;
5157
4591
  }
5158
- /**
5159
- * Step 5: PAI skill installation (~/.claude/skills/PAI/SKILL.md)
5160
- */
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/. */
5161
4596
  async function stepPaiSkill(rl) {
5162
4597
  section("Step 5: PAI Skill Installation");
5163
4598
  line$1();
@@ -5201,9 +4636,10 @@ async function stepPaiSkill(rl) {
5201
4636
  console.log(c.ok("Installed ~/.claude/skills/PAI/SKILL.md"));
5202
4637
  return true;
5203
4638
  }
5204
- /**
5205
- * Step 6: AI Steering Rules installation (~/.claude/skills/PAI/AI-STEERING-RULES.md)
5206
- */
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/. */
5207
4643
  async function stepAiSteeringRules(rl) {
5208
4644
  section("Step 6: AI Steering Rules");
5209
4645
  line$1();
@@ -5248,59 +4684,10 @@ async function stepAiSteeringRules(rl) {
5248
4684
  console.log(c.ok("Installed ~/.claude/skills/PAI/AI-STEERING-RULES.md"));
5249
4685
  return true;
5250
4686
  }
5251
- /**
5252
- * Step 6b: CORE skill installation (~/.claude/Skills/CORE/)
5253
- *
5254
- * Installs the CORE skill template and supporting docs from templates/skills/CORE/
5255
- * to ~/.claude/Skills/CORE/. Also creates the user skills directory at
5256
- * ~/.claude/Skills/user/ if it doesn't exist.
5257
- */
5258
- async function stepCoreSkill(rl) {
5259
- section("Step 6b: CORE Skill Installation");
5260
- line$1();
5261
- line$1(" The CORE skill defines PAI's identity, response format, session commands,");
5262
- line$1(" compaction resilience, and operating principles. It auto-loads at session start.");
5263
- line$1();
5264
- const coreSrcDir = join(getTemplatesDir$1(), "skills", "CORE");
5265
- if (!existsSync(coreSrcDir)) {
5266
- console.log(c.warn("CORE skill template not found: " + coreSrcDir));
5267
- console.log(c.dim(" Skipping CORE skill installation."));
5268
- return false;
5269
- }
5270
- const skillsDir = join(homedir(), ".claude", "Skills");
5271
- const coreDestDir = join(skillsDir, "CORE");
5272
- const userSkillsDir = join(skillsDir, "user");
5273
- if (existsSync(join(coreDestDir, "SKILL.md"))) {
5274
- console.log(c.dim(" Found existing CORE skill at ~/.claude/Skills/CORE/"));
5275
- line$1();
5276
- if (!await promptYesNo(rl, "Update ~/.claude/Skills/CORE/ with the latest CORE skill templates?", true)) {
5277
- console.log(c.dim(" Keeping existing CORE skill unchanged."));
5278
- if (!existsSync(userSkillsDir)) {
5279
- mkdirSync(userSkillsDir, { recursive: true });
5280
- console.log(c.ok("Created ~/.claude/Skills/user/ for custom skills"));
5281
- }
5282
- return false;
5283
- }
5284
- } else if (!await promptYesNo(rl, "Install CORE skill to ~/.claude/Skills/CORE/?", true)) {
5285
- console.log(c.dim(" Skipping CORE skill installation."));
5286
- return false;
5287
- }
5288
- if (!existsSync(coreDestDir)) mkdirSync(coreDestDir, { recursive: true });
5289
- if (!existsSync(userSkillsDir)) mkdirSync(userSkillsDir, { recursive: true });
5290
- const files = readdirSync(coreSrcDir).filter((f) => f.endsWith(".md"));
5291
- let copied = 0;
5292
- for (const file of files) {
5293
- copyFileSync(join(coreSrcDir, file), join(coreDestDir, file));
5294
- copied++;
5295
- }
5296
- line$1();
5297
- console.log(c.ok(`Installed ${copied} CORE skill files to ~/.claude/Skills/CORE/`));
5298
- console.log(c.ok("Created ~/.claude/Skills/user/ for custom skills"));
5299
- return true;
5300
- }
5301
- /**
5302
- * Step 7: Hook scripts (pre-compact, session-stop, statusline)
5303
- */
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). */
5304
4691
  async function stepHooks(rl) {
5305
4692
  section("Step 7: Lifecycle Hooks");
5306
4693
  line$1();
@@ -5344,13 +4731,10 @@ async function stepHooks(rl) {
5344
4731
  else console.log(c.warn(" tab-color-command.sh not found — skipping tab color."));
5345
4732
  return anyInstalled;
5346
4733
  }
5347
- /**
5348
- * Step 7b: Install compiled TypeScript hooks (.mjs) to ~/.claude/Hooks/
5349
- *
5350
- * Finds all .mjs files in the package's dist/hooks/ directory and copies them
5351
- * to ~/.claude/Hooks/. Content is compared before copying — identical files are
5352
- * skipped for idempotent re-runs. Each installed file gets chmod 755.
5353
- */
4734
+
4735
+ //#endregion
4736
+ //#region src/cli/commands/setup/steps/09-ts-hooks.ts
4737
+ /** Step 7b: TypeScript (.mjs) hooks installation to ~/.claude/Hooks/. */
5354
4738
  async function stepTsHooks(rl) {
5355
4739
  section("Step 7b: TypeScript Hooks Installation");
5356
4740
  line$1();
@@ -5412,22 +4796,218 @@ async function stepTsHooks(rl) {
5412
4796
  cleanedCount++;
5413
4797
  }
5414
4798
  }
5415
- line$1();
5416
- if (copiedCount > 0 || cleanedCount > 0) {
5417
- const parts = [];
5418
- if (copiedCount > 0) parts.push(`${copiedCount} hook(s) installed`);
5419
- if (skippedCount > 0) parts.push(`${skippedCount} unchanged`);
5420
- if (cleanedCount > 0) parts.push(`${cleanedCount} stale .ts file(s) cleaned up`);
5421
- console.log(c.ok(parts.join(", ") + "."));
5422
- } else console.log(c.dim(` All ${skippedCount} hook(s) already up-to-date.`));
5423
- 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
+ };
5424
5006
  }
5425
- /**
5426
- * Step 8b: Prompt for the DA (Digital Assistant) name
5427
- *
5428
- * The name appears in tab titles, session notes, and hook output.
5429
- * Stored in env.DA via settings merge.
5430
- */
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. */
5431
5011
  async function stepDaName(rl) {
5432
5012
  section("Step 8b: Assistant Name");
5433
5013
  line$1();
@@ -5439,12 +5019,6 @@ async function stepDaName(rl) {
5439
5019
  console.log(c.ok(`Assistant name set to: ${daName}`));
5440
5020
  return daName;
5441
5021
  }
5442
- /**
5443
- * Step 8: Patch ~/.claude/settings.json with PAI hooks, env vars, permissions, and flags
5444
- *
5445
- * Registers all 17 hook entries across 8 event types, adds env vars including DA name,
5446
- * sets the statusline command, adds tool permissions (allow/deny), and sets flags.
5447
- */
5448
5022
  async function stepSettings(rl, daName) {
5449
5023
  section("Step 8: Settings Patch");
5450
5024
  line$1();
@@ -5603,9 +5177,10 @@ async function stepSettings(rl, daName) {
5603
5177
  if (!result.changed) console.log(c.dim(" Settings already up-to-date. No changes made."));
5604
5178
  return result.changed;
5605
5179
  }
5606
- /**
5607
- * Step 9: Daemon install (launchd plist)
5608
- */
5180
+
5181
+ //#endregion
5182
+ //#region src/cli/commands/setup/steps/11-daemon.ts
5183
+ /** Step 9: PAI daemon installation via launchd plist. */
5609
5184
  async function stepDaemon(rl) {
5610
5185
  section("Step 9: Daemon Install");
5611
5186
  line$1();
@@ -5630,9 +5205,10 @@ async function stepDaemon(rl) {
5630
5205
  console.log(c.ok("Daemon installed as com.pai.pai-daemon."));
5631
5206
  return true;
5632
5207
  }
5633
- /**
5634
- * Step 10: MCP registration in ~/.claude.json
5635
- */
5208
+
5209
+ //#endregion
5210
+ //#region src/cli/commands/setup/steps/12-mcp.ts
5211
+ /** Step 10: PAI MCP server registration in ~/.claude.json. */
5636
5212
  async function stepMcp(rl) {
5637
5213
  section("Step 10: MCP Registration");
5638
5214
  line$1();
@@ -5660,9 +5236,10 @@ async function stepMcp(rl) {
5660
5236
  console.log(c.ok("PAI MCP server registered in ~/.claude.json."));
5661
5237
  return true;
5662
5238
  }
5663
- /**
5664
- * Step 11: Directory scanning configuration
5665
- */
5239
+
5240
+ //#endregion
5241
+ //#region src/cli/commands/setup/steps/13-directories.ts
5242
+ /** Step 11: Directory scanning configuration and registry scan prompt. */
5666
5243
  async function stepDirectories(rl) {
5667
5244
  section("Step 11: Directories to Index");
5668
5245
  line$1();
@@ -5683,16 +5260,17 @@ async function stepDirectories(rl) {
5683
5260
  const runScan = await promptYesNo(rl, "Run `pai registry scan` to auto-detect projects after setup?", false);
5684
5261
  if (runScan) {
5685
5262
  line$1();
5686
- console.log(c.dim(" Registry scan will run after setup completes."));
5263
+ console.log(chalk.dim(" Registry scan will run after setup completes."));
5687
5264
  } else {
5688
- console.log(c.dim(" Add projects manually: pai project add <path>"));
5689
- 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"));
5690
5267
  }
5691
5268
  stepDirectories._runScan = runScan;
5692
5269
  }
5693
- /**
5694
- * Step 12: Initial index
5695
- */
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. */
5696
5274
  async function stepInitialIndex(rl) {
5697
5275
  section("Step 12: Initial Index");
5698
5276
  line$1();
@@ -5726,22 +5304,23 @@ async function stepInitialIndex(rl) {
5726
5304
  console.log(c.warn("Could not run registry scan. Run manually: pai registry scan"));
5727
5305
  }
5728
5306
  } else {
5729
- console.log(c.dim(" Start the daemon later: pai daemon serve"));
5730
- 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"));
5731
5309
  }
5732
5310
  else {
5733
- console.log(c.dim(" Register projects with: pai project add <path>"));
5734
- console.log(c.dim(" Then index them with: pai memory index --all"));
5735
- 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"));
5736
5314
  }
5737
5315
  }
5738
- /**
5739
- * Step 13: Summary and next steps
5740
- */
5741
- function stepSummary(configUpdates, claudeMdGenerated, paiSkillInstalled, aiSteeringRulesInstalled, coreSkillInstalled, hooksInstalled, tsHooksInstalled, settingsPatched, daName, daemonInstalled, mcpRegistered) {
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. */
5320
+ function stepSummary(configUpdates, claudeMdGenerated, paiSkillInstalled, aiSteeringRulesInstalled, hooksInstalled, tsHooksInstalled, settingsPatched, daName, daemonInstalled, mcpRegistered) {
5742
5321
  section("Setup Complete");
5743
5322
  line$1();
5744
- console.log(c.ok("PAI Knowledge OS is configured!"));
5323
+ console.log(chalk.green(" PAI Knowledge OS is configured!"));
5745
5324
  line$1();
5746
5325
  const backend = configUpdates.storageBackend;
5747
5326
  const model = configUpdates.embeddingModel;
@@ -5752,7 +5331,6 @@ function stepSummary(configUpdates, claudeMdGenerated, paiSkillInstalled, aiStee
5752
5331
  console.log(chalk.dim(" CLAUDE.md: ") + chalk.cyan(claudeMdGenerated ? "~/.claude/CLAUDE.md (generated)" : "(unchanged)"));
5753
5332
  console.log(chalk.dim(" PAI skill: ") + chalk.cyan(paiSkillInstalled ? "~/.claude/skills/PAI/SKILL.md (installed)" : "(unchanged)"));
5754
5333
  console.log(chalk.dim(" Steering rules: ") + chalk.cyan(aiSteeringRulesInstalled ? "~/.claude/skills/PAI/AI-STEERING-RULES.md (installed)" : "(unchanged)"));
5755
- console.log(chalk.dim(" CORE skill: ") + chalk.cyan(coreSkillInstalled ? "~/.claude/Skills/CORE/ (installed)" : "(unchanged)"));
5756
5334
  console.log(chalk.dim(" Hooks (shell): ") + chalk.cyan(hooksInstalled ? "pai-pre-compact.sh, pai-session-stop.sh (installed)" : "(unchanged)"));
5757
5335
  console.log(chalk.dim(" Hooks (TS): ") + chalk.cyan(tsHooksInstalled ? "14 .mjs hooks installed to ~/.claude/Hooks/" : "(unchanged)"));
5758
5336
  console.log(chalk.dim(" Assistant name: ") + chalk.cyan(daName));
@@ -5788,11 +5366,14 @@ function stepSummary(configUpdates, claudeMdGenerated, paiSkillInstalled, aiStee
5788
5366
  console.log(chalk.cyan(" pai --help"));
5789
5367
  line$1();
5790
5368
  }
5369
+
5370
+ //#endregion
5371
+ //#region src/cli/commands/setup/index.ts
5791
5372
  async function runSetup() {
5792
5373
  const rl = createRl();
5793
5374
  try {
5794
5375
  if (existsSync(CONFIG_FILE$2)) {
5795
- const current = loadConfig$1();
5376
+ const current = loadConfig();
5796
5377
  line$1();
5797
5378
  console.log(chalk.yellow(" Note: PAI is already configured.") + chalk.dim(" Proceeding will update your existing configuration."));
5798
5379
  console.log(chalk.dim(` Config: ${CONFIG_FILE$2}`));
@@ -5800,7 +5381,7 @@ async function runSetup() {
5800
5381
  line$1();
5801
5382
  if (!await promptYesNo(rl, "Continue and update configuration?", true)) {
5802
5383
  rl.close();
5803
- line$1(c.dim(" Setup cancelled."));
5384
+ line$1(chalk.dim(" Setup cancelled."));
5804
5385
  line$1();
5805
5386
  return;
5806
5387
  }
@@ -5813,7 +5394,6 @@ async function runSetup() {
5813
5394
  const claudeMdGenerated = await stepClaudeMd(rl);
5814
5395
  const paiSkillInstalled = await stepPaiSkill(rl);
5815
5396
  const aiSteeringRulesInstalled = await stepAiSteeringRules(rl);
5816
- const coreSkillInstalled = await stepCoreSkill(rl);
5817
5397
  const hooksInstalled = await stepHooks(rl);
5818
5398
  const tsHooksInstalled = await stepTsHooks(rl);
5819
5399
  const daName = await stepDaName(rl);
@@ -5827,9 +5407,9 @@ async function runSetup() {
5827
5407
  };
5828
5408
  mergeConfig(allUpdates);
5829
5409
  line$1();
5830
- console.log(c.ok("Configuration saved."));
5410
+ console.log(chalk.green(" Configuration saved."));
5831
5411
  await stepInitialIndex(rl);
5832
- stepSummary(allUpdates, claudeMdGenerated, paiSkillInstalled, aiSteeringRulesInstalled, coreSkillInstalled, hooksInstalled, tsHooksInstalled, settingsPatched, daName, daemonInstalled, mcpRegistered);
5412
+ stepSummary(allUpdates, claudeMdGenerated, paiSkillInstalled, aiSteeringRulesInstalled, hooksInstalled, tsHooksInstalled, settingsPatched, daName, daemonInstalled, mcpRegistered);
5833
5413
  } finally {
5834
5414
  rl.close();
5835
5415
  }
@@ -5841,11 +5421,9 @@ function registerSetupCommand(program) {
5841
5421
  }
5842
5422
 
5843
5423
  //#endregion
5844
- //#region src/obsidian/sync.ts
5845
- /**
5846
- * Find the project-root Notes directory for a project.
5847
- * Checks canonical location first, then .claude/Notes.
5848
- */
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. */
5849
5427
  function findNotesDir(rootPath) {
5850
5428
  const canonical = join(rootPath, "Notes");
5851
5429
  if (existsSync(canonical)) return canonical;
@@ -5855,8 +5433,7 @@ function findNotesDir(rootPath) {
5855
5433
  }
5856
5434
  /**
5857
5435
  * Find the Claude Code session notes directory from the registry-stored value.
5858
- * Returns null if not set or does not exist on disk.
5859
- * 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.
5860
5437
  */
5861
5438
  function findClaudeNotesDir(claudeNotesDirFromRegistry, notesDir) {
5862
5439
  if (!claudeNotesDirFromRegistry) return null;
@@ -5864,9 +5441,7 @@ function findClaudeNotesDir(claudeNotesDirFromRegistry, notesDir) {
5864
5441
  if (notesDir && claudeNotesDirFromRegistry === notesDir) return null;
5865
5442
  return claudeNotesDirFromRegistry;
5866
5443
  }
5867
- /**
5868
- * Check whether a path exists via lstat (does not follow symlinks).
5869
- */
5444
+ /** Check whether a path exists via lstat (does not follow symlinks). */
5870
5445
  function lstatExists(p) {
5871
5446
  try {
5872
5447
  lstatSync(p);
@@ -5875,9 +5450,7 @@ function lstatExists(p) {
5875
5450
  return false;
5876
5451
  }
5877
5452
  }
5878
- /**
5879
- * Resolve slug collisions by appending -2, -3, etc.
5880
- */
5453
+ /** Resolve slug collisions by appending -2, -3, etc. */
5881
5454
  function uniqueSlug(base, taken) {
5882
5455
  if (!taken.has(base)) return base;
5883
5456
  let n = 2;
@@ -5906,12 +5479,7 @@ function cleanBrokenSymlinks(dir) {
5906
5479
  }
5907
5480
  /**
5908
5481
  * Ensure a sub-symlink inside a project directory is correct.
5909
- *
5910
- * If the symlink already points to the right target, nothing changes.
5911
- * If it points somewhere else, it is removed and recreated.
5912
- * If the path is a non-symlink, it is left alone (data-loss prevention).
5913
- *
5914
- * @returns true if a new symlink was created, false otherwise.
5482
+ * Returns true if a new symlink was created, false otherwise.
5915
5483
  */
5916
5484
  function ensureSubSymlink(linkPath, target, errors, label) {
5917
5485
  if (lstatExists(linkPath)) try {
@@ -5936,14 +5504,8 @@ function ensureSubSymlink(linkPath, target, errors, label) {
5936
5504
  }
5937
5505
  /**
5938
5506
  * Migrate a legacy flat symlink at `slugPath` to a real directory.
5939
- *
5940
- * The old structure was: {vault}/{slug} → {notesDir}
5941
- * The new structure is: {vault}/{slug}/ (real dir)
5942
- * notes → {notesDir}
5943
- * sessions → {claudeNotesDir}
5944
- *
5945
5507
  * If `slugPath` is already a real directory, this is a no-op.
5946
- * 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.
5947
5509
  */
5948
5510
  function migrateToProjectDir(slugPath, errors, slug) {
5949
5511
  if (!lstatExists(slugPath)) return true;
@@ -5964,13 +5526,11 @@ function migrateToProjectDir(slugPath, errors, slug) {
5964
5526
  /**
5965
5527
  * Sync all active project Notes directories into the Obsidian vault.
5966
5528
  *
5967
- * For each active project that has at least one Notes source, creates:
5968
- *
5529
+ * For each active project with at least one Notes source, creates:
5969
5530
  * {vault}/{slug}/ — real directory
5970
- * notes → {root}/Notes/ (if project root Notes exists)
5971
- * sessions → ~/.claude/projects/{enc}/Notes/ (if Claude Code session notes exist)
5531
+ * notes → {root}/Notes/
5532
+ * sessions → ~/.claude/projects/{enc}/Notes/ (if different)
5972
5533
  *
5973
- * Projects with neither source are skipped.
5974
5534
  * Archived projects get a stub markdown file in {vault}/_archive/.
5975
5535
  */
5976
5536
  function syncVault(vaultPath, db) {
@@ -6032,6 +5592,10 @@ function syncVault(vaultPath, db) {
6032
5592
  }
6033
5593
  return stats;
6034
5594
  }
5595
+
5596
+ //#endregion
5597
+ //#region src/obsidian/sync/generate.ts
5598
+ /** Index and topic page generation — writes _index.md and _topics/{tag}.md. */
6035
5599
  /**
6036
5600
  * Generate _index.md listing all projects with session counts, tags, and
6037
5601
  * indicators for which note sources are available (notes, sessions, or both).
@@ -6126,14 +5690,14 @@ function generateTopicPages(vaultPath, db) {
6126
5690
  function defaultVaultPath() {
6127
5691
  return join(homedir(), ".pai", "obsidian-vault");
6128
5692
  }
5693
+
5694
+ //#endregion
5695
+ //#region src/obsidian/sync/walk.ts
5696
+ /** Directory walking and session file discovery for vault note generation. */
6129
5697
  const SESSION_FILENAME_RE = /^(\d{4}) - (\d{4}-\d{2})-\d{2} - .+\.md$/;
6130
- /** Build the per-project master note filename. */
6131
- function masterFilename(slug) {
6132
- return `_${slug}-master.md`;
6133
- }
6134
5698
  /**
6135
- * Walk a directory (non-recursive, then one level of YYYY/MM subdirs).
6136
- * 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.
6137
5701
  */
6138
5702
  function walkNotesDir(dir) {
6139
5703
  const results = [];
@@ -6181,7 +5745,7 @@ function walkNotesDir(dir) {
6181
5745
  }
6182
5746
  /**
6183
5747
  * Extract YYYY/MM from a session file path.
6184
- * 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.
6185
5749
  */
6186
5750
  function extractYearMonth(filePath) {
6187
5751
  const pathMatch = filePath.match(/\/(\d{4})\/(\d{2})\//);
@@ -6191,8 +5755,50 @@ function extractYearMonth(filePath) {
6191
5755
  return "unknown";
6192
5756
  }
6193
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
+ /**
6194
5801
  * Remove any old/broken backlink footer from a session file.
6195
- * Matches the old [[../_master|...]] pattern as well as any [[_{slug}-master|...]] footer.
6196
5802
  * Returns true if the file was modified.
6197
5803
  */
6198
5804
  function removeOldBacklink(filePath) {
@@ -6213,8 +5819,7 @@ function removeOldBacklink(filePath) {
6213
5819
  }
6214
5820
  /**
6215
5821
  * Append the master note backlink footer to a session file, idempotently.
6216
- * Uses Obsidian filename-based wikilink (resolves from anywhere in the vault).
6217
- * Only writes if the sentinel string is not already present in the file.
5822
+ * Only writes if the sentinel string is not already present.
6218
5823
  */
6219
5824
  function appendBacklinkIfMissing(filePath, slug, displayName) {
6220
5825
  let content;
@@ -6235,12 +5840,10 @@ function appendBacklinkIfMissing(filePath, slug, displayName) {
6235
5840
  }
6236
5841
  }
6237
5842
  /**
6238
- * Generate _master.md files for projects that have enough sessions.
5843
+ * Generate _master.md files for projects that have >= threshold session files.
6239
5844
  *
6240
- * For each project directory in the vault that has >= threshold session files,
6241
- * writes a {vaultPath}/{slug}/_master.md containing:
6242
- * - Project title
6243
- * - Session count + date range
5845
+ * For each qualifying project, writes a {vaultPath}/{slug}/_master.md containing:
5846
+ * - Project title, session count + date range
6244
5847
  * - Sessions grouped by YYYY/MM with Obsidian [[wikilinks]]
6245
5848
  *
6246
5849
  * Also appends a backlink footer to each session file (idempotently).
@@ -6264,34 +5867,7 @@ function generateMasterNotes(vaultPath, db, threshold = 5) {
6264
5867
  if (existsSync(legacyMaster)) try {
6265
5868
  unlinkSync(legacyMaster);
6266
5869
  } catch {}
6267
- const sessionFiles = [];
6268
- for (const subLink of ["notes", "sessions"]) {
6269
- const linkPath = join(slugPath, subLink);
6270
- if (!existsSync(linkPath)) continue;
6271
- let realDir;
6272
- try {
6273
- const stat = lstatSync(linkPath);
6274
- if (stat.isSymbolicLink()) realDir = readlinkSync(linkPath);
6275
- else if (stat.isDirectory()) realDir = linkPath;
6276
- else continue;
6277
- } catch {
6278
- continue;
6279
- }
6280
- const files = walkNotesDir(realDir);
6281
- for (const absPath of files) {
6282
- const basename = absPath.split("/").pop() ?? "";
6283
- if (!SESSION_FILENAME_RE.test(basename)) continue;
6284
- const vaultRelPath = `${subLink}/${relative(realDir, absPath)}`;
6285
- const wikilinkTarget = vaultRelPath.replace(/\.md$/, "");
6286
- sessionFiles.push({
6287
- absPath,
6288
- vaultRelPath,
6289
- wikilinkTarget,
6290
- yearMonth: extractYearMonth(absPath),
6291
- basename: basename.replace(/\.md$/, "")
6292
- });
6293
- }
6294
- }
5870
+ const sessionFiles = collectSessionFiles(slugPath);
6295
5871
  if (sessionFiles.length < threshold) continue;
6296
5872
  sessionFiles.sort((a, b) => {
6297
5873
  if (a.yearMonth !== b.yearMonth) return a.yearMonth.localeCompare(b.yearMonth);
@@ -6341,12 +5917,8 @@ function generateMasterNotes(vaultPath, db, threshold = 5) {
6341
5917
  /**
6342
5918
  * Remove the generic #Session tag from session note files across all projects.
6343
5919
  *
6344
- * Session notes are written with `**Tags:** #Session ...` but the generic #Session
6345
- * tag is not useful each note already belongs to a project. This function scans
6346
- * all session and notes directories for every active project and removes #Session
6347
- * (with or without a trailing space) from any `**Tags:**` line.
6348
- *
6349
- * 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.
6350
5922
  *
6351
5923
  * @param db Registry SQLite database
6352
5924
  * @returns Object with counts: { filesScanned, filesModified, errors }
@@ -6363,7 +5935,9 @@ function fixSessionTags(db) {
6363
5935
  ORDER BY slug ASC`).all();
6364
5936
  for (const project of projects) {
6365
5937
  const dirsToScan = [];
6366
- 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;
6367
5941
  if (notesDir) dirsToScan.push(notesDir);
6368
5942
  if (project.claude_notes_dir && existsSync(project.claude_notes_dir) && project.claude_notes_dir !== notesDir) dirsToScan.push(project.claude_notes_dir);
6369
5943
  for (const dir of dirsToScan) {
@@ -6699,8 +6273,13 @@ function registerObsidianCommands(obsidianCmd, getDb) {
6699
6273
  }
6700
6274
 
6701
6275
  //#endregion
6702
- //#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
+ }
6703
6281
  let _fedDb = null;
6282
+ /** Get (or lazily open) the PAI federation database. */
6704
6283
  function getFedDb() {
6705
6284
  if (!_fedDb) try {
6706
6285
  _fedDb = openFederation();
@@ -6710,15 +6289,14 @@ function getFedDb() {
6710
6289
  }
6711
6290
  return _fedDb;
6712
6291
  }
6713
- /** Shorten a vault path to just the last 2-3 components for display. */
6714
- function shortPath(p, parts = 3) {
6715
- return p.split("/").filter(Boolean).slice(-parts).join("/");
6716
- }
6292
+
6293
+ //#endregion
6294
+ //#region src/cli/commands/zettel/explore.ts
6717
6295
  async function cmdExplore(note, opts) {
6718
6296
  const depth = parseInt(opts.depth ?? "3", 10);
6719
6297
  const direction = opts.direction ?? "both";
6720
6298
  const mode = opts.mode ?? "all";
6721
- const { zettelExplore } = await import("../zettelkasten-e-a4rW_6.mjs");
6299
+ const { zettelExplore } = await import("../zettelkasten-cdajbnPr.mjs");
6722
6300
  const result = zettelExplore(getFedDb(), {
6723
6301
  startNote: note,
6724
6302
  depth,
@@ -6764,12 +6342,25 @@ async function cmdExplore(note, opts) {
6764
6342
  if (result.maxDepthReached) console.log(warn(" Max depth reached — use --depth to explore further"));
6765
6343
  console.log();
6766
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
6767
6358
  async function cmdHealth(opts) {
6768
6359
  const scope = opts.scope ?? "full";
6769
6360
  const projectPath = opts.project;
6770
6361
  const recentDays = parseInt(opts.days ?? "30", 10);
6771
6362
  const includeTypes = opts.include ? opts.include.split(",").map((s) => s.trim()) : void 0;
6772
- const { zettelHealth } = await import("../zettelkasten-e-a4rW_6.mjs");
6363
+ const { zettelHealth } = await import("../zettelkasten-cdajbnPr.mjs");
6773
6364
  const result = zettelHealth(getFedDb(), {
6774
6365
  scope,
6775
6366
  projectPath,
@@ -6816,6 +6407,19 @@ async function cmdHealth(opts) {
6816
6407
  }
6817
6408
  console.log();
6818
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
6819
6423
  async function cmdSurprise(note, opts) {
6820
6424
  if (!opts.vaultProjectId) {
6821
6425
  console.error(err(" --vault-project-id is required"));
@@ -6825,7 +6429,7 @@ async function cmdSurprise(note, opts) {
6825
6429
  const limit = parseInt(opts.limit ?? "10", 10);
6826
6430
  const minSimilarity = parseFloat(opts.minSimilarity ?? "0.3");
6827
6431
  const minGraphDistance = parseInt(opts.minDistance ?? "3", 10);
6828
- const { zettelSurprise } = await import("../zettelkasten-e-a4rW_6.mjs");
6432
+ const { zettelSurprise } = await import("../zettelkasten-cdajbnPr.mjs");
6829
6433
  const db = getFedDb();
6830
6434
  console.log();
6831
6435
  console.log(header(" PAI Zettel Surprise"));
@@ -6857,6 +6461,19 @@ async function cmdSurprise(note, opts) {
6857
6461
  console.log();
6858
6462
  }
6859
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
6860
6477
  async function cmdSuggest(note, opts) {
6861
6478
  if (!opts.vaultProjectId) {
6862
6479
  console.error(err(" --vault-project-id is required"));
@@ -6865,7 +6482,7 @@ async function cmdSuggest(note, opts) {
6865
6482
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
6866
6483
  const limit = parseInt(opts.limit ?? "5", 10);
6867
6484
  const excludeLinked = opts.excludeLinked !== false;
6868
- const { zettelSuggest } = await import("../zettelkasten-e-a4rW_6.mjs");
6485
+ const { zettelSuggest } = await import("../zettelkasten-cdajbnPr.mjs");
6869
6486
  const db = getFedDb();
6870
6487
  console.log();
6871
6488
  console.log(header(" PAI Zettel Suggest"));
@@ -6895,6 +6512,19 @@ async function cmdSuggest(note, opts) {
6895
6512
  console.log();
6896
6513
  }
6897
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
6898
6528
  async function cmdConverse(question, opts) {
6899
6529
  if (!opts.vaultProjectId) {
6900
6530
  console.error(err(" --vault-project-id is required"));
@@ -6903,7 +6533,7 @@ async function cmdConverse(question, opts) {
6903
6533
  const vaultProjectId = parseInt(opts.vaultProjectId, 10);
6904
6534
  const depth = parseInt(opts.depth ?? "2", 10);
6905
6535
  const limit = parseInt(opts.limit ?? "15", 10);
6906
- const { zettelConverse } = await import("../zettelkasten-e-a4rW_6.mjs");
6536
+ const { zettelConverse } = await import("../zettelkasten-cdajbnPr.mjs");
6907
6537
  const db = getFedDb();
6908
6538
  console.log();
6909
6539
  console.log(header(" PAI Zettel Converse"));
@@ -6941,6 +6571,19 @@ async function cmdConverse(question, opts) {
6941
6571
  for (const line of promptLines) console.log(` ${dim(line)}`);
6942
6572
  console.log();
6943
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
6944
6587
  async function cmdThemes(opts) {
6945
6588
  if (!opts.vaultProjectId) {
6946
6589
  console.error(err(" --vault-project-id is required"));
@@ -6951,7 +6594,7 @@ async function cmdThemes(opts) {
6951
6594
  const minClusterSize = parseInt(opts.minSize ?? "3", 10);
6952
6595
  const maxThemes = parseInt(opts.maxThemes ?? "10", 10);
6953
6596
  const similarityThreshold = parseFloat(opts.threshold ?? "0.65");
6954
- const { zettelThemes } = await import("../zettelkasten-e-a4rW_6.mjs");
6597
+ const { zettelThemes } = await import("../zettelkasten-cdajbnPr.mjs");
6955
6598
  const db = getFedDb();
6956
6599
  console.log();
6957
6600
  console.log(header(" PAI Zettel Themes"));
@@ -6989,50 +6632,260 @@ async function cmdThemes(opts) {
6989
6632
  console.log();
6990
6633
  }
6991
6634
  }
6992
- function registerZettelCommands(parent, _getDb) {
6993
- 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) => {
6994
6637
  try {
6995
- await cmdExplore(note, opts);
6638
+ await cmdThemes(opts);
6996
6639
  } catch (e) {
6997
6640
  console.error(err(` Error: ${e}`));
6998
6641
  process.exit(1);
6999
6642
  }
7000
6643
  });
7001
- 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) => {
7002
- try {
7003
- await cmdHealth(opts);
7004
- } catch (e) {
7005
- console.error(err(` Error: ${e}`));
7006
- process.exit(1);
7007
- }
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);
7008
6699
  });
7009
- 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) => {
7010
- try {
7011
- await cmdSurprise(note, opts);
7012
- } catch (e) {
7013
- console.error(err(` Error: ${e}`));
7014
- 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)}`);
7015
6817
  }
7016
- });
7017
- 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) => {
7018
6871
  try {
7019
- await cmdSuggest(note, opts);
6872
+ await cmdList(opts);
7020
6873
  } catch (e) {
7021
6874
  console.error(err(` Error: ${e}`));
7022
6875
  process.exit(1);
7023
6876
  }
7024
6877
  });
7025
- 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) => {
7026
6879
  try {
7027
- await cmdConverse(question, opts);
6880
+ await cmdSearch(query, opts);
7028
6881
  } catch (e) {
7029
6882
  console.error(err(` Error: ${e}`));
7030
6883
  process.exit(1);
7031
6884
  }
7032
6885
  });
7033
- 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 () => {
7034
6887
  try {
7035
- await cmdThemes(opts);
6888
+ await cmdStats();
7036
6889
  } catch (e) {
7037
6890
  console.error(err(` Error: ${e}`));
7038
6891
  process.exit(1);
@@ -7344,7 +7197,7 @@ function registerUpdateCommand(program) {
7344
7197
  //#endregion
7345
7198
  //#region src/cli/commands/notify.ts
7346
7199
  function makeClient$1() {
7347
- return new PaiClient(loadConfig$1().socketPath);
7200
+ return new PaiClient(loadConfig().socketPath);
7348
7201
  }
7349
7202
  function modeColor(mode) {
7350
7203
  switch (mode) {
@@ -7526,7 +7379,7 @@ function registerNotifyCommands(notifyCmd) {
7526
7379
  //#endregion
7527
7380
  //#region src/cli/commands/topic.ts
7528
7381
  function makeClient() {
7529
- return new PaiClient(loadConfig$1().socketPath);
7382
+ return new PaiClient(loadConfig().socketPath);
7530
7383
  }
7531
7384
  function confidenceBar(confidence, width = 20) {
7532
7385
  const filled = Math.round(confidence * width);
@@ -7651,6 +7504,7 @@ registerNotifyCommands(program.command("notify").description("Notification confi
7651
7504
  registerTopicCommands(program.command("topic").description("Topic shift detection: check whether context has drifted to a different project"));
7652
7505
  registerObsidianCommands(program.command("obsidian").description("Obsidian vault: sync project notes, view status, open in Obsidian"), getDb);
7653
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"));
7654
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) => {
7655
7509
  cmdGo(getDb(), query);
7656
7510
  });