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