@tekmidian/pai 0.9.15 → 0.10.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/README.md +123 -2
- package/dist/cli/index.mjs +993 -73
- package/dist/cli/index.mjs.map +1 -1
- package/dist/daemon/index.mjs +5 -5
- package/dist/{daemon-B-vBWU1i.mjs → daemon-CSKj8Xm9.mjs} +9 -9
- package/dist/{daemon-B-vBWU1i.mjs.map → daemon-CSKj8Xm9.mjs.map} +1 -1
- package/dist/{helpers-OCVFgprQ.mjs → helpers-crDEr6S2.mjs} +3 -3
- package/dist/helpers-crDEr6S2.mjs.map +1 -0
- package/dist/hooks/stop-hook.mjs +69 -0
- package/dist/hooks/stop-hook.mjs.map +2 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{indexer-backend-BrrCnasS.mjs → indexer-backend-DcYThufd.mjs} +2 -2
- package/dist/{indexer-backend-BrrCnasS.mjs.map → indexer-backend-DcYThufd.mjs.map} +1 -1
- package/dist/{kg-extraction-r5KkTmWY.mjs → kg-extraction-iR1BKKWK.mjs} +37 -3
- package/dist/kg-extraction-iR1BKKWK.mjs.map +1 -0
- package/dist/{sync-CdHSL9Kc.mjs → sync-CmBKOL3K.mjs} +2 -2
- package/dist/{sync-CdHSL9Kc.mjs.map → sync-CmBKOL3K.mjs.map} +1 -1
- package/dist/{vault-indexer-DsPC6wQf.mjs → vault-indexer-vNnL3Azb.mjs} +2 -2
- package/dist/{vault-indexer-DsPC6wQf.mjs.map → vault-indexer-vNnL3Azb.mjs.map} +1 -1
- package/dist/{work-queue-worker-94INtUiz.mjs → work-queue-worker-BxsBtz6j.mjs} +2 -2
- package/dist/{work-queue-worker-94INtUiz.mjs.map → work-queue-worker-BxsBtz6j.mjs.map} +1 -1
- package/package.json +1 -1
- package/src/hooks/ts/stop/stop-hook.ts +80 -0
- package/dist/helpers-OCVFgprQ.mjs.map +0 -1
- package/dist/kg-extraction-r5KkTmWY.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -4,19 +4,19 @@ import { _ as warn, a as fmtDate, c as ok, d as scaffoldProjectDirs, f as shorte
|
|
|
4
4
|
import { a as slugify$1, i as parseSessionFilename } from "../migrate-fLD6rAdO.mjs";
|
|
5
5
|
import { n as ensurePaiMarker } from "../pai-marker-CXQPX2P6.mjs";
|
|
6
6
|
import { n as openFederation } from "../db-CYmBWcjh.mjs";
|
|
7
|
-
import "../helpers-
|
|
8
|
-
import { i as indexProject, n as indexAll, t as embedChunks } from "../sync-
|
|
7
|
+
import "../helpers-crDEr6S2.mjs";
|
|
8
|
+
import { i as indexProject, n as indexAll, t as embedChunks } from "../sync-CmBKOL3K.mjs";
|
|
9
9
|
import "../embeddings-DGRAPAYb.mjs";
|
|
10
10
|
import { t as STOP_WORDS } from "../stop-words-BaMEGVeY.mjs";
|
|
11
11
|
import { n as populateSlugs, r as searchMemory } from "../search-i2nlQ-JM.mjs";
|
|
12
12
|
import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "../detect-CdaA48EI.mjs";
|
|
13
|
-
import { a as resolveHome, c as upsertSession, n as cmdScan, o as saveScanConfig, r as loadScanConfig, s as upsertProject, t as extractAndStoreTriples } from "../kg-extraction-
|
|
13
|
+
import { a as resolveHome, c as upsertSession, n as cmdScan, o as saveScanConfig, r as loadScanConfig, s as upsertProject, t as extractAndStoreTriples } from "../kg-extraction-iR1BKKWK.mjs";
|
|
14
14
|
import "../indexer-D7MvSQPY.mjs";
|
|
15
15
|
import { t as PaiClient } from "../ipc-client-C3pjwy2m.mjs";
|
|
16
16
|
import { a as expandHome, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig, t as CONFIG_DIR } from "../config-DqBY3aT0.mjs";
|
|
17
17
|
import { t as createStorageBackend } from "../factory-BufouUQ1.mjs";
|
|
18
18
|
import { s as kgQuery } from "../kg-entity-DVzTsy6G.mjs";
|
|
19
|
-
import { appendFileSync, chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { homedir, platform, tmpdir } from "node:os";
|
|
21
21
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
22
22
|
import chalk from "chalk";
|
|
@@ -218,6 +218,117 @@ function findProjectNotesDirs(project) {
|
|
|
218
218
|
} catch {}
|
|
219
219
|
return results;
|
|
220
220
|
}
|
|
221
|
+
const SKIP_DIRS = new Set([
|
|
222
|
+
".git",
|
|
223
|
+
"node_modules",
|
|
224
|
+
".next",
|
|
225
|
+
".nuxt",
|
|
226
|
+
"dist",
|
|
227
|
+
"build",
|
|
228
|
+
"coverage",
|
|
229
|
+
".cache",
|
|
230
|
+
"__pycache__",
|
|
231
|
+
"vendor",
|
|
232
|
+
".svn",
|
|
233
|
+
".hg",
|
|
234
|
+
"venv",
|
|
235
|
+
".venv",
|
|
236
|
+
"target",
|
|
237
|
+
"Notes"
|
|
238
|
+
]);
|
|
239
|
+
/**
|
|
240
|
+
* Walk `dir` up to `maxDepth` levels deep, collecting all subdirectory paths
|
|
241
|
+
* whose basename matches `targetBasename` (case-insensitive on macOS/case-sensitive
|
|
242
|
+
* on Linux, whichever is appropriate — we use exact case matching to keep it
|
|
243
|
+
* correct and fast).
|
|
244
|
+
*
|
|
245
|
+
* Skips hidden directories and known build/tool dirs.
|
|
246
|
+
* Stops walking a branch if the deadline is exceeded (returns partial results).
|
|
247
|
+
*/
|
|
248
|
+
function walkForBasename(dir, targetBasename, maxDepth, deadline, results) {
|
|
249
|
+
if (Date.now() > deadline) return;
|
|
250
|
+
if (maxDepth <= 0) return;
|
|
251
|
+
let entries;
|
|
252
|
+
try {
|
|
253
|
+
entries = readdirSync(dir);
|
|
254
|
+
} catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (Date.now() > deadline) return;
|
|
259
|
+
if (entry.startsWith(".")) continue;
|
|
260
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
261
|
+
const full = join(dir, entry);
|
|
262
|
+
let isDir = false;
|
|
263
|
+
try {
|
|
264
|
+
isDir = statSync(full).isDirectory();
|
|
265
|
+
} catch {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!isDir) continue;
|
|
269
|
+
if (entry.toLowerCase() === targetBasename.toLowerCase()) {
|
|
270
|
+
results.push(full);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
walkForBasename(full, targetBasename, maxDepth - 1, deadline, results);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Try to find where a project has moved.
|
|
278
|
+
*
|
|
279
|
+
* Searches scan_dirs from config (plus a set of common fallback dirs) for a
|
|
280
|
+
* directory whose basename matches the basename of the registered root_path.
|
|
281
|
+
*
|
|
282
|
+
* Returns:
|
|
283
|
+
* { found: string } — exactly one match found
|
|
284
|
+
* { ambiguous: string[] } — multiple matches found
|
|
285
|
+
* { found: undefined } — no match found (or timed out)
|
|
286
|
+
*/
|
|
287
|
+
function findMovedPath(registeredPath) {
|
|
288
|
+
const targetBasename = basename(registeredPath);
|
|
289
|
+
const config = loadScanConfig();
|
|
290
|
+
const deadline = Date.now() + 5e3;
|
|
291
|
+
const home = homedir();
|
|
292
|
+
const configDirs = (config.scan_dirs ?? []).map((d) => resolveHome(d));
|
|
293
|
+
function deepestExistingAncestor(p) {
|
|
294
|
+
let current = p;
|
|
295
|
+
while (true) {
|
|
296
|
+
const parent = join(current, "..");
|
|
297
|
+
if (parent === current) return null;
|
|
298
|
+
if (parent === home) return null;
|
|
299
|
+
if (existsSync(parent)) return parent;
|
|
300
|
+
current = parent;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const ancestorRoot = deepestExistingAncestor(registeredPath);
|
|
304
|
+
const fallbackDirs = [
|
|
305
|
+
join(home, "dev"),
|
|
306
|
+
join(home, "Desktop"),
|
|
307
|
+
join(home, "Projects"),
|
|
308
|
+
join(home, "Documents"),
|
|
309
|
+
join(home, "Cloud"),
|
|
310
|
+
join(home, "Daten", "Cloud"),
|
|
311
|
+
join(home, "Daten"),
|
|
312
|
+
join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"),
|
|
313
|
+
...ancestorRoot ? [ancestorRoot] : []
|
|
314
|
+
];
|
|
315
|
+
const searchRoots = [...new Set([...configDirs, ...fallbackDirs])].map((d) => {
|
|
316
|
+
try {
|
|
317
|
+
return resolve(d);
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}).filter((d) => d !== null && existsSync(d));
|
|
322
|
+
const results = [];
|
|
323
|
+
for (const root of searchRoots) {
|
|
324
|
+
if (Date.now() > deadline) break;
|
|
325
|
+
walkForBasename(root, targetBasename, 6, deadline, results);
|
|
326
|
+
}
|
|
327
|
+
const unique = [...new Set(results)];
|
|
328
|
+
if (unique.length === 0) return {};
|
|
329
|
+
if (unique.length === 1) return { found: unique[0] };
|
|
330
|
+
return { ambiguous: unique };
|
|
331
|
+
}
|
|
221
332
|
function cmdAdd(db, rawPath, opts) {
|
|
222
333
|
const rootPath = resolvePath(rawPath);
|
|
223
334
|
const slug = opts.slug ?? slugFromPath(rootPath);
|
|
@@ -272,7 +383,7 @@ function cmdList$3(db, opts) {
|
|
|
272
383
|
if (opts.status) {
|
|
273
384
|
where.push("p.status = ?");
|
|
274
385
|
params.push(opts.status);
|
|
275
|
-
}
|
|
386
|
+
} else if (!opts.all) where.push("p.status = 'active'");
|
|
276
387
|
if (opts.type) {
|
|
277
388
|
where.push("p.type = ?");
|
|
278
389
|
params.push(opts.type);
|
|
@@ -310,7 +421,9 @@ function cmdList$3(db, opts) {
|
|
|
310
421
|
"Last Active"
|
|
311
422
|
], tableRows));
|
|
312
423
|
console.log();
|
|
313
|
-
|
|
424
|
+
const hiddenCount = db.prepare("SELECT COUNT(*) AS cnt FROM projects").get().cnt - rows.length;
|
|
425
|
+
if (!opts.all && !opts.status && hiddenCount > 0) console.log(dim(` ${rows.length} active project(s) (${hiddenCount} archived — use --all to show)`));
|
|
426
|
+
else console.log(dim(` ${rows.length} project(s)`));
|
|
314
427
|
}
|
|
315
428
|
function cmdInfo$1(db, identifier) {
|
|
316
429
|
const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
|
|
@@ -518,6 +631,32 @@ function cmdConsolidate(db, identifier, opts) {
|
|
|
518
631
|
console.log();
|
|
519
632
|
console.log(ok(` Consolidated ${movedCount} file(s) into ${canonicalNotes}`));
|
|
520
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Attempt moved-path recovery for a project whose registered root_path is
|
|
636
|
+
* missing. Updates the DB if exactly one candidate is found.
|
|
637
|
+
*
|
|
638
|
+
* Returns the new path on success, or undefined if ambiguous/not found.
|
|
639
|
+
* Prints all messages to stderr (safe for shell-wrapper stdout capture).
|
|
640
|
+
*/
|
|
641
|
+
function tryRecoverMovedProject(db, project) {
|
|
642
|
+
process.stderr.write(warn(`Path not found: ${project.root_path}\n`) + dim(" Searching for moved location...\n"));
|
|
643
|
+
const result = findMovedPath(project.root_path);
|
|
644
|
+
if (result.found) {
|
|
645
|
+
const newPath = result.found;
|
|
646
|
+
const newEncoded = encodeDir(newPath);
|
|
647
|
+
const ts = now();
|
|
648
|
+
db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(newPath, newEncoded, ts, project.id);
|
|
649
|
+
process.stderr.write(ok(`Project moved: ${shortenPath(project.root_path, 50)}\n`) + dim(` → ${newPath}\n`) + ok("Registry updated.\n"));
|
|
650
|
+
return newPath;
|
|
651
|
+
}
|
|
652
|
+
if (result.ambiguous) {
|
|
653
|
+
process.stderr.write(warn(`Multiple directories named "${basename(project.root_path)}" found:\n`));
|
|
654
|
+
for (const candidate of result.ambiguous) process.stderr.write(dim(` ${candidate}\n`));
|
|
655
|
+
process.stderr.write(dim(`\n Disambiguate with: pai projects rebind ${project.slug} <path>\n`));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
process.stderr.write(err(`Project "${project.slug}" root_path "${project.root_path}" does not exist on disk\n and no folder named "${basename(project.root_path)}" was found in scan dirs.\n`) + dim(` Fix with: pai projects rebind ${project.slug} <new-path>\n`));
|
|
659
|
+
}
|
|
521
660
|
function cmdGo(db, query) {
|
|
522
661
|
const all = db.prepare("SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC").all();
|
|
523
662
|
if (!all.length) {
|
|
@@ -527,12 +666,31 @@ function cmdGo(db, query) {
|
|
|
527
666
|
const q = query.trim().toLowerCase();
|
|
528
667
|
const exact = getProject$2(db, query);
|
|
529
668
|
if (exact) {
|
|
669
|
+
if (!existsSync(exact.root_path)) {
|
|
670
|
+
const recovered = tryRecoverMovedProject(db, exact);
|
|
671
|
+
if (!recovered) {
|
|
672
|
+
process.exitCode = 1;
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
process.stdout.write(recovered + "\n");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
530
678
|
process.stdout.write(exact.root_path + "\n");
|
|
531
679
|
return;
|
|
532
680
|
}
|
|
533
681
|
const partial = all.filter((p) => containsIgnoreCase(p.slug, q) || containsIgnoreCase(p.display_name, q) || containsIgnoreCase(basename(p.root_path), q));
|
|
534
682
|
if (partial.length === 1) {
|
|
535
|
-
|
|
683
|
+
const p = partial[0];
|
|
684
|
+
if (!existsSync(p.root_path)) {
|
|
685
|
+
const recovered = tryRecoverMovedProject(db, p);
|
|
686
|
+
if (!recovered) {
|
|
687
|
+
process.exitCode = 1;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
process.stdout.write(recovered + "\n");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
process.stdout.write(p.root_path + "\n");
|
|
536
694
|
return;
|
|
537
695
|
}
|
|
538
696
|
if (partial.length > 1) {
|
|
@@ -563,6 +721,34 @@ function cmdGo(db, query) {
|
|
|
563
721
|
}
|
|
564
722
|
process.exit(1);
|
|
565
723
|
}
|
|
724
|
+
function cmdRebind(db, slug, newPath) {
|
|
725
|
+
const project = requireProject(db, slug);
|
|
726
|
+
const resolved = resolve(newPath.startsWith("~/") ? join(homedir(), newPath.slice(2)) : newPath);
|
|
727
|
+
if (!existsSync(resolved)) {
|
|
728
|
+
console.error(err(`Path does not exist: ${resolved}`));
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
let isDir = false;
|
|
732
|
+
try {
|
|
733
|
+
isDir = statSync(resolved).isDirectory();
|
|
734
|
+
} catch {}
|
|
735
|
+
if (!isDir) {
|
|
736
|
+
console.error(err(`Path is not a directory: ${resolved}`));
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
const newEncoded = encodeDir(resolved);
|
|
740
|
+
const conflict = db.prepare("SELECT slug FROM projects WHERE encoded_dir = ? AND id != ?").get(newEncoded, project.id);
|
|
741
|
+
if (conflict) {
|
|
742
|
+
console.error(err(`Path is already registered to project: ${bold(conflict.slug)}\n`) + dim(` ${resolved}\n`) + dim(` Archive or move that project first, or choose a different path.`));
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
const ts = now();
|
|
746
|
+
db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(resolved, newEncoded, ts, project.id);
|
|
747
|
+
console.log(ok(`Rebound: ${bold(slug)}`));
|
|
748
|
+
console.log(dim(` Old path: ${project.root_path}`));
|
|
749
|
+
console.log(dim(` New path: ${resolved}`));
|
|
750
|
+
console.log(dim(` Encoded: ${newEncoded}`));
|
|
751
|
+
}
|
|
566
752
|
|
|
567
753
|
//#endregion
|
|
568
754
|
//#region src/cli/commands/project/session-config.ts
|
|
@@ -1099,16 +1285,41 @@ function cmdHealth$1(db, opts) {
|
|
|
1099
1285
|
//#endregion
|
|
1100
1286
|
//#region src/cli/commands/project/projects-index.ts
|
|
1101
1287
|
function registerProjectsCommands(projectsCmd, getDb) {
|
|
1102
|
-
projectsCmd.command("list", { isDefault: true }).description("List registered projects. Short form: pai projects (bare, no subcommand)").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
|
|
1288
|
+
projectsCmd.command("list", { isDefault: true }).description("List registered projects. Short form: pai projects (bare, no subcommand)\nDefault: active projects only. Use --all to include archived.").option("--all", "Include archived projects (default: active only)").option("--status <status>", "Filter by status: active | archived").option("--tag <tag>", "Filter by tag").option("--type <type>", "Filter by type").action((opts) => {
|
|
1103
1289
|
cmdList$3(getDb(), opts);
|
|
1104
1290
|
});
|
|
1105
|
-
projectsCmd.command("cd <identifier>").description("cd to a project directory. Short form: pai cd <name>\n(The shell wrapper handles the actual cd; pure output here.)").action((identifier) => {
|
|
1106
|
-
const
|
|
1291
|
+
projectsCmd.command("cd <identifier>").description("cd to a project directory. Short form: pai cd <name>\n(The shell wrapper handles the actual cd; pure output here.)\nAuto-detects moved projects when the registered path no longer exists.").action((identifier) => {
|
|
1292
|
+
const db = getDb();
|
|
1293
|
+
const project = resolveIdentifier(db, identifier);
|
|
1107
1294
|
if (!project) {
|
|
1108
1295
|
console.error(`Project not found: ${identifier}`);
|
|
1109
1296
|
process.exit(1);
|
|
1297
|
+
return;
|
|
1110
1298
|
}
|
|
1111
|
-
|
|
1299
|
+
if (existsSync(project.root_path)) {
|
|
1300
|
+
process.stdout.write(project.root_path + "\n");
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
process.stderr.write(warn(`Path not found: ${project.root_path}\n`) + dim(" Searching for moved location...\n"));
|
|
1304
|
+
const result = findMovedPath(project.root_path);
|
|
1305
|
+
if (result.found) {
|
|
1306
|
+
const newPath = result.found;
|
|
1307
|
+
const newEncoded = encodeDir(newPath);
|
|
1308
|
+
const ts = now();
|
|
1309
|
+
db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(newPath, newEncoded, ts, project.id);
|
|
1310
|
+
process.stderr.write(ok(`Project moved: ${shortenPath(project.root_path, 50)}\n`) + dim(` → ${newPath}\n`) + ok("Registry updated.\n"));
|
|
1311
|
+
process.stdout.write(newPath + "\n");
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (result.ambiguous) {
|
|
1315
|
+
process.stderr.write(warn(`Multiple directories named "${basename(project.root_path)}" found:\n`));
|
|
1316
|
+
for (const candidate of result.ambiguous) process.stderr.write(dim(` ${candidate}\n`));
|
|
1317
|
+
process.stderr.write(dim(`\n Disambiguate with: pai projects rebind ${project.slug} <path>\n`));
|
|
1318
|
+
process.exitCode = 1;
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
process.stderr.write(err(`Project "${project.slug}" root_path "${project.root_path}" does not exist on disk\n and no folder named "${basename(project.root_path)}" was found in scan dirs.\n`) + dim(` Fix with: pai projects rebind ${project.slug} <new-path>\n`));
|
|
1322
|
+
process.exitCode = 1;
|
|
1112
1323
|
});
|
|
1113
1324
|
projectsCmd.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) => {
|
|
1114
1325
|
cmdAdd(getDb(), rawPath, opts);
|
|
@@ -1125,6 +1336,9 @@ function registerProjectsCommands(projectsCmd, getDb) {
|
|
|
1125
1336
|
projectsCmd.command("move <slug> <new-path>").description("Update the root path for a project").action((slug, newPath) => {
|
|
1126
1337
|
cmdMove(getDb(), slug, newPath);
|
|
1127
1338
|
});
|
|
1339
|
+
projectsCmd.command("rebind <slug> <new-path>").description("Manually update the root_path for a project (for when auto-detect found multiple matches).\nValidates the new path exists and is a directory, then updates the registry.").action((slug, newPath) => {
|
|
1340
|
+
cmdRebind(getDb(), slug, newPath);
|
|
1341
|
+
});
|
|
1128
1342
|
projectsCmd.command("tag <slug> <tags...>").description("Add one or more tags to a project").action((slug, tags) => {
|
|
1129
1343
|
cmdTag$1(getDb(), slug, tags);
|
|
1130
1344
|
});
|
|
@@ -2309,6 +2523,81 @@ function fmtAge(mtime) {
|
|
|
2309
2523
|
return `${Math.floor(diffDay / 30)}mo`;
|
|
2310
2524
|
}
|
|
2311
2525
|
/**
|
|
2526
|
+
* Filesystem-level UUID scan: walk ALL ~/.claude/projects/<encoded-dir>/<uuid>.jsonl
|
|
2527
|
+
* looking for top-level files whose UUID starts with `prefix`.
|
|
2528
|
+
*
|
|
2529
|
+
* Returns a ScannedSession-like object (minimal fields) for each match, or an empty
|
|
2530
|
+
* array if nothing is found. This is used as a fallback when the regular catalog
|
|
2531
|
+
* (limited to named/recent sessions) doesn't contain the requested UUID.
|
|
2532
|
+
*
|
|
2533
|
+
* Complexity: O(number of project directories + files per dir). Typically <200 dirs
|
|
2534
|
+
* with a handful of top-level jsonl each — fast enough for an interactive CLI.
|
|
2535
|
+
*/
|
|
2536
|
+
function scanFilesystemForUuidPrefix(prefix) {
|
|
2537
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) return [];
|
|
2538
|
+
const prefixLower = prefix.toLowerCase();
|
|
2539
|
+
const matches = [];
|
|
2540
|
+
let encodedDirs;
|
|
2541
|
+
try {
|
|
2542
|
+
encodedDirs = readdirSync(CLAUDE_PROJECTS_DIR);
|
|
2543
|
+
} catch {
|
|
2544
|
+
return [];
|
|
2545
|
+
}
|
|
2546
|
+
for (const encodedDir of encodedDirs) {
|
|
2547
|
+
const projectDir = join(CLAUDE_PROJECTS_DIR, encodedDir);
|
|
2548
|
+
try {
|
|
2549
|
+
if (!statSync(projectDir).isDirectory()) continue;
|
|
2550
|
+
} catch {
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
let files;
|
|
2554
|
+
try {
|
|
2555
|
+
files = readdirSync(projectDir);
|
|
2556
|
+
} catch {
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2559
|
+
for (const file of files) {
|
|
2560
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
2561
|
+
const uuid = file.slice(0, -6);
|
|
2562
|
+
if (!UUID_RE.test(uuid)) continue;
|
|
2563
|
+
if (!uuid.toLowerCase().startsWith(prefixLower)) continue;
|
|
2564
|
+
const topLevelPath = join(projectDir, file);
|
|
2565
|
+
const topInfo = parseTopLevel(topLevelPath);
|
|
2566
|
+
const resumable = topInfo.systemLines > 0;
|
|
2567
|
+
const decodedPath = smartDecodeDir(encodedDir) ?? encodedDir.replace(/-/g, "/");
|
|
2568
|
+
const sessionJsonlPath = join(projectDir, "sessions", `${uuid}.jsonl`);
|
|
2569
|
+
const hasTranscript = existsSync(sessionJsonlPath);
|
|
2570
|
+
const transcript = hasTranscript ? parseTranscript(sessionJsonlPath) : {
|
|
2571
|
+
userLines: 0,
|
|
2572
|
+
lastUserPrompt: "",
|
|
2573
|
+
msgCount: 0,
|
|
2574
|
+
mtime: 0
|
|
2575
|
+
};
|
|
2576
|
+
matches.push({
|
|
2577
|
+
uuid,
|
|
2578
|
+
shortId: uuid.slice(0, 8),
|
|
2579
|
+
encodedDir,
|
|
2580
|
+
decodedPath,
|
|
2581
|
+
topLevelPath,
|
|
2582
|
+
topLevelSystemLines: topInfo.systemLines,
|
|
2583
|
+
topLevelSize: topInfo.size,
|
|
2584
|
+
resumable,
|
|
2585
|
+
sessionStatus: resumable ? "resumable" : "stub",
|
|
2586
|
+
sessionJsonlPath: hasTranscript ? sessionJsonlPath : void 0,
|
|
2587
|
+
userLines: transcript.userLines,
|
|
2588
|
+
lastUserPrompt: transcript.lastUserPrompt,
|
|
2589
|
+
msgCount: transcript.msgCount,
|
|
2590
|
+
aiTitle: transcript.aiTitle,
|
|
2591
|
+
mtime: topInfo.mtime || transcript.mtime,
|
|
2592
|
+
friendlyName: transcript.aiTitle ?? basename(decodedPath),
|
|
2593
|
+
clcDirectory: void 0,
|
|
2594
|
+
registryRootPath: void 0
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2312
2601
|
* Resolve a name-or-id-or-prefix to a single ScannedSession.
|
|
2313
2602
|
*
|
|
2314
2603
|
* Comparisons are case-insensitive; stored casing is preserved in output.
|
|
@@ -2316,7 +2605,8 @@ function fmtAge(mtime) {
|
|
|
2316
2605
|
* Priority:
|
|
2317
2606
|
* 1. Exact case-insensitive match on friendlyName
|
|
2318
2607
|
* 2. Partial case-insensitive match (contains)
|
|
2319
|
-
* 3. UUID prefix match
|
|
2608
|
+
* 3. UUID prefix match against the in-memory catalog
|
|
2609
|
+
* 4. UUID prefix match against the full filesystem (fallback for any session)
|
|
2320
2610
|
*/
|
|
2321
2611
|
function resolveSessionByNameOrId(sessions, query) {
|
|
2322
2612
|
const qLower = query.toLowerCase().trim();
|
|
@@ -2343,7 +2633,18 @@ function resolveSessionByNameOrId(sessions, query) {
|
|
|
2343
2633
|
const candidates = byUuid.slice(0, 5).map((s, i) => ` ${i + 1}. ${s.shortId} ${s.friendlyName ?? s.decodedPath} (${fmtAge(s.mtime)} ago)`).join("\n");
|
|
2344
2634
|
throw new Error(`UUID prefix "${query}" is ambiguous — ${byUuid.length} matches:\n${candidates}\n\nProvide more characters.`);
|
|
2345
2635
|
}
|
|
2346
|
-
|
|
2636
|
+
if (/^[0-9a-f-]{4,36}$/i.test(qLower)) {
|
|
2637
|
+
const fsSessions = scanFilesystemForUuidPrefix(qLower);
|
|
2638
|
+
if (fsSessions.length === 1) return {
|
|
2639
|
+
session: fsSessions[0],
|
|
2640
|
+
friendlyName: fsSessions[0].friendlyName
|
|
2641
|
+
};
|
|
2642
|
+
if (fsSessions.length > 1) {
|
|
2643
|
+
const candidates = fsSessions.slice(0, 5).map((s, i) => ` ${i + 1}. ${s.shortId} ${s.friendlyName ?? s.decodedPath} (${fmtAge(s.mtime)} ago)`).join("\n");
|
|
2644
|
+
throw new Error(`UUID prefix "${query}" is ambiguous — ${fsSessions.length} matches:\n${candidates}\n\nProvide more characters.`);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
throw new Error(`No session found matching "${query}".\n\nRun: pai sessions to list sessions.\nRun: pai sessions --all to include transcript-only sessions.\nRun: pai find <words> to search prompt history.`);
|
|
2347
2648
|
}
|
|
2348
2649
|
|
|
2349
2650
|
//#endregion
|
|
@@ -2425,12 +2726,15 @@ function callAiBroker(method, params = {}, timeoutMs = 8e3) {
|
|
|
2425
2726
|
});
|
|
2426
2727
|
}
|
|
2427
2728
|
/**
|
|
2428
|
-
* Fetch all live iTerm2
|
|
2729
|
+
* Fetch all live iTerm2 session metadata from AIBroker via the `sessions` method.
|
|
2429
2730
|
* Returns an empty array if AIBroker is not running.
|
|
2731
|
+
*
|
|
2732
|
+
* This is metadata-only (no scrollback). It is faster than `session_content`
|
|
2733
|
+
* and the correct source for listing/routing purposes.
|
|
2430
2734
|
*/
|
|
2431
2735
|
async function fetchLiveSessions() {
|
|
2432
2736
|
try {
|
|
2433
|
-
const sessions = (await callAiBroker("
|
|
2737
|
+
const sessions = (await callAiBroker("sessions", {})).sessions;
|
|
2434
2738
|
if (!Array.isArray(sessions)) return [];
|
|
2435
2739
|
return sessions;
|
|
2436
2740
|
} catch {
|
|
@@ -2470,34 +2774,39 @@ function fmtStatus(status) {
|
|
|
2470
2774
|
case "orphan": return chalk.dim("orphan");
|
|
2471
2775
|
}
|
|
2472
2776
|
}
|
|
2473
|
-
function renderLiveSessions(
|
|
2474
|
-
|
|
2777
|
+
function renderLiveSessions(allLiveSessions, allTabs) {
|
|
2778
|
+
const displayed = allTabs ? allLiveSessions : allLiveSessions.filter((s) => s.kind !== "shell");
|
|
2779
|
+
const skippedCount = allLiveSessions.length - displayed.length;
|
|
2780
|
+
if (displayed.length === 0) return;
|
|
2475
2781
|
console.log("\n" + header("Live Sessions") + "\n");
|
|
2476
2782
|
const liveHeaders = [
|
|
2477
2783
|
"#",
|
|
2478
|
-
"
|
|
2784
|
+
"id",
|
|
2479
2785
|
"name",
|
|
2480
2786
|
"at prompt",
|
|
2481
|
-
"
|
|
2787
|
+
"kind"
|
|
2482
2788
|
];
|
|
2483
|
-
const liveRows =
|
|
2789
|
+
const liveRows = displayed.map((s, idx) => {
|
|
2484
2790
|
const shortId = s.sessionId.slice(0, 8);
|
|
2485
|
-
const
|
|
2486
|
-
const
|
|
2791
|
+
const rawName = s.paiName ?? s.name;
|
|
2792
|
+
const name = rawName.length > 36 ? rawName.slice(0, 35) + "…" : rawName;
|
|
2487
2793
|
const atPrompt = s.atPrompt ? chalk.green("yes") : chalk.yellow("busy");
|
|
2794
|
+
const kind = s.kind === "claude" ? chalk.cyan(s.kind) : chalk.dim(s.kind);
|
|
2488
2795
|
return [
|
|
2489
2796
|
chalk.dim(String(idx + 1)),
|
|
2490
2797
|
chalk.cyan(shortId),
|
|
2491
2798
|
name,
|
|
2492
2799
|
atPrompt,
|
|
2493
|
-
|
|
2800
|
+
kind
|
|
2494
2801
|
];
|
|
2495
2802
|
});
|
|
2496
2803
|
console.log(renderTable(liveHeaders, liveRows));
|
|
2804
|
+
if (skippedCount > 0) console.log(dim(` (${skippedCount} shell tab${skippedCount === 1 ? "" : "s"} hidden — use --all-tabs to show)`));
|
|
2497
2805
|
}
|
|
2498
2806
|
async function cmdRecent(db, opts) {
|
|
2499
2807
|
const limit = parseInt(opts.n ?? "20", 10);
|
|
2500
2808
|
const includeAll = opts.all === true;
|
|
2809
|
+
const allTabs = opts.allTabs === true;
|
|
2501
2810
|
const liveSessions = await fetchLiveSessions();
|
|
2502
2811
|
const sessions = scanSessions(db, {
|
|
2503
2812
|
limit,
|
|
@@ -2506,10 +2815,13 @@ async function cmdRecent(db, opts) {
|
|
|
2506
2815
|
if (opts.json) {
|
|
2507
2816
|
const output = {
|
|
2508
2817
|
live: liveSessions.map((s) => ({
|
|
2818
|
+
index: s.index,
|
|
2509
2819
|
sessionId: s.sessionId,
|
|
2510
2820
|
name: s.name,
|
|
2511
|
-
paiName: s.paiName
|
|
2512
|
-
atPrompt: s.atPrompt
|
|
2821
|
+
paiName: s.paiName,
|
|
2822
|
+
atPrompt: s.atPrompt,
|
|
2823
|
+
kind: s.kind,
|
|
2824
|
+
active: s.active
|
|
2513
2825
|
})),
|
|
2514
2826
|
paused: sessions.map((s, idx) => ({
|
|
2515
2827
|
idx: idx + 1,
|
|
@@ -2530,9 +2842,10 @@ async function cmdRecent(db, opts) {
|
|
|
2530
2842
|
console.log(JSON.stringify(output, null, 2));
|
|
2531
2843
|
return;
|
|
2532
2844
|
}
|
|
2533
|
-
|
|
2845
|
+
const hasClaudeLive = liveSessions.some((s) => s.kind === "claude");
|
|
2846
|
+
if (liveSessions.length > 0) renderLiveSessions(liveSessions, allTabs);
|
|
2534
2847
|
if (sessions.length === 0) {
|
|
2535
|
-
if (
|
|
2848
|
+
if (!hasClaudeLive) if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
|
|
2536
2849
|
else console.log(err("No named sessions found.\n\n Named sessions appear when you have entries in ~/.claude/session.json\n (set via /Name inside Claude Code) or resumable top-level jsonl files.\n Run: pai session recent --all to list all sessions including unnamed orphans."));
|
|
2537
2850
|
return;
|
|
2538
2851
|
}
|
|
@@ -2563,7 +2876,7 @@ async function cmdRecent(db, opts) {
|
|
|
2563
2876
|
];
|
|
2564
2877
|
});
|
|
2565
2878
|
console.log(renderTable(headers, rows));
|
|
2566
|
-
console.log("\n" + dim("Go to a session: ") + chalk.white("pai resume <name>") + "\n" + (
|
|
2879
|
+
console.log("\n" + dim("Go to a session: ") + chalk.white("pai resume <name>") + "\n" + (hasClaudeLive ? dim("Pause all live: ") + chalk.white("pai pause all") + "\n" : "") + (includeAll ? "" : dim("Show all sessions: ") + chalk.white("pai sessions --all") + "\n"));
|
|
2567
2880
|
}
|
|
2568
2881
|
|
|
2569
2882
|
//#endregion
|
|
@@ -2573,7 +2886,7 @@ async function cmdRecent(db, opts) {
|
|
|
2573
2886
|
* Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
|
|
2574
2887
|
* in stderr). Timeout: 5 seconds.
|
|
2575
2888
|
*/
|
|
2576
|
-
function probeResume(uuid, cwd) {
|
|
2889
|
+
function probeResume$1(uuid, cwd) {
|
|
2577
2890
|
const result = spawnSync("claude", [
|
|
2578
2891
|
"--resume",
|
|
2579
2892
|
uuid,
|
|
@@ -2614,7 +2927,9 @@ function cmdGoto(db, query, opts) {
|
|
|
2614
2927
|
try {
|
|
2615
2928
|
resolved = resolveSessionByNameOrId(allSessions, query);
|
|
2616
2929
|
} catch (resolveErr) {
|
|
2617
|
-
|
|
2930
|
+
let msg = String(resolveErr).replace(/^Error: /, "");
|
|
2931
|
+
if (msg.includes("No session found matching")) msg += `\n\nTip: pai find "${query}" — search prompt history by keywords`;
|
|
2932
|
+
console.error(err(msg));
|
|
2618
2933
|
process.exit(1);
|
|
2619
2934
|
}
|
|
2620
2935
|
const { session: matchedSession, friendlyName } = resolved;
|
|
@@ -2666,7 +2981,7 @@ function cmdGoto(db, query, opts) {
|
|
|
2666
2981
|
return;
|
|
2667
2982
|
}
|
|
2668
2983
|
if (resumableUuid) {
|
|
2669
|
-
const probe = probeResume(resumableUuid, projectDir);
|
|
2984
|
+
const probe = probeResume$1(resumableUuid, projectDir);
|
|
2670
2985
|
if (probe.ok) {
|
|
2671
2986
|
const result = spawnSync("claude", [
|
|
2672
2987
|
"--resume",
|
|
@@ -3002,7 +3317,7 @@ async function cmdPauseAll(opts) {
|
|
|
3002
3317
|
process.exitCode = 1;
|
|
3003
3318
|
return;
|
|
3004
3319
|
}
|
|
3005
|
-
const claudeSessions = liveSessions.filter((s) => s.
|
|
3320
|
+
const claudeSessions = liveSessions.filter((s) => s.kind === "claude");
|
|
3006
3321
|
const skipped = liveSessions.length - claudeSessions.length;
|
|
3007
3322
|
if (skipped > 0) process.stderr.write(`Skipping ${skipped} non-Claude tab${skipped === 1 ? "" : "s"} (bare shells).\n`);
|
|
3008
3323
|
if (claudeSessions.length === 0) {
|
|
@@ -3063,7 +3378,7 @@ async function cmdPauseAll(opts) {
|
|
|
3063
3378
|
else {
|
|
3064
3379
|
console.log(warn(`${succeeded}/${total} session(s) paused. `) + err(`${failed} failed.`));
|
|
3065
3380
|
for (const r of results.filter((r) => !r.pauseOk)) {
|
|
3066
|
-
const label = r.session.paiName ?? r.session.name;
|
|
3381
|
+
const label = r.session.paiName ?? r.session.name ?? r.session.sessionId.slice(0, 8);
|
|
3067
3382
|
console.log(err(` ${label}: ${r.error ?? "unknown error"}`));
|
|
3068
3383
|
}
|
|
3069
3384
|
}
|
|
@@ -3077,7 +3392,7 @@ async function cmdPauseAll(opts) {
|
|
|
3077
3392
|
//#endregion
|
|
3078
3393
|
//#region src/cli/commands/session/sessions-index.ts
|
|
3079
3394
|
function registerSessionsCommands(sessionsCmd, getDb) {
|
|
3080
|
-
sessionsCmd.command("list", { isDefault: true }).description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions (bare, no subcommand)\nUse --all to also show unnamed orphan sessions.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--json", "Output raw JSON instead of formatted table").action(async (opts) => {
|
|
3395
|
+
sessionsCmd.command("list", { isDefault: true }).description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions (bare, no subcommand)\nUse --all to also show unnamed orphan sessions.").option("-n <count>", "Maximum sessions to show (default: 20)", "20").option("--all", "Include unnamed orphan sessions (not in clc registry)").option("--all-tabs", "Show all iTerm2 tabs in Live Sessions, including bare shells").option("--json", "Output raw JSON instead of formatted table").action(async (opts) => {
|
|
3081
3396
|
await cmdRecent(getDb(), opts);
|
|
3082
3397
|
});
|
|
3083
3398
|
sessionsCmd.command("goto <name-or-id>").description("Go to a session: resume if a resumable snapshot exists, start fresh otherwise.\nRecommended short form: pai resume <name>\nResolves by clc/registry name (case-insensitive) or UUID prefix.").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((nameOrId, opts) => {
|
|
@@ -4697,7 +5012,7 @@ function cmdLogs(opts) {
|
|
|
4697
5012
|
}
|
|
4698
5013
|
function registerDaemonCommands(daemonCmd) {
|
|
4699
5014
|
daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
|
|
4700
|
-
const { serve } = await import("../daemon-
|
|
5015
|
+
const { serve } = await import("../daemon-CSKj8Xm9.mjs").then((n) => n.t);
|
|
4701
5016
|
const { loadConfig: lc, ensureConfigDir } = await import("../config-DqBY3aT0.mjs").then((n) => n.r);
|
|
4702
5017
|
ensureConfigDir();
|
|
4703
5018
|
await serve(lc());
|
|
@@ -7732,7 +8047,7 @@ function typeColor(type) {
|
|
|
7732
8047
|
default: return chalk.white(type);
|
|
7733
8048
|
}
|
|
7734
8049
|
}
|
|
7735
|
-
function fmtTs(ts) {
|
|
8050
|
+
function fmtTs$2(ts) {
|
|
7736
8051
|
if (!ts) return dim("—");
|
|
7737
8052
|
try {
|
|
7738
8053
|
const d = new Date(ts);
|
|
@@ -7786,7 +8101,7 @@ async function cmdList$1(opts) {
|
|
|
7786
8101
|
const typeStr = typeColor(obs.type ?? "").padEnd(TYPE_W + (typeColor(obs.type ?? "").length - (obs.type ?? "").length));
|
|
7787
8102
|
const titleStr = trunc(obs.title ?? "", TITLE_W).padEnd(TITLE_W);
|
|
7788
8103
|
const projStr = trunc(obs.project_slug ?? "—", PROJ_W).padEnd(PROJ_W);
|
|
7789
|
-
const tsStr = fmtTs(obs.created_at);
|
|
8104
|
+
const tsStr = fmtTs$2(obs.created_at);
|
|
7790
8105
|
console.log(` ${idStr} ${typeStr} ${titleStr} ${projStr} ${dim(tsStr)}`);
|
|
7791
8106
|
}
|
|
7792
8107
|
console.log();
|
|
@@ -7830,7 +8145,7 @@ async function cmdSearch(query, opts) {
|
|
|
7830
8145
|
const type = typeColor(obs.type ?? "");
|
|
7831
8146
|
const title = bold(obs.title ?? "(untitled)");
|
|
7832
8147
|
const proj = obs.project_slug ? dim(`[${obs.project_slug}]`) : dim("[—]");
|
|
7833
|
-
const ts = dim(fmtTs(obs.created_at));
|
|
8148
|
+
const ts = dim(fmtTs$2(obs.created_at));
|
|
7834
8149
|
const id = dim(`#${obs.id}`);
|
|
7835
8150
|
console.log(` ${idx} ${type.padEnd(12)} ${title}`);
|
|
7836
8151
|
console.log(` ${proj} ${ts} ${id}`);
|
|
@@ -7856,7 +8171,7 @@ async function cmdStats$1() {
|
|
|
7856
8171
|
console.log(header(" PAI Observation Statistics"));
|
|
7857
8172
|
console.log();
|
|
7858
8173
|
console.log(` ${bold("Total observations:")} ${chalk.cyan(String(stats.total ?? 0))}`);
|
|
7859
|
-
if (stats.most_recent) console.log(` ${bold("Most recent:")} ${dim(fmtTs(stats.most_recent))}`);
|
|
8174
|
+
if (stats.most_recent) console.log(` ${bold("Most recent:")} ${dim(fmtTs$2(stats.most_recent))}`);
|
|
7860
8175
|
console.log();
|
|
7861
8176
|
if (stats.by_type && stats.by_type.length > 0) {
|
|
7862
8177
|
console.log(bold(" By type:"));
|
|
@@ -9026,23 +9341,597 @@ function registerDbCommands(dbCmd) {
|
|
|
9026
9341
|
});
|
|
9027
9342
|
}
|
|
9028
9343
|
|
|
9344
|
+
//#endregion
|
|
9345
|
+
//#region src/cli/commands/find.ts
|
|
9346
|
+
/**
|
|
9347
|
+
* pai find <query> [--n=20] [--json]
|
|
9348
|
+
*
|
|
9349
|
+
* Content-based search across ~/.claude/history.jsonl.
|
|
9350
|
+
* Finds user prompts matching the query (case-insensitive substring),
|
|
9351
|
+
* groups by sessionId, sorts by most-recent match, and shows a table
|
|
9352
|
+
* with short UUID, date, project path, and the matching prompt snippet.
|
|
9353
|
+
*
|
|
9354
|
+
* The sessionId from history.jsonl is the same UUID used by claude --resume,
|
|
9355
|
+
* so rows are directly resumable via: pai resume <id>
|
|
9356
|
+
*
|
|
9357
|
+
* Note: older history.jsonl entries (before Claude Code ~2.0) may lack a
|
|
9358
|
+
* sessionId field. These are grouped under a synthetic "no-session" bucket
|
|
9359
|
+
* and shown in a separate note at the bottom.
|
|
9360
|
+
*/
|
|
9361
|
+
const HISTORY_FILE$1 = join(homedir(), ".claude", "history.jsonl");
|
|
9362
|
+
function shortenProject$1(p, maxLen = 42) {
|
|
9363
|
+
if (p.length <= maxLen) return p;
|
|
9364
|
+
return "…" + p.slice(-(maxLen - 1));
|
|
9365
|
+
}
|
|
9366
|
+
function fmtTs$1(ts) {
|
|
9367
|
+
const d = new Date(ts);
|
|
9368
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
9369
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
9370
|
+
}
|
|
9371
|
+
async function searchHistory$1(query, maxResults) {
|
|
9372
|
+
if (!existsSync(HISTORY_FILE$1)) return [];
|
|
9373
|
+
const queryLower = query.toLowerCase();
|
|
9374
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
9375
|
+
const noSession = {
|
|
9376
|
+
sessionId: null,
|
|
9377
|
+
lastMatchTs: 0,
|
|
9378
|
+
lastMatchDisplay: "",
|
|
9379
|
+
project: "",
|
|
9380
|
+
matchCount: 0
|
|
9381
|
+
};
|
|
9382
|
+
const rl = createInterface({
|
|
9383
|
+
input: createReadStream(HISTORY_FILE$1, { encoding: "utf8" }),
|
|
9384
|
+
crlfDelay: Infinity
|
|
9385
|
+
});
|
|
9386
|
+
for await (const line of rl) {
|
|
9387
|
+
const t = line.trim();
|
|
9388
|
+
if (!t) continue;
|
|
9389
|
+
let entry;
|
|
9390
|
+
try {
|
|
9391
|
+
entry = JSON.parse(t);
|
|
9392
|
+
} catch {
|
|
9393
|
+
continue;
|
|
9394
|
+
}
|
|
9395
|
+
const display = entry.display ?? "";
|
|
9396
|
+
if (!display.toLowerCase().includes(queryLower)) continue;
|
|
9397
|
+
const ts = entry.timestamp ?? 0;
|
|
9398
|
+
const project = entry.project ?? "";
|
|
9399
|
+
const sessionId = entry.sessionId ?? null;
|
|
9400
|
+
if (!sessionId) {
|
|
9401
|
+
if (ts > noSession.lastMatchTs) {
|
|
9402
|
+
noSession.lastMatchTs = ts;
|
|
9403
|
+
noSession.lastMatchDisplay = display;
|
|
9404
|
+
noSession.project = project;
|
|
9405
|
+
}
|
|
9406
|
+
noSession.matchCount++;
|
|
9407
|
+
continue;
|
|
9408
|
+
}
|
|
9409
|
+
const existing = bySession.get(sessionId);
|
|
9410
|
+
if (!existing) bySession.set(sessionId, {
|
|
9411
|
+
sessionId,
|
|
9412
|
+
lastMatchTs: ts,
|
|
9413
|
+
lastMatchDisplay: display,
|
|
9414
|
+
project,
|
|
9415
|
+
matchCount: 1
|
|
9416
|
+
});
|
|
9417
|
+
else {
|
|
9418
|
+
existing.matchCount++;
|
|
9419
|
+
if (ts > existing.lastMatchTs) {
|
|
9420
|
+
existing.lastMatchTs = ts;
|
|
9421
|
+
existing.lastMatchDisplay = display;
|
|
9422
|
+
existing.project = project;
|
|
9423
|
+
}
|
|
9424
|
+
}
|
|
9425
|
+
}
|
|
9426
|
+
return [...bySession.values()].sort((a, b) => b.lastMatchTs - a.lastMatchTs).slice(0, maxResults);
|
|
9427
|
+
}
|
|
9428
|
+
async function cmdFind(query, opts) {
|
|
9429
|
+
const maxResults = parseInt(opts.n ?? "20", 10);
|
|
9430
|
+
if (!existsSync(HISTORY_FILE$1)) {
|
|
9431
|
+
console.error(err(`~/.claude/history.jsonl not found.`));
|
|
9432
|
+
process.exitCode = 1;
|
|
9433
|
+
return;
|
|
9434
|
+
}
|
|
9435
|
+
const matches = await searchHistory$1(query, maxResults);
|
|
9436
|
+
if (opts.json) {
|
|
9437
|
+
console.log(JSON.stringify(matches, null, 2));
|
|
9438
|
+
return;
|
|
9439
|
+
}
|
|
9440
|
+
if (matches.length === 0) {
|
|
9441
|
+
console.log(warn(`No sessions found matching "${query}" in ~/.claude/history.jsonl.`));
|
|
9442
|
+
console.log(dim(` Try a shorter or different search term.`));
|
|
9443
|
+
return;
|
|
9444
|
+
}
|
|
9445
|
+
console.log("\n" + header("Session Search Results") + "\n");
|
|
9446
|
+
console.log(dim(` Query: "${query}" (${matches.length} session(s) with matching prompts)\n`));
|
|
9447
|
+
const headers = [
|
|
9448
|
+
"#",
|
|
9449
|
+
"id",
|
|
9450
|
+
"when",
|
|
9451
|
+
"project",
|
|
9452
|
+
"last matching prompt"
|
|
9453
|
+
];
|
|
9454
|
+
const rows = matches.map((m, idx) => {
|
|
9455
|
+
const shortId = (m.sessionId ?? "no-session").slice(0, 8);
|
|
9456
|
+
const when = m.lastMatchTs > 0 ? fmtTs$1(m.lastMatchTs) : dim("—");
|
|
9457
|
+
const project = shortenProject$1(m.project || dim("—"));
|
|
9458
|
+
const snippet = m.lastMatchDisplay.replace(/\n+/g, " ").trim().slice(0, 48);
|
|
9459
|
+
const display = snippet.length < m.lastMatchDisplay.replace(/\n+/g, " ").trim().length ? `"${snippet}…"` : `"${snippet}"`;
|
|
9460
|
+
return [
|
|
9461
|
+
dim(String(idx + 1)),
|
|
9462
|
+
chalk.cyan(shortId),
|
|
9463
|
+
when,
|
|
9464
|
+
dim(project),
|
|
9465
|
+
chalk.dim(display)
|
|
9466
|
+
];
|
|
9467
|
+
});
|
|
9468
|
+
console.log(renderTable(headers, rows));
|
|
9469
|
+
console.log();
|
|
9470
|
+
console.log(dim(" Resume: ") + chalk.white("pai resume <id>"));
|
|
9471
|
+
console.log();
|
|
9472
|
+
}
|
|
9473
|
+
|
|
9474
|
+
//#endregion
|
|
9475
|
+
//#region src/cli/lib/history-search.ts
|
|
9476
|
+
/**
|
|
9477
|
+
* history-search.ts
|
|
9478
|
+
*
|
|
9479
|
+
* Content-based search across ~/.claude/history.jsonl.
|
|
9480
|
+
* Streams the file (avoids loading 26k+ lines into memory), groups matching
|
|
9481
|
+
* lines by sessionId, and returns results sorted by most-recent match.
|
|
9482
|
+
*
|
|
9483
|
+
* Used by: pai <query> (main resolver) and pai find (compat alias)
|
|
9484
|
+
*/
|
|
9485
|
+
const HISTORY_FILE = join(homedir(), ".claude", "history.jsonl");
|
|
9486
|
+
/**
|
|
9487
|
+
* Search ~/.claude/history.jsonl for prompts matching the query (case-insensitive
|
|
9488
|
+
* substring). Results are grouped by sessionId and sorted by most-recent match.
|
|
9489
|
+
*
|
|
9490
|
+
* Entries without a sessionId (old Claude Code versions) are excluded from
|
|
9491
|
+
* results since they can't be resumed.
|
|
9492
|
+
*/
|
|
9493
|
+
async function searchHistory(query, maxResults) {
|
|
9494
|
+
if (!existsSync(HISTORY_FILE)) return [];
|
|
9495
|
+
const queryLower = query.toLowerCase();
|
|
9496
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
9497
|
+
const rl = createInterface({
|
|
9498
|
+
input: createReadStream(HISTORY_FILE, { encoding: "utf8" }),
|
|
9499
|
+
crlfDelay: Infinity
|
|
9500
|
+
});
|
|
9501
|
+
for await (const line of rl) {
|
|
9502
|
+
const t = line.trim();
|
|
9503
|
+
if (!t) continue;
|
|
9504
|
+
let entry;
|
|
9505
|
+
try {
|
|
9506
|
+
entry = JSON.parse(t);
|
|
9507
|
+
} catch {
|
|
9508
|
+
continue;
|
|
9509
|
+
}
|
|
9510
|
+
const display = entry.display ?? "";
|
|
9511
|
+
if (!display.toLowerCase().includes(queryLower)) continue;
|
|
9512
|
+
const ts = entry.timestamp ?? 0;
|
|
9513
|
+
const project = entry.project ?? "";
|
|
9514
|
+
const sessionId = entry.sessionId ?? null;
|
|
9515
|
+
if (!sessionId) continue;
|
|
9516
|
+
const existing = bySession.get(sessionId);
|
|
9517
|
+
if (!existing) bySession.set(sessionId, {
|
|
9518
|
+
sessionId,
|
|
9519
|
+
lastMatchTs: ts,
|
|
9520
|
+
lastMatchDisplay: display,
|
|
9521
|
+
project,
|
|
9522
|
+
matchCount: 1
|
|
9523
|
+
});
|
|
9524
|
+
else {
|
|
9525
|
+
existing.matchCount++;
|
|
9526
|
+
if (ts > existing.lastMatchTs) {
|
|
9527
|
+
existing.lastMatchTs = ts;
|
|
9528
|
+
existing.lastMatchDisplay = display;
|
|
9529
|
+
existing.project = project;
|
|
9530
|
+
}
|
|
9531
|
+
}
|
|
9532
|
+
}
|
|
9533
|
+
return [...bySession.values()].sort((a, b) => b.lastMatchTs - a.lastMatchTs).slice(0, maxResults);
|
|
9534
|
+
}
|
|
9535
|
+
|
|
9536
|
+
//#endregion
|
|
9537
|
+
//#region src/cli/commands/main-resolver.ts
|
|
9538
|
+
function probeResume(uuid, cwd) {
|
|
9539
|
+
const result = spawnSync("claude", [
|
|
9540
|
+
"--resume",
|
|
9541
|
+
uuid,
|
|
9542
|
+
"--print",
|
|
9543
|
+
"--output-format=json",
|
|
9544
|
+
"_"
|
|
9545
|
+
], {
|
|
9546
|
+
cwd,
|
|
9547
|
+
timeout: 5e3,
|
|
9548
|
+
env: process.env,
|
|
9549
|
+
stdio: [
|
|
9550
|
+
"ignore",
|
|
9551
|
+
"ignore",
|
|
9552
|
+
"pipe"
|
|
9553
|
+
]
|
|
9554
|
+
});
|
|
9555
|
+
if (result.error) return {
|
|
9556
|
+
ok: false,
|
|
9557
|
+
reason: `spawn error: ${result.error.message}`
|
|
9558
|
+
};
|
|
9559
|
+
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
9560
|
+
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
9561
|
+
ok: false,
|
|
9562
|
+
reason: "No conversation found for this UUID"
|
|
9563
|
+
};
|
|
9564
|
+
if (result.status !== 0) return {
|
|
9565
|
+
ok: false,
|
|
9566
|
+
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
9567
|
+
};
|
|
9568
|
+
return { ok: true };
|
|
9569
|
+
}
|
|
9570
|
+
/**
|
|
9571
|
+
* Launch Claude for the given session. Handles resume probe + fallback to fresh.
|
|
9572
|
+
* Never returns on success (process.exit inside spawnSync block).
|
|
9573
|
+
*/
|
|
9574
|
+
function launchSession(session, allSessions, dryRun) {
|
|
9575
|
+
let resumableUuid;
|
|
9576
|
+
let resumableSession;
|
|
9577
|
+
if (session.resumable) {
|
|
9578
|
+
resumableUuid = session.uuid;
|
|
9579
|
+
resumableSession = session;
|
|
9580
|
+
} else if (session.encodedDir) {
|
|
9581
|
+
const sameProject = allSessions.filter((s) => s.encodedDir === session.encodedDir && s.resumable);
|
|
9582
|
+
sameProject.sort((a, b) => b.mtime - a.mtime);
|
|
9583
|
+
if (sameProject.length > 0) {
|
|
9584
|
+
resumableSession = sameProject[0];
|
|
9585
|
+
resumableUuid = resumableSession.uuid;
|
|
9586
|
+
}
|
|
9587
|
+
}
|
|
9588
|
+
const rawDir = session.clcDirectory ?? session.registryRootPath ?? session.decodedPath;
|
|
9589
|
+
let projectDir;
|
|
9590
|
+
try {
|
|
9591
|
+
projectDir = realpathSync(rawDir);
|
|
9592
|
+
} catch {
|
|
9593
|
+
console.error(err(`Session directory does not exist or cannot be resolved.\n Path: ${rawDir}\n The directory may have moved or been deleted.`));
|
|
9594
|
+
process.exit(1);
|
|
9595
|
+
return;
|
|
9596
|
+
}
|
|
9597
|
+
const name = session.friendlyName ?? session.shortId;
|
|
9598
|
+
const promptArg = `/Name ${name}\ngo`;
|
|
9599
|
+
if (dryRun) {
|
|
9600
|
+
if (resumableUuid) {
|
|
9601
|
+
console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
|
|
9602
|
+
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
9603
|
+
console.log(` probe: claude --resume ${resumableUuid} --print --output-format=json "_"`);
|
|
9604
|
+
console.log(` argv: claude --resume ${resumableUuid} --name "${name}" "/Name ${name}\\ngo"`);
|
|
9605
|
+
console.log(` fallback: claude --name "${name}" "/Name ${name}\\ngo"`);
|
|
9606
|
+
} else {
|
|
9607
|
+
console.log("\n" + chalk.bold("Dry run — would exec (FRESH path):") + "\n");
|
|
9608
|
+
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
9609
|
+
console.log(` argv: claude --name "${name}" "/Name ${name}\\ngo"`);
|
|
9610
|
+
}
|
|
9611
|
+
console.log();
|
|
9612
|
+
return;
|
|
9613
|
+
}
|
|
9614
|
+
if (resumableUuid) {
|
|
9615
|
+
const probe = probeResume(resumableUuid, projectDir);
|
|
9616
|
+
if (probe.ok) {
|
|
9617
|
+
const result = spawnSync("claude", [
|
|
9618
|
+
"--resume",
|
|
9619
|
+
resumableUuid,
|
|
9620
|
+
"--name",
|
|
9621
|
+
name,
|
|
9622
|
+
promptArg
|
|
9623
|
+
], {
|
|
9624
|
+
cwd: projectDir,
|
|
9625
|
+
stdio: "inherit",
|
|
9626
|
+
env: process.env
|
|
9627
|
+
});
|
|
9628
|
+
if (result.error) {
|
|
9629
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
9630
|
+
process.exit(1);
|
|
9631
|
+
}
|
|
9632
|
+
process.exit(result.status ?? 0);
|
|
9633
|
+
} else {
|
|
9634
|
+
process.stderr.write(chalk.yellow(`\n Resume failed for ${resumableUuid.slice(0, 8)}: ${probe.reason ?? "unknown error"}\n Starting fresh session in same directory.\n\n`));
|
|
9635
|
+
const result = spawnSync("claude", [
|
|
9636
|
+
"--name",
|
|
9637
|
+
name,
|
|
9638
|
+
promptArg
|
|
9639
|
+
], {
|
|
9640
|
+
cwd: projectDir,
|
|
9641
|
+
stdio: "inherit",
|
|
9642
|
+
env: process.env
|
|
9643
|
+
});
|
|
9644
|
+
if (result.error) {
|
|
9645
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
9646
|
+
process.exit(1);
|
|
9647
|
+
}
|
|
9648
|
+
process.exit(result.status ?? 0);
|
|
9649
|
+
}
|
|
9650
|
+
} else {
|
|
9651
|
+
const result = spawnSync("claude", [
|
|
9652
|
+
"--name",
|
|
9653
|
+
name,
|
|
9654
|
+
promptArg
|
|
9655
|
+
], {
|
|
9656
|
+
cwd: projectDir,
|
|
9657
|
+
stdio: "inherit",
|
|
9658
|
+
env: process.env
|
|
9659
|
+
});
|
|
9660
|
+
if (result.error) {
|
|
9661
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
9662
|
+
process.exit(1);
|
|
9663
|
+
}
|
|
9664
|
+
process.exit(result.status ?? 0);
|
|
9665
|
+
}
|
|
9666
|
+
}
|
|
9667
|
+
/**
|
|
9668
|
+
* Given a SessionMatch from history search, find the corresponding ScannedSession
|
|
9669
|
+
* (for launch). Falls back to a minimal synthetic session using the decodedPath
|
|
9670
|
+
* from the history entry's project field.
|
|
9671
|
+
*/
|
|
9672
|
+
function matchToSession(match, allSessions) {
|
|
9673
|
+
if (!match.sessionId) return null;
|
|
9674
|
+
const catalogMatch = allSessions.find((s) => s.uuid === match.sessionId);
|
|
9675
|
+
if (catalogMatch) return catalogMatch;
|
|
9676
|
+
if (!match.project) return null;
|
|
9677
|
+
return {
|
|
9678
|
+
uuid: match.sessionId,
|
|
9679
|
+
shortId: match.sessionId.slice(0, 8),
|
|
9680
|
+
encodedDir: "",
|
|
9681
|
+
decodedPath: match.project,
|
|
9682
|
+
topLevelPath: "",
|
|
9683
|
+
topLevelSystemLines: 0,
|
|
9684
|
+
topLevelSize: 0,
|
|
9685
|
+
resumable: false,
|
|
9686
|
+
sessionStatus: "transcript-only",
|
|
9687
|
+
sessionJsonlPath: void 0,
|
|
9688
|
+
userLines: 0,
|
|
9689
|
+
lastUserPrompt: match.lastMatchDisplay.slice(0, 80),
|
|
9690
|
+
msgCount: 0,
|
|
9691
|
+
mtime: match.lastMatchTs,
|
|
9692
|
+
friendlyName: void 0,
|
|
9693
|
+
clcDirectory: void 0,
|
|
9694
|
+
registryRootPath: match.project
|
|
9695
|
+
};
|
|
9696
|
+
}
|
|
9697
|
+
function fmtTs(ts) {
|
|
9698
|
+
const d = new Date(ts);
|
|
9699
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
9700
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
9701
|
+
}
|
|
9702
|
+
function shortenProject(p, maxLen = 44) {
|
|
9703
|
+
if (!p || p.length <= maxLen) return p || dim("—");
|
|
9704
|
+
return "…" + p.slice(-(maxLen - 1));
|
|
9705
|
+
}
|
|
9706
|
+
async function askForChoice(max) {
|
|
9707
|
+
return new Promise((resolve) => {
|
|
9708
|
+
const rl = createInterface({
|
|
9709
|
+
input: process.stdin,
|
|
9710
|
+
output: process.stdout
|
|
9711
|
+
});
|
|
9712
|
+
rl.question(dim(`\n Enter # to launch (1-${max}), or press Enter to cancel: `), (answer) => {
|
|
9713
|
+
rl.close();
|
|
9714
|
+
const n = parseInt(answer.trim(), 10);
|
|
9715
|
+
if (!isNaN(n) && n >= 1 && n <= max) resolve(n);
|
|
9716
|
+
else resolve(null);
|
|
9717
|
+
});
|
|
9718
|
+
});
|
|
9719
|
+
}
|
|
9720
|
+
async function cmdMain(db, query, pickN, opts) {
|
|
9721
|
+
const maxResults = parseInt(opts.n ?? "20", 10);
|
|
9722
|
+
const allSessions = scanSessions(db, {
|
|
9723
|
+
limit: 500,
|
|
9724
|
+
filter: "named"
|
|
9725
|
+
});
|
|
9726
|
+
if (!query) {
|
|
9727
|
+
let liveSessions = [];
|
|
9728
|
+
try {
|
|
9729
|
+
liveSessions = await fetchLiveSessions();
|
|
9730
|
+
} catch {}
|
|
9731
|
+
const claudeLive = liveSessions.filter((s) => s.kind !== "shell");
|
|
9732
|
+
const recentDisk = allSessions.slice(0, maxResults);
|
|
9733
|
+
if (claudeLive.length === 0 && recentDisk.length === 0) {
|
|
9734
|
+
console.log(warn("No sessions found. Start Claude Code in a project directory first."));
|
|
9735
|
+
return;
|
|
9736
|
+
}
|
|
9737
|
+
if (claudeLive.length > 0) {
|
|
9738
|
+
console.log("\n" + header("Live Sessions") + "\n");
|
|
9739
|
+
const liveHeaders = [
|
|
9740
|
+
"#",
|
|
9741
|
+
"id",
|
|
9742
|
+
"name",
|
|
9743
|
+
"at prompt"
|
|
9744
|
+
];
|
|
9745
|
+
const liveRows = claudeLive.map((s, i) => [
|
|
9746
|
+
dim(String(i + 1)),
|
|
9747
|
+
chalk.cyan(s.sessionId.slice(0, 8)),
|
|
9748
|
+
s.paiName ?? s.name ?? dim("—"),
|
|
9749
|
+
s.atPrompt ? chalk.green("yes") : chalk.dim("no")
|
|
9750
|
+
]);
|
|
9751
|
+
console.log(renderTable(liveHeaders, liveRows));
|
|
9752
|
+
}
|
|
9753
|
+
if (recentDisk.length > 0) {
|
|
9754
|
+
console.log("\n" + header("Recent Sessions") + "\n");
|
|
9755
|
+
const diskHeaders = [
|
|
9756
|
+
"#",
|
|
9757
|
+
"id",
|
|
9758
|
+
"age",
|
|
9759
|
+
"project",
|
|
9760
|
+
"last prompt"
|
|
9761
|
+
];
|
|
9762
|
+
const diskRows = recentDisk.map((s, i) => {
|
|
9763
|
+
const snippet = s.lastUserPrompt.replace(/\n+/g, " ").trim().slice(0, 40);
|
|
9764
|
+
return [
|
|
9765
|
+
dim(String(i + 1)),
|
|
9766
|
+
chalk.cyan(s.shortId),
|
|
9767
|
+
dim(fmtAge(s.mtime)),
|
|
9768
|
+
dim(shortenProject(s.friendlyName ?? s.decodedPath, 30)),
|
|
9769
|
+
chalk.dim(snippet ? `"${snippet}"` : "—")
|
|
9770
|
+
];
|
|
9771
|
+
});
|
|
9772
|
+
console.log(renderTable(diskHeaders, diskRows));
|
|
9773
|
+
}
|
|
9774
|
+
console.log();
|
|
9775
|
+
console.log(dim(" Resume a session: ") + chalk.white("pai <topic>") + dim(" or ") + chalk.white("pai <id>"));
|
|
9776
|
+
console.log();
|
|
9777
|
+
return;
|
|
9778
|
+
}
|
|
9779
|
+
if (/^[0-9a-f-]{8,36}$/i.test(query)) {
|
|
9780
|
+
const byUuid = allSessions.filter((s) => s.uuid.startsWith(query.toLowerCase()));
|
|
9781
|
+
if (byUuid.length === 1) {
|
|
9782
|
+
launchSession(byUuid[0], allSessions, opts.dryRun ?? false);
|
|
9783
|
+
return;
|
|
9784
|
+
}
|
|
9785
|
+
if (byUuid.length > 1) {
|
|
9786
|
+
console.error(err(`UUID prefix "${query}" is ambiguous — ${byUuid.length} catalog matches.`));
|
|
9787
|
+
process.exitCode = 1;
|
|
9788
|
+
return;
|
|
9789
|
+
}
|
|
9790
|
+
}
|
|
9791
|
+
{
|
|
9792
|
+
const qLower = query.toLowerCase();
|
|
9793
|
+
const byExact = allSessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase() === qLower);
|
|
9794
|
+
if (byExact.length >= 1) {
|
|
9795
|
+
launchSession(byExact[0], allSessions, opts.dryRun ?? false);
|
|
9796
|
+
return;
|
|
9797
|
+
}
|
|
9798
|
+
const byPartial = allSessions.filter((s) => s.friendlyName && s.friendlyName.toLowerCase().includes(qLower));
|
|
9799
|
+
if (byPartial.length === 1) {
|
|
9800
|
+
launchSession(byPartial[0], allSessions, opts.dryRun ?? false);
|
|
9801
|
+
return;
|
|
9802
|
+
}
|
|
9803
|
+
if (byPartial.length > 1) {
|
|
9804
|
+
console.log("\n" + header(`Sessions matching "${query}"`) + "\n");
|
|
9805
|
+
const headers = [
|
|
9806
|
+
"#",
|
|
9807
|
+
"id",
|
|
9808
|
+
"age",
|
|
9809
|
+
"name",
|
|
9810
|
+
"project"
|
|
9811
|
+
];
|
|
9812
|
+
const rows = byPartial.slice(0, maxResults).map((s, i) => [
|
|
9813
|
+
dim(String(i + 1)),
|
|
9814
|
+
chalk.cyan(s.shortId),
|
|
9815
|
+
dim(fmtAge(s.mtime)),
|
|
9816
|
+
s.friendlyName ?? dim("—"),
|
|
9817
|
+
dim(shortenProject(s.decodedPath, 36))
|
|
9818
|
+
]);
|
|
9819
|
+
console.log(renderTable(headers, rows));
|
|
9820
|
+
console.log();
|
|
9821
|
+
if (pickN !== void 0) {
|
|
9822
|
+
const idx = pickN - 1;
|
|
9823
|
+
if (idx >= 0 && idx < byPartial.length) {
|
|
9824
|
+
launchSession(byPartial[idx], allSessions, opts.dryRun ?? false);
|
|
9825
|
+
return;
|
|
9826
|
+
}
|
|
9827
|
+
console.error(err(`Invalid choice: ${pickN}`));
|
|
9828
|
+
process.exitCode = 1;
|
|
9829
|
+
return;
|
|
9830
|
+
}
|
|
9831
|
+
if (opts.auto) {
|
|
9832
|
+
launchSession(byPartial[0], allSessions, opts.dryRun ?? false);
|
|
9833
|
+
return;
|
|
9834
|
+
}
|
|
9835
|
+
const choice = await askForChoice(Math.min(byPartial.length, maxResults));
|
|
9836
|
+
if (choice !== null) launchSession(byPartial[choice - 1], allSessions, opts.dryRun ?? false);
|
|
9837
|
+
return;
|
|
9838
|
+
}
|
|
9839
|
+
}
|
|
9840
|
+
if (!existsSync(HISTORY_FILE)) {
|
|
9841
|
+
console.error(err("~/.claude/history.jsonl not found."));
|
|
9842
|
+
console.error(dim(" No prompt history available for search."));
|
|
9843
|
+
process.exitCode = 1;
|
|
9844
|
+
return;
|
|
9845
|
+
}
|
|
9846
|
+
process.stderr.write(dim(` Searching prompt history for "${query}"...\n`));
|
|
9847
|
+
const matches = await searchHistory(query, maxResults);
|
|
9848
|
+
if (matches.length === 0) {
|
|
9849
|
+
console.log(warn(`No sessions found matching "${query}".`));
|
|
9850
|
+
console.log(dim(` Try a shorter or different search term.`));
|
|
9851
|
+
console.log(dim(` Or run: `) + chalk.white("pai") + dim(" (no args) to see all recent sessions."));
|
|
9852
|
+
return;
|
|
9853
|
+
}
|
|
9854
|
+
console.log("\n" + header(`Sessions matching "${query}"`) + "\n");
|
|
9855
|
+
const headers = [
|
|
9856
|
+
"#",
|
|
9857
|
+
"id",
|
|
9858
|
+
"when",
|
|
9859
|
+
"project",
|
|
9860
|
+
"last matching prompt"
|
|
9861
|
+
];
|
|
9862
|
+
const rows = matches.map((m, idx) => {
|
|
9863
|
+
const shortId = (m.sessionId ?? "—").slice(0, 8);
|
|
9864
|
+
const when = m.lastMatchTs > 0 ? fmtTs(m.lastMatchTs) : dim("—");
|
|
9865
|
+
const project = shortenProject(m.project || "—");
|
|
9866
|
+
const snippet = m.lastMatchDisplay.replace(/\n+/g, " ").trim().slice(0, 48);
|
|
9867
|
+
const fullSnippet = m.lastMatchDisplay.replace(/\n+/g, " ").trim();
|
|
9868
|
+
const display = snippet.length < fullSnippet.length ? `"${snippet}…"` : `"${snippet}"`;
|
|
9869
|
+
return [
|
|
9870
|
+
dim(String(idx + 1)),
|
|
9871
|
+
chalk.cyan(shortId),
|
|
9872
|
+
when,
|
|
9873
|
+
dim(project),
|
|
9874
|
+
chalk.dim(display)
|
|
9875
|
+
];
|
|
9876
|
+
});
|
|
9877
|
+
console.log(renderTable(headers, rows));
|
|
9878
|
+
console.log();
|
|
9879
|
+
if (pickN !== void 0) {
|
|
9880
|
+
const idx = pickN - 1;
|
|
9881
|
+
if (idx >= 0 && idx < matches.length) {
|
|
9882
|
+
const session = matchToSession(matches[idx], allSessions);
|
|
9883
|
+
if (!session) {
|
|
9884
|
+
console.error(err("Could not resolve session for launch (no project path)."));
|
|
9885
|
+
process.exitCode = 1;
|
|
9886
|
+
return;
|
|
9887
|
+
}
|
|
9888
|
+
launchSession(session, allSessions, opts.dryRun ?? false);
|
|
9889
|
+
return;
|
|
9890
|
+
}
|
|
9891
|
+
console.error(err(`Invalid choice: ${pickN}`));
|
|
9892
|
+
process.exitCode = 1;
|
|
9893
|
+
return;
|
|
9894
|
+
}
|
|
9895
|
+
if (opts.auto) {
|
|
9896
|
+
const session = matchToSession(matches[0], allSessions);
|
|
9897
|
+
if (!session) {
|
|
9898
|
+
console.error(err("Could not resolve session for launch (no project path)."));
|
|
9899
|
+
process.exitCode = 1;
|
|
9900
|
+
return;
|
|
9901
|
+
}
|
|
9902
|
+
launchSession(session, allSessions, opts.dryRun ?? false);
|
|
9903
|
+
return;
|
|
9904
|
+
}
|
|
9905
|
+
const choice = await askForChoice(matches.length);
|
|
9906
|
+
if (choice !== null) {
|
|
9907
|
+
const session = matchToSession(matches[choice - 1], allSessions);
|
|
9908
|
+
if (!session) {
|
|
9909
|
+
console.error(err("Could not resolve session for launch (no project path)."));
|
|
9910
|
+
process.exitCode = 1;
|
|
9911
|
+
return;
|
|
9912
|
+
}
|
|
9913
|
+
launchSession(session, allSessions, opts.dryRun ?? false);
|
|
9914
|
+
}
|
|
9915
|
+
}
|
|
9916
|
+
|
|
9029
9917
|
//#endregion
|
|
9030
9918
|
//#region src/cli/index.ts
|
|
9031
9919
|
/**
|
|
9032
|
-
* PAI Knowledge OS — CLI entry point
|
|
9920
|
+
* PAI Knowledge OS — CLI entry point (v0.10.0 topic-first redesign)
|
|
9033
9921
|
*
|
|
9034
|
-
*
|
|
9035
|
-
* pai sessions
|
|
9036
|
-
* pai
|
|
9037
|
-
* pai
|
|
9038
|
-
* pai
|
|
9039
|
-
* pai
|
|
9040
|
-
* pai
|
|
9041
|
-
* pai version
|
|
9922
|
+
* Top-level surface:
|
|
9923
|
+
* pai → recent sessions picker
|
|
9924
|
+
* pai <topic> → history search + candidate picker + launch
|
|
9925
|
+
* pai <uuid-prefix> → direct session resume via filesystem scan
|
|
9926
|
+
* pai cd <name> → cd to project directory (no Claude launch)
|
|
9927
|
+
* pai pause [all] → save state (or mass-pause every live session)
|
|
9928
|
+
* pai end → finalize session
|
|
9042
9929
|
*
|
|
9043
|
-
*
|
|
9044
|
-
* pai
|
|
9045
|
-
* pai
|
|
9930
|
+
* Power-user namespaces (still accessible, hidden from main help):
|
|
9931
|
+
* pai sessions ... → full session management
|
|
9932
|
+
* pai projects ... → project management
|
|
9933
|
+
* pai registry ... → registry maintenance
|
|
9934
|
+
* pai memory ... → memory engine
|
|
9046
9935
|
*/
|
|
9047
9936
|
function getVersion() {
|
|
9048
9937
|
try {
|
|
@@ -9064,20 +9953,23 @@ function getDb() {
|
|
|
9064
9953
|
}
|
|
9065
9954
|
const program = new Command();
|
|
9066
9955
|
program.name("pai").description("PAI Knowledge OS — Personal AI Infrastructure CLI").version(getVersion(), "-V, --version", "Print version and exit").addHelpText("after", `
|
|
9067
|
-
|
|
9068
|
-
pai
|
|
9069
|
-
pai
|
|
9070
|
-
pai
|
|
9071
|
-
pai cd <name> cd to a project directory
|
|
9956
|
+
Usage:
|
|
9957
|
+
pai Show recent sessions (interactive picker)
|
|
9958
|
+
pai <topic> Find sessions by topic and launch the chosen one
|
|
9959
|
+
pai <uuid-prefix> Resume a specific session by UUID
|
|
9960
|
+
pai cd <name> cd to a project directory (no Claude launch)
|
|
9961
|
+
pai pause [all] Save state (or mass-pause every live session)
|
|
9962
|
+
pai end Finalize session: save state + mark note Completed
|
|
9072
9963
|
|
|
9073
|
-
|
|
9074
|
-
pai
|
|
9075
|
-
pai
|
|
9076
|
-
pai
|
|
9964
|
+
Examples:
|
|
9965
|
+
pai mdf Find all sessions where you worked on MDF
|
|
9966
|
+
pai solar panels Free-text search across your prompt history
|
|
9967
|
+
pai 81c5c3dc Resume session by UUID prefix
|
|
9968
|
+
pai See the 20 most recent sessions
|
|
9077
9969
|
|
|
9078
|
-
|
|
9079
|
-
pai sessions ...
|
|
9080
|
-
pai projects ... Project management (cd, list, ...)
|
|
9970
|
+
Power-user namespaces (run "pai sessions --help" etc. for details):
|
|
9971
|
+
pai sessions ... Full session management (list, goto, info, ...)
|
|
9972
|
+
pai projects ... Project management (cd, rebind, list, ...)
|
|
9081
9973
|
pai registry ... Registry maintenance (scan, ...)
|
|
9082
9974
|
pai memory ... Memory engine (index, search, ...)
|
|
9083
9975
|
|
|
@@ -9102,9 +9994,6 @@ registerDbCommands(program.command("db").description("Database inspection: query
|
|
|
9102
9994
|
registerObsidianCommands(program.command("obsidian").description("Obsidian vault: sync project notes, view status, open in Obsidian"), getDb);
|
|
9103
9995
|
registerZettelCommands(program.command("zettel").description("Zettelkasten intelligence: explore, surprise, converse, themes, health, suggest"), getDb);
|
|
9104
9996
|
registerObservationCommands(program.command("observation").description("Observation capture: list, search, and stats"));
|
|
9105
|
-
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) => {
|
|
9106
|
-
cmdGo(getDb(), query);
|
|
9107
|
-
});
|
|
9108
9997
|
program.command("pause [target]").description("Save state and display safe-exit instructions for the current session.\nWrites a ## Continue checkpoint to the project's TODO.md.\nUse `pai pause all` to pause every live session via AIBroker.\nLong form: pai sessions pause").option("--dry-run", "Preview changes without writing them").option("--exit", "(pause all only) Also send /exit to each session after pausing").option("--wait <ms>", "(pause all only) Milliseconds to wait before /exit (default: 5000)", "5000").action(async (target, opts) => {
|
|
9109
9998
|
if (target === "all") await cmdPauseAll({
|
|
9110
9999
|
exit: opts.exit,
|
|
@@ -9123,22 +10012,50 @@ program.command("pause [target]").description("Save state and display safe-exit
|
|
|
9123
10012
|
program.command("end").description("Finalize a session: save state, mark note Completed, display safe-exit instructions.\nLong form: pai sessions end").option("--dry-run", "Preview all changes without writing them").action((opts) => {
|
|
9124
10013
|
cmdEnd(getDb(), opts);
|
|
9125
10014
|
});
|
|
9126
|
-
program.command("resume <name>").description("Go to a session by name: resume if resumable, start fresh otherwise.\
|
|
10015
|
+
program.command("resume <name>").description("Go to a session by name or UUID: resume if resumable, start fresh otherwise.\nAlso: pai <name> (shorter) | pai sessions goto <name> (long form)").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((name, opts) => {
|
|
9127
10016
|
cmdGoto(getDb(), name, { dryRun: opts.dryRun });
|
|
9128
10017
|
});
|
|
9129
|
-
program.command("cd <identifier>").description("cd to a project directory (shell wrapper handles the actual cd).\nLong form: pai projects cd <identifier>\nThe shell function installed by pai shell-init intercepts this command\nand calls builtin cd with the resolved path.").action((identifier) => {
|
|
9130
|
-
const
|
|
10018
|
+
program.command("cd <identifier>").description("cd to a project directory (shell wrapper handles the actual cd).\nLong form: pai projects cd <identifier>\nThe shell function installed by pai shell-init intercepts this command\nand calls builtin cd with the resolved path.\nAuto-detects moved projects when the registered path no longer exists.").action((identifier) => {
|
|
10019
|
+
const db = getDb();
|
|
10020
|
+
const project = resolveIdentifier(db, identifier);
|
|
9131
10021
|
if (!project) {
|
|
9132
10022
|
console.error(`Project not found: ${identifier}`);
|
|
9133
10023
|
process.exit(1);
|
|
10024
|
+
return;
|
|
10025
|
+
}
|
|
10026
|
+
if (existsSync(project.root_path)) {
|
|
10027
|
+
process.stdout.write(project.root_path + "\n");
|
|
10028
|
+
return;
|
|
9134
10029
|
}
|
|
9135
|
-
process.
|
|
10030
|
+
process.stderr.write(warn(`Path not found: ${project.root_path}\n`) + dim(" Searching for moved location...\n"));
|
|
10031
|
+
const result = findMovedPath(project.root_path);
|
|
10032
|
+
if (result.found) {
|
|
10033
|
+
const newPath = result.found;
|
|
10034
|
+
const newEncoded = encodeDir(newPath);
|
|
10035
|
+
const ts = now();
|
|
10036
|
+
db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(newPath, newEncoded, ts, project.id);
|
|
10037
|
+
process.stderr.write(ok(`Project moved: ${shortenPath(project.root_path, 50)}\n`) + dim(` → ${newPath}\n`) + ok("Registry updated.\n"));
|
|
10038
|
+
process.stdout.write(newPath + "\n");
|
|
10039
|
+
return;
|
|
10040
|
+
}
|
|
10041
|
+
if (result.ambiguous) {
|
|
10042
|
+
process.stderr.write(warn(`Multiple directories named "${basename(project.root_path)}" found:\n`));
|
|
10043
|
+
for (const candidate of result.ambiguous) process.stderr.write(dim(` ${candidate}\n`));
|
|
10044
|
+
process.stderr.write(dim(`\n Disambiguate with: pai projects rebind ${project.slug} <path>\n`));
|
|
10045
|
+
process.exitCode = 1;
|
|
10046
|
+
return;
|
|
10047
|
+
}
|
|
10048
|
+
process.stderr.write(err(`Project "${project.slug}" root_path "${project.root_path}" does not exist on disk\n and no folder named "${basename(project.root_path)}" was found in scan dirs.\n`) + dim(` Fix with: pai projects rebind ${project.slug} <new-path>\n`));
|
|
10049
|
+
process.exitCode = 1;
|
|
9136
10050
|
});
|
|
9137
10051
|
program.command("notes [project-slug]").description("Markdown session notes — list notes, optionally filtered to a project.").option("--limit <n>", "Maximum number of notes to show", "20").option("--status <status>", "Filter by status: open | completed | compacted").action((projectSlug, opts) => {
|
|
9138
10052
|
cmdList$2(getDb(), projectSlug, opts);
|
|
9139
10053
|
}).command("list [project-slug]").description("List markdown session notes (same as: pai notes)").option("--limit <n>", "Maximum number of notes to show", "20").option("--status <status>", "Filter by status: open | completed | compacted").action((projectSlug, opts) => {
|
|
9140
10054
|
cmdList$2(getDb(), projectSlug, opts);
|
|
9141
10055
|
});
|
|
10056
|
+
program.command("find <query>").description("Search prompt history for matching sessions.\nSame as: pai <query> (the default command does history search too)").option("-n, --n <count>", "Maximum number of sessions to show", "20").option("--json", "Output as JSON array").action(async (query, opts) => {
|
|
10057
|
+
await cmdFind(query, opts);
|
|
10058
|
+
});
|
|
9142
10059
|
program.command("shell-init").description("Emit shell integration code. Add to ~/.zshrc:\n eval \"$(pai shell-init)\"").action(() => {
|
|
9143
10060
|
process.stdout.write(`# PAI shell integration — generated by: pai shell-init
|
|
9144
10061
|
pai() {
|
|
@@ -9166,8 +10083,12 @@ pai() {
|
|
|
9166
10083
|
}
|
|
9167
10084
|
`);
|
|
9168
10085
|
});
|
|
9169
|
-
program.command("
|
|
9170
|
-
|
|
10086
|
+
program.command("query [query] [pick]", {
|
|
10087
|
+
isDefault: true,
|
|
10088
|
+
hidden: true
|
|
10089
|
+
}).description("Find and launch a session by topic, name, or UUID.\nNo arg → recent sessions picker\nTopic → history search → candidate list → pick #\nUUID → direct resume via filesystem scan").option("-y, --auto", "Auto-pick #1 without prompting").option("--dry-run", "Print what would happen without launching").option("-n, --n <count>", "Max candidates for history search", "20").action(async (query, pick, opts) => {
|
|
10090
|
+
const pickN = pick !== void 0 ? parseInt(pick, 10) : void 0;
|
|
10091
|
+
await cmdMain(getDb(), query, pickN, opts);
|
|
9171
10092
|
});
|
|
9172
10093
|
program.configureOutput({ writeErr: (str) => process.stderr.write(err(str)) });
|
|
9173
10094
|
program.exitOverride((error) => {
|
|
@@ -9177,7 +10098,6 @@ program.exitOverride((error) => {
|
|
|
9177
10098
|
process.exit(1);
|
|
9178
10099
|
});
|
|
9179
10100
|
program.parse(process.argv);
|
|
9180
|
-
if (process.argv.length <= 2) program.help();
|
|
9181
10101
|
|
|
9182
10102
|
//#endregion
|
|
9183
10103
|
export { };
|