@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.
- package/ARCHITECTURE.md +72 -1
- package/README.md +87 -1
- package/dist/{auto-route-BG6I_4B1.mjs → auto-route-C-DrW6BL.mjs} +3 -3
- package/dist/{auto-route-BG6I_4B1.mjs.map → auto-route-C-DrW6BL.mjs.map} +1 -1
- package/dist/cli/index.mjs +1482 -1628
- package/dist/cli/index.mjs.map +1 -1
- package/dist/clusters-JIDQW65f.mjs +201 -0
- package/dist/clusters-JIDQW65f.mjs.map +1 -0
- package/dist/{config-Cf92lGX_.mjs → config-BuhHWyOK.mjs} +21 -6
- package/dist/config-BuhHWyOK.mjs.map +1 -0
- package/dist/daemon/index.mjs +11 -8
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/{daemon-2ND5WO2j.mjs → daemon-D3hYb5_C.mjs} +669 -218
- package/dist/daemon-D3hYb5_C.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +4597 -4
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/db-DdUperSl.mjs +110 -0
- package/dist/db-DdUperSl.mjs.map +1 -0
- package/dist/{detect-BU3Nx_2L.mjs → detect-CdaA48EI.mjs} +1 -1
- package/dist/{detect-BU3Nx_2L.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
- package/dist/{detector-Bp-2SM3x.mjs → detector-jGBuYQJM.mjs} +2 -2
- package/dist/{detector-Bp-2SM3x.mjs.map → detector-jGBuYQJM.mjs.map} +1 -1
- package/dist/{factory-Bzcy70G9.mjs → factory-Ygqe_bVZ.mjs} +7 -5
- package/dist/{factory-Bzcy70G9.mjs.map → factory-Ygqe_bVZ.mjs.map} +1 -1
- package/dist/helpers-BEST-4Gx.mjs +420 -0
- package/dist/helpers-BEST-4Gx.mjs.map +1 -0
- package/dist/hooks/capture-all-events.mjs +2 -2
- package/dist/hooks/capture-all-events.mjs.map +3 -3
- package/dist/hooks/capture-session-summary.mjs +38 -0
- package/dist/hooks/capture-session-summary.mjs.map +3 -3
- package/dist/hooks/cleanup-session-files.mjs +6 -12
- package/dist/hooks/cleanup-session-files.mjs.map +4 -4
- package/dist/hooks/context-compression-hook.mjs +93 -104
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs +14 -11
- package/dist/hooks/initialize-session.mjs.map +4 -4
- package/dist/hooks/inject-observations.mjs +220 -0
- package/dist/hooks/inject-observations.mjs.map +7 -0
- package/dist/hooks/load-core-context.mjs +2 -2
- package/dist/hooks/load-core-context.mjs.map +3 -3
- package/dist/hooks/load-project-context.mjs +90 -91
- package/dist/hooks/load-project-context.mjs.map +4 -4
- package/dist/hooks/observe.mjs +354 -0
- package/dist/hooks/observe.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs +94 -107
- package/dist/hooks/stop-hook.mjs.map +4 -4
- package/dist/hooks/sync-todo-to-md.mjs +31 -33
- package/dist/hooks/sync-todo-to-md.mjs.map +4 -4
- package/dist/index.d.mts +30 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -8
- package/dist/indexer-D53l5d1U.mjs +1 -0
- package/dist/{indexer-backend-CIMXedqk.mjs → indexer-backend-jcJFsmB4.mjs} +37 -127
- package/dist/indexer-backend-jcJFsmB4.mjs.map +1 -0
- package/dist/{ipc-client-Bjg_a1dc.mjs → ipc-client-CoyUHPod.mjs} +2 -7
- package/dist/{ipc-client-Bjg_a1dc.mjs.map → ipc-client-CoyUHPod.mjs.map} +1 -1
- package/dist/latent-ideas-bTJo6Omd.mjs +191 -0
- package/dist/latent-ideas-bTJo6Omd.mjs.map +1 -0
- package/dist/neighborhood-BYYbEkUJ.mjs +135 -0
- package/dist/neighborhood-BYYbEkUJ.mjs.map +1 -0
- package/dist/note-context-BK24bX8Y.mjs +126 -0
- package/dist/note-context-BK24bX8Y.mjs.map +1 -0
- package/dist/postgres-CKf-EDtS.mjs +846 -0
- package/dist/postgres-CKf-EDtS.mjs.map +1 -0
- package/dist/{reranker-D7bRAHi6.mjs → reranker-CMNZcfVx.mjs} +1 -1
- package/dist/{reranker-D7bRAHi6.mjs.map → reranker-CMNZcfVx.mjs.map} +1 -1
- package/dist/{search-_oHfguA5.mjs → search-DC1qhkKn.mjs} +2 -58
- package/dist/search-DC1qhkKn.mjs.map +1 -0
- package/dist/{sqlite-WWBq7_2C.mjs → sqlite-l-s9xPjY.mjs} +160 -3
- package/dist/sqlite-l-s9xPjY.mjs.map +1 -0
- package/dist/state-C6_vqz7w.mjs +102 -0
- package/dist/state-C6_vqz7w.mjs.map +1 -0
- package/dist/stop-words-BaMEGVeY.mjs +326 -0
- package/dist/stop-words-BaMEGVeY.mjs.map +1 -0
- package/dist/{indexer-CMPOiY1r.mjs → sync-BOsnEj2-.mjs} +14 -216
- package/dist/sync-BOsnEj2-.mjs.map +1 -0
- package/dist/themes-BvYF0W8T.mjs +148 -0
- package/dist/themes-BvYF0W8T.mjs.map +1 -0
- package/dist/{tools-DV_lsiCc.mjs → tools-DcaJlYDN.mjs} +162 -273
- package/dist/tools-DcaJlYDN.mjs.map +1 -0
- package/dist/trace-CRx9lPuc.mjs +137 -0
- package/dist/trace-CRx9lPuc.mjs.map +1 -0
- package/dist/{vault-indexer-k-kUlaZ-.mjs → vault-indexer-Bi2cRmn7.mjs} +134 -132
- package/dist/vault-indexer-Bi2cRmn7.mjs.map +1 -0
- package/dist/zettelkasten-cdajbnPr.mjs +708 -0
- package/dist/zettelkasten-cdajbnPr.mjs.map +1 -0
- package/package.json +1 -2
- package/src/hooks/ts/lib/project-utils/index.ts +50 -0
- package/src/hooks/ts/lib/project-utils/notify.ts +75 -0
- package/src/hooks/ts/lib/project-utils/paths.ts +218 -0
- package/src/hooks/ts/lib/project-utils/session-notes.ts +363 -0
- package/src/hooks/ts/lib/project-utils/todo.ts +178 -0
- package/src/hooks/ts/lib/project-utils/tokens.ts +39 -0
- package/src/hooks/ts/lib/project-utils.ts +40 -1018
- package/src/hooks/ts/post-tool-use/observe.ts +327 -0
- package/src/hooks/ts/session-end/capture-session-summary.ts +41 -0
- package/src/hooks/ts/session-start/inject-observations.ts +254 -0
- package/dist/chunker-CbnBe0s0.mjs +0 -191
- package/dist/chunker-CbnBe0s0.mjs.map +0 -1
- package/dist/config-Cf92lGX_.mjs.map +0 -1
- package/dist/daemon-2ND5WO2j.mjs.map +0 -1
- package/dist/db-Dp8VXIMR.mjs +0 -212
- package/dist/db-Dp8VXIMR.mjs.map +0 -1
- package/dist/indexer-CMPOiY1r.mjs.map +0 -1
- package/dist/indexer-backend-CIMXedqk.mjs.map +0 -1
- package/dist/mcp/index.d.mts +0 -1
- package/dist/mcp/index.mjs +0 -500
- package/dist/mcp/index.mjs.map +0 -1
- package/dist/postgres-FXrHDPcE.mjs +0 -358
- package/dist/postgres-FXrHDPcE.mjs.map +0 -1
- package/dist/schemas-BFIgGntb.mjs +0 -3405
- package/dist/schemas-BFIgGntb.mjs.map +0 -1
- package/dist/search-_oHfguA5.mjs.map +0 -1
- package/dist/sqlite-WWBq7_2C.mjs.map +0 -1
- package/dist/tools-DV_lsiCc.mjs.map +0 -1
- package/dist/vault-indexer-k-kUlaZ-.mjs.map +0 -1
- package/dist/zettelkasten-e-a4rW_6.mjs +0 -901
- package/dist/zettelkasten-e-a4rW_6.mjs.map +0 -1
- package/templates/README.md +0 -181
- package/templates/skills/CORE/Aesthetic.md +0 -333
- package/templates/skills/CORE/CONSTITUTION.md +0 -1502
- package/templates/skills/CORE/HistorySystem.md +0 -427
- package/templates/skills/CORE/HookSystem.md +0 -1082
- package/templates/skills/CORE/Prompting.md +0 -509
- package/templates/skills/CORE/ProsodyAgentTemplate.md +0 -53
- package/templates/skills/CORE/ProsodyGuide.md +0 -416
- package/templates/skills/CORE/SKILL.md +0 -741
- package/templates/skills/CORE/SkillSystem.md +0 -213
- package/templates/skills/CORE/TerminalTabs.md +0 -119
- package/templates/skills/CORE/VOICE.md +0 -106
- package/templates/skills/createskill-skill.template.md +0 -78
- package/templates/skills/history-system.template.md +0 -371
- package/templates/skills/hook-system.template.md +0 -913
- package/templates/skills/sessions-skill.template.md +0 -102
- package/templates/skills/skill-system.template.md +0 -214
- package/templates/skills/terminal-tabs.template.md +0 -120
- package/templates/templates.md +0 -20
package/dist/cli/index.mjs
CHANGED
|
@@ -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-
|
|
7
|
-
import
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "../detect-
|
|
12
|
-
import
|
|
13
|
-
import { t as
|
|
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$
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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$
|
|
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
|
|
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
|
-
|
|
1911
|
-
|
|
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))
|
|
1920
|
-
|
|
1921
|
-
|
|
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
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
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
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
return
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
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
|
|
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(
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
4163
|
-
const { loadConfig: lc, ensureConfigDir } = await import("../config-
|
|
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 (
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
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
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
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
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
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
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
5263
|
+
console.log(chalk.dim(" Registry scan will run after setup completes."));
|
|
5687
5264
|
} else {
|
|
5688
|
-
console.log(
|
|
5689
|
-
console.log(
|
|
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
|
-
|
|
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(
|
|
5730
|
-
console.log(
|
|
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(
|
|
5734
|
-
console.log(
|
|
5735
|
-
console.log(
|
|
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
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
5410
|
+
console.log(chalk.green(" Configuration saved."));
|
|
5831
5411
|
await stepInitialIndex(rl);
|
|
5832
|
-
stepSummary(allUpdates, claudeMdGenerated, paiSkillInstalled, aiSteeringRulesInstalled,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
5968
|
-
*
|
|
5529
|
+
* For each active project with at least one Notes source, creates:
|
|
5969
5530
|
* {vault}/{slug}/ — real directory
|
|
5970
|
-
* notes → {root}/Notes/
|
|
5971
|
-
* sessions → ~/.claude/projects/{enc}/Notes/
|
|
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 (
|
|
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
|
-
*
|
|
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
|
|
5843
|
+
* Generate _master.md files for projects that have >= threshold session files.
|
|
6239
5844
|
*
|
|
6240
|
-
* For each project
|
|
6241
|
-
*
|
|
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
|
-
*
|
|
6345
|
-
*
|
|
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
|
|
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
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
6993
|
-
parent.command("
|
|
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
|
|
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
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
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
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
6886
|
+
parent.command("stats").description("Show observation statistics: totals, by type, by project").action(async () => {
|
|
7034
6887
|
try {
|
|
7035
|
-
await
|
|
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
|
|
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
|
|
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
|
});
|