@tekmidian/pai 0.14.2 → 0.16.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/dist/{auto-route-CLYcToZ5.mjs → auto-route-CdvjEWs5.mjs} +4 -4
- package/dist/{auto-route-CLYcToZ5.mjs.map → auto-route-CdvjEWs5.mjs.map} +1 -1
- package/dist/{kg-extraction-C8DEUHTS.mjs → checkpoint-block-Bl0HD3U2.mjs} +442 -13
- package/dist/checkpoint-block-Bl0HD3U2.mjs.map +1 -0
- package/dist/cli/index.mjs +14 -12
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +31 -16
- package/dist/cli/program.mjs.map +1 -1
- package/dist/{clusters-BYPw7vfW.mjs → clusters-CKWDwcMX.mjs} +2 -2
- package/dist/{clusters-BYPw7vfW.mjs.map → clusters-CKWDwcMX.mjs.map} +1 -1
- package/dist/{config-C8m-tPhP.mjs → config-DGOfoGfm.mjs} +1 -1
- package/dist/config-DGOfoGfm.mjs.map +1 -0
- package/dist/daemon/index.mjs +18 -16
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/{daemon-COSvHTNu.mjs → daemon-1rKbN-fd.mjs} +31 -30
- package/dist/{daemon-COSvHTNu.mjs.map → daemon-1rKbN-fd.mjs.map} +1 -1
- package/dist/daemon-mcp/index.mjs +24 -2
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/db-BtuN768f.mjs +206 -0
- package/dist/db-BtuN768f.mjs.map +1 -0
- package/dist/{db-CmYbAVCD.mjs → db-CYmBWcjh.mjs} +1 -1
- package/dist/{db-CmYbAVCD.mjs.map → db-CYmBWcjh.mjs.map} +1 -1
- package/dist/{detect-KjycLtXM.mjs → detect-Bf2z-oKB.mjs} +1 -1
- package/dist/{detect-KjycLtXM.mjs.map → detect-Bf2z-oKB.mjs.map} +1 -1
- package/dist/{detector-ZiOhHszd.mjs → detector-rq9JGTmS.mjs} +2 -2
- package/dist/{detector-ZiOhHszd.mjs.map → detector-rq9JGTmS.mjs.map} +1 -1
- package/dist/{embeddings-BJPOcbik.mjs → embeddings-DGRAPAYb.mjs} +1 -1
- package/dist/{embeddings-BJPOcbik.mjs.map → embeddings-DGRAPAYb.mjs.map} +1 -1
- package/dist/{factory-Q88X1bAN.mjs → factory-HcQsQqh0.mjs} +8 -5
- package/dist/{factory-Q88X1bAN.mjs.map → factory-HcQsQqh0.mjs.map} +1 -1
- package/dist/{helpers-IjZkXBhj.mjs → helpers-crDEr6S2.mjs} +1 -1
- package/dist/{helpers-IjZkXBhj.mjs.map → helpers-crDEr6S2.mjs.map} +1 -1
- package/dist/hooks/capture-all-events.mjs.map +1 -1
- package/dist/hooks/capture-session-summary.mjs.map +1 -1
- package/dist/hooks/capture-tool-output.mjs.map +1 -1
- package/dist/hooks/cleanup-session-files.mjs.map +1 -1
- package/dist/hooks/context-compression-hook.mjs +304 -48
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs.map +1 -1
- package/dist/hooks/inject-observations.mjs.map +1 -1
- package/dist/hooks/load-core-context.mjs.map +1 -1
- package/dist/hooks/load-project-context.mjs.map +1 -1
- package/dist/hooks/observe.mjs.map +1 -1
- package/dist/hooks/post-compact-inject.mjs.map +1 -1
- package/dist/hooks/security-validator.mjs.map +1 -1
- package/dist/hooks/session-commands.mjs.map +1 -1
- package/dist/hooks/stop-hook.mjs +299 -43
- package/dist/hooks/stop-hook.mjs.map +4 -4
- package/dist/hooks/subagent-stop-hook.mjs.map +1 -1
- package/dist/hooks/sync-todo-to-md.mjs.map +1 -1
- package/dist/hooks/update-tab-on-action.mjs.map +1 -1
- package/dist/hooks/update-tab-titles.mjs.map +1 -1
- package/dist/hooks/whisper-rules.mjs.map +1 -1
- package/dist/index.d.mts +0 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +10 -9
- package/dist/indexer-AEcT8wHf.mjs +1 -0
- package/dist/{indexer-backend-Cm5RS7y0.mjs → indexer-backend-BQZXoarl.mjs} +3 -3
- package/dist/{indexer-backend-Cm5RS7y0.mjs.map → indexer-backend-BQZXoarl.mjs.map} +1 -1
- package/dist/{ipc-client-CoyUHPod.mjs → ipc-client-vO2GV325.mjs} +1 -1
- package/dist/{ipc-client-CoyUHPod.mjs.map → ipc-client-vO2GV325.mjs.map} +1 -1
- package/dist/{kg-entity-LXblD7LZ.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
- package/dist/{kg-entity-LXblD7LZ.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
- package/dist/{latent-ideas-RyC6eyqI.mjs → latent-ideas-C6IFFT0-.mjs} +4 -4
- package/dist/{latent-ideas-RyC6eyqI.mjs.map → latent-ideas-C6IFFT0-.mjs.map} +1 -1
- package/dist/{main-resolver-uFxNiDy7.mjs → main-resolver-DimnS2qP.mjs} +22 -22
- package/dist/{main-resolver-uFxNiDy7.mjs.map → main-resolver-DimnS2qP.mjs.map} +1 -1
- package/dist/{migrate-B02wDDgS.mjs → migrate-fLD6rAdO.mjs} +2 -2
- package/dist/{migrate-B02wDDgS.mjs.map → migrate-fLD6rAdO.mjs.map} +1 -1
- package/dist/{neighborhood-CklGIB8r.mjs → neighborhood-CzRNG2Oy.mjs} +2 -2
- package/dist/{neighborhood-CklGIB8r.mjs.map → neighborhood-CzRNG2Oy.mjs.map} +1 -1
- package/dist/{note-context-qvZlVXVO.mjs → note-context-CrfMbr4R.mjs} +1 -1
- package/dist/{note-context-qvZlVXVO.mjs.map → note-context-CrfMbr4R.mjs.map} +1 -1
- package/dist/{pai-marker-HVBwwBW-.mjs → pai-marker-B20KqhA8.mjs} +44 -2
- package/dist/pai-marker-B20KqhA8.mjs.map +1 -0
- package/dist/{pick-gne3kOJG.mjs → pick-B3zEYjin.mjs} +2316 -483
- package/dist/pick-B3zEYjin.mjs.map +1 -0
- package/dist/{postgres-B451Hnkm.mjs → postgres-zjKqPl4X.mjs} +2 -2
- package/dist/{postgres-B451Hnkm.mjs.map → postgres-zjKqPl4X.mjs.map} +1 -1
- package/dist/{query-feedback-fapbqRgh.mjs → query-feedback-BoY8_Dbb.mjs} +1 -1
- package/dist/{query-feedback-fapbqRgh.mjs.map → query-feedback-BoY8_Dbb.mjs.map} +1 -1
- package/dist/{reranker-C08R99zn.mjs → reranker-CMNZcfVx.mjs} +1 -1
- package/dist/{reranker-C08R99zn.mjs.map → reranker-CMNZcfVx.mjs.map} +1 -1
- package/dist/{search-HcdKtMla.mjs → search-i2nlQ-JM.mjs} +3 -3
- package/dist/{search-HcdKtMla.mjs.map → search-i2nlQ-JM.mjs.map} +1 -1
- package/dist/skills/End/SKILL.md +78 -0
- package/dist/skills/Pause/SKILL.md +77 -0
- package/dist/skills/Tasks/SKILL.md +22 -0
- package/dist/{sqlite-DQpY1Esi.mjs → sqlite-ChSCYwiC.mjs} +3 -3
- package/dist/{sqlite-DQpY1Esi.mjs.map → sqlite-ChSCYwiC.mjs.map} +1 -1
- package/dist/{state-BIlxNRUn.mjs → state-BXIdbxDs.mjs} +1 -1
- package/dist/{state-BIlxNRUn.mjs.map → state-BXIdbxDs.mjs.map} +1 -1
- package/dist/{stop-words-BwplsQ3z.mjs → stop-words-BaMEGVeY.mjs} +1 -1
- package/dist/{stop-words-BwplsQ3z.mjs.map → stop-words-BaMEGVeY.mjs.map} +1 -1
- package/dist/{sync-uPR4g438.mjs → sync-CmBKOL3K.mjs} +6 -205
- package/dist/sync-CmBKOL3K.mjs.map +1 -0
- package/dist/{themes-Cg8oG9Ra.mjs → themes-nvRM8v84.mjs} +3 -3
- package/dist/{themes-Cg8oG9Ra.mjs.map → themes-nvRM8v84.mjs.map} +1 -1
- package/dist/{tools-Op3C2Nm6.mjs → tools-CznjzfYz.mjs} +26 -26
- package/dist/{tools-Op3C2Nm6.mjs.map → tools-CznjzfYz.mjs.map} +1 -1
- package/dist/{trace-CLK-NPkb.mjs → trace-B8oz1ok0.mjs} +1 -1
- package/dist/{trace-CLK-NPkb.mjs.map → trace-B8oz1ok0.mjs.map} +1 -1
- package/dist/{utils-CqqgB0dH.mjs → utils-BAxjW3j8.mjs} +1 -1
- package/dist/{utils-CqqgB0dH.mjs.map → utils-BAxjW3j8.mjs.map} +1 -1
- package/dist/{vault-indexer-Dn_BccVK.mjs → vault-indexer-DrttVeEk.mjs} +2 -2
- package/dist/{vault-indexer-Dn_BccVK.mjs.map → vault-indexer-DrttVeEk.mjs.map} +1 -1
- package/dist/{work-queue-worker-BwouhVkb.mjs → work-queue-worker-BPXLkEik.mjs} +47 -30
- package/dist/work-queue-worker-BPXLkEik.mjs.map +1 -0
- package/dist/{zettelkasten-NwBD1NsT.mjs → zettelkasten-CTlt1dGv.mjs} +4 -4
- package/dist/{zettelkasten-NwBD1NsT.mjs.map → zettelkasten-CTlt1dGv.mjs.map} +1 -1
- package/docs/commands/README.md +17 -0
- package/docs/commands/backup.md +1 -1
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +1 -1
- package/docs/commands/end.md +4 -1
- package/docs/commands/help.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +1 -1
- package/docs/commands/notify.md +1 -1
- package/docs/commands/observation.md +1 -1
- package/docs/commands/obsidian.md +1 -1
- package/docs/commands/pause.md +4 -1
- package/docs/commands/project.md +1 -1
- package/docs/commands/projects.md +1 -1
- package/docs/commands/registry.md +18 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/session.md +271 -0
- package/docs/commands/sessions.md +1 -1
- package/docs/commands/setup.md +1 -1
- package/docs/commands/shell-init.md +1 -1
- package/docs/commands/skill.md +1 -1
- package/docs/commands/task.md +2 -1
- package/docs/commands/topic.md +1 -1
- package/docs/commands/update.md +1 -1
- package/docs/commands/zettel.md +1 -1
- package/package.json +1 -1
- package/scripts/build-hooks.mjs +32 -3
- package/src/hooks/ts/lib/project-utils/todo.test.ts +132 -0
- package/src/hooks/ts/lib/project-utils/todo.ts +60 -34
- package/dist/config-C8m-tPhP.mjs.map +0 -1
- package/dist/kg-extraction-C8DEUHTS.mjs.map +0 -1
- package/dist/pai-marker-HVBwwBW-.mjs.map +0 -1
- package/dist/pick-gne3kOJG.mjs.map +0 -1
- package/dist/sync-uPR4g438.mjs.map +0 -1
- package/dist/work-queue-worker-BwouhVkb.mjs.map +0 -1
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
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$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-
|
|
3
|
-
import { a as slugify$1, i as parseSessionFilename } from "./migrate-
|
|
4
|
-
import { n as ensurePaiMarker } from "./pai-marker-
|
|
5
|
-
import { n as openFederation } from "./db-
|
|
6
|
-
import { n as
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { t as
|
|
10
|
-
import { t as
|
|
11
|
-
import {
|
|
12
|
-
import { s as
|
|
13
|
-
import {
|
|
1
|
+
import { n as openRegistry } from "./db-BtuN768f.mjs";
|
|
2
|
+
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$1, o as header, p as slugFromPath, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "./utils-BAxjW3j8.mjs";
|
|
3
|
+
import { a as slugify$1, i as parseSessionFilename } from "./migrate-fLD6rAdO.mjs";
|
|
4
|
+
import { n as ensurePaiMarker } from "./pai-marker-B20KqhA8.mjs";
|
|
5
|
+
import { n as openFederation } from "./db-CYmBWcjh.mjs";
|
|
6
|
+
import { i as indexProject, n as indexAll, t as embedChunks } from "./sync-CmBKOL3K.mjs";
|
|
7
|
+
import { t as STOP_WORDS } from "./stop-words-BaMEGVeY.mjs";
|
|
8
|
+
import { n as populateSlugs, r as searchMemory } from "./search-i2nlQ-JM.mjs";
|
|
9
|
+
import { n as formatDetection, r as formatDetectionJson, t as detectProject } from "./detect-Bf2z-oKB.mjs";
|
|
10
|
+
import { a as readBodyFile, c as loadScanConfig, d as saveScanConfig, f as upsertProject, i as findNotesDir$1, n as applyContinue, o as extractAndStoreTriples, p as upsertSession, r as findLatestNote, s as cmdScan, t as appendCheckpointToNote, u as resolveHome } from "./checkpoint-block-Bl0HD3U2.mjs";
|
|
11
|
+
import { t as PaiClient } from "./ipc-client-vO2GV325.mjs";
|
|
12
|
+
import { a as expandHome, c as UNROUTED, i as ensureConfigDir, n as CONFIG_FILE$2, o as loadConfig, s as OWNER_LABEL_PREFIX, t as CONFIG_DIR } from "./config-DGOfoGfm.mjs";
|
|
13
|
+
import { t as createStorageBackend } from "./factory-HcQsQqh0.mjs";
|
|
14
|
+
import { s as kgQuery } from "./kg-entity-DbOMPdF9.mjs";
|
|
15
|
+
import { a as renderDedupedSessions, c as fetchLiveSessions, d as fmtAge, f as resolveSessionByNameOrId, i as normalizeName$1, l as revealItermSession, o as printExitDir, p as scanSessions, r as buildDeduped, s as callAiBroker, u as sendToSession } from "./main-resolver-DimnS2qP.mjs";
|
|
14
16
|
import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { homedir, platform } from "node:os";
|
|
17
|
+
import { homedir, platform, tmpdir } from "node:os";
|
|
16
18
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
17
19
|
import chalk from "chalk";
|
|
18
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -120,7 +122,7 @@ function cmdPromote(db, opts) {
|
|
|
120
122
|
|
|
121
123
|
//#endregion
|
|
122
124
|
//#region src/cli/commands/project/helpers.ts
|
|
123
|
-
function getProject$
|
|
125
|
+
function getProject$2(db, slug) {
|
|
124
126
|
const direct = db.prepare("SELECT * FROM projects WHERE slug = ?").get(slug);
|
|
125
127
|
if (direct) return direct;
|
|
126
128
|
return db.prepare(`SELECT p.* FROM projects p
|
|
@@ -128,7 +130,7 @@ function getProject$1(db, slug) {
|
|
|
128
130
|
WHERE a.alias = ?`).get(slug);
|
|
129
131
|
}
|
|
130
132
|
function requireProject(db, slug) {
|
|
131
|
-
const project = getProject$
|
|
133
|
+
const project = getProject$2(db, slug);
|
|
132
134
|
if (!project) {
|
|
133
135
|
console.error(err(`Project not found: ${slug}`));
|
|
134
136
|
process.exit(1);
|
|
@@ -144,7 +146,7 @@ function resolveIdentifier(db, identifier) {
|
|
|
144
146
|
const rows = db.prepare("SELECT * FROM projects ORDER BY status ASC, updated_at DESC").all();
|
|
145
147
|
if (num <= rows.length) return rows[num - 1];
|
|
146
148
|
}
|
|
147
|
-
return getProject$
|
|
149
|
+
return getProject$2(db, identifier);
|
|
148
150
|
}
|
|
149
151
|
function getProjectTags(db, projectId) {
|
|
150
152
|
return db.prepare(`SELECT t.name FROM tags t
|
|
@@ -163,7 +165,7 @@ function getLastSessionDate(db, projectId) {
|
|
|
163
165
|
ORDER BY created_at DESC LIMIT 1`).get(projectId);
|
|
164
166
|
return row ? row.created_at : null;
|
|
165
167
|
}
|
|
166
|
-
function upsertTag(db, tagName) {
|
|
168
|
+
function upsertTag$1(db, tagName) {
|
|
167
169
|
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tagName);
|
|
168
170
|
return db.prepare("SELECT id FROM tags WHERE name = ?").get(tagName).id;
|
|
169
171
|
}
|
|
@@ -419,7 +421,7 @@ function cmdList$3(db, opts) {
|
|
|
419
421
|
if (!opts.all && !opts.status && hiddenCount > 0) console.log(dim$1(` ${rows.length} active project(s) (${hiddenCount} archived — use --all to show)`));
|
|
420
422
|
else console.log(dim$1(` ${rows.length} project(s)`));
|
|
421
423
|
}
|
|
422
|
-
function cmdInfo(db, identifier) {
|
|
424
|
+
function cmdInfo$1(db, identifier) {
|
|
423
425
|
const project = resolveIdentifier(db, identifier) ?? requireProject(db, identifier);
|
|
424
426
|
const tags = getProjectTags(db, project.id);
|
|
425
427
|
const aliases = getProjectAliases(db, project.id);
|
|
@@ -489,12 +491,12 @@ function cmdMove(db, slug, newPath) {
|
|
|
489
491
|
console.log(dim$1(` Old path: ${project.root_path}`));
|
|
490
492
|
console.log(dim$1(` New path: ${resolvedNew}`));
|
|
491
493
|
}
|
|
492
|
-
function cmdTag(db, slug, tags) {
|
|
494
|
+
function cmdTag$1(db, slug, tags) {
|
|
493
495
|
const project = requireProject(db, slug);
|
|
494
496
|
const added = [];
|
|
495
497
|
const skipped = [];
|
|
496
498
|
for (const tagName of tags) {
|
|
497
|
-
const tagId = upsertTag(db, tagName);
|
|
499
|
+
const tagId = upsertTag$1(db, tagName);
|
|
498
500
|
if (db.prepare("SELECT 1 FROM project_tags WHERE project_id = ? AND tag_id = ?").get(project.id, tagId)) skipped.push(tagName);
|
|
499
501
|
else {
|
|
500
502
|
db.prepare("INSERT INTO project_tags (project_id, tag_id) VALUES (?, ?)").run(project.id, tagId);
|
|
@@ -510,7 +512,7 @@ function cmdAlias(db, slug, alias) {
|
|
|
510
512
|
console.error(err(`"${alias}" is already a project slug — cannot use as alias.`));
|
|
511
513
|
process.exit(1);
|
|
512
514
|
}
|
|
513
|
-
const project = getProject$
|
|
515
|
+
const project = getProject$2(db, slug);
|
|
514
516
|
try {
|
|
515
517
|
db.prepare("INSERT INTO aliases (alias, project_id) VALUES (?, ?)").run(alias, project.id);
|
|
516
518
|
console.log(ok(`Alias added: ${bold$1(alias)} → ${slug}`));
|
|
@@ -658,7 +660,7 @@ function cmdGo(db, query) {
|
|
|
658
660
|
process.exit(1);
|
|
659
661
|
}
|
|
660
662
|
const q = query.trim().toLowerCase();
|
|
661
|
-
const exact = getProject$
|
|
663
|
+
const exact = getProject$2(db, query);
|
|
662
664
|
if (exact) {
|
|
663
665
|
if (!existsSync(exact.root_path)) {
|
|
664
666
|
const recovered = tryRecoverMovedProject(db, exact);
|
|
@@ -1280,7 +1282,7 @@ function cmdHealth$1(db, opts) {
|
|
|
1280
1282
|
//#region src/cli/commands/project/projects-index.ts
|
|
1281
1283
|
function registerProjectsCommands(projectsCmd, getDb) {
|
|
1282
1284
|
projectsCmd.command("list", { isDefault: true }).description("Show the unified deduped listing (same as `pai`).\nUse --all to include cold / zero-session / archived projects.").option("--all", "Show all entries including cold / zero-session / archived projects").option("-n, --n <count>", "Max rows to show", "20").action(async (opts) => {
|
|
1283
|
-
const { cmdMain } = await import("./main-resolver-
|
|
1285
|
+
const { cmdMain } = await import("./main-resolver-DimnS2qP.mjs").then((n) => n.n);
|
|
1284
1286
|
await cmdMain(getDb(), void 0, void 0, opts);
|
|
1285
1287
|
});
|
|
1286
1288
|
projectsCmd.command("list-raw").description("Raw registry listing with slug, path, status, last_active columns").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) => {
|
|
@@ -1323,7 +1325,7 @@ function registerProjectsCommands(projectsCmd, getDb) {
|
|
|
1323
1325
|
cmdAdd(getDb(), rawPath, opts);
|
|
1324
1326
|
});
|
|
1325
1327
|
projectsCmd.command("info <slug>").description("Show full details for a project").action((slug) => {
|
|
1326
|
-
cmdInfo(getDb(), slug);
|
|
1328
|
+
cmdInfo$1(getDb(), slug);
|
|
1327
1329
|
});
|
|
1328
1330
|
projectsCmd.command("archive <slug>").description("Archive a project").action((slug) => {
|
|
1329
1331
|
cmdArchive(getDb(), slug);
|
|
@@ -1338,7 +1340,7 @@ function registerProjectsCommands(projectsCmd, getDb) {
|
|
|
1338
1340
|
cmdRebind(getDb(), slug, newPath);
|
|
1339
1341
|
});
|
|
1340
1342
|
projectsCmd.command("tag <slug> <tags...>").description("Add one or more tags to a project").action((slug, tags) => {
|
|
1341
|
-
cmdTag(getDb(), slug, tags);
|
|
1343
|
+
cmdTag$1(getDb(), slug, tags);
|
|
1342
1344
|
});
|
|
1343
1345
|
projectsCmd.command("alias <slug> <alias>").description("Register an alternative slug for a project").action((slug, alias) => {
|
|
1344
1346
|
cmdAlias(getDb(), slug, alias);
|
|
@@ -1435,6 +1437,360 @@ function cmdMigrate$1(db) {
|
|
|
1435
1437
|
}
|
|
1436
1438
|
}
|
|
1437
1439
|
|
|
1440
|
+
//#endregion
|
|
1441
|
+
//#region src/cli/commands/registry/dedupe.ts
|
|
1442
|
+
/** Resolve a path to its canonical form, or null when it no longer exists. */
|
|
1443
|
+
function safeRealPath(p) {
|
|
1444
|
+
try {
|
|
1445
|
+
return realpathSync(p);
|
|
1446
|
+
} catch {
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
function countRefs(db, projectId) {
|
|
1451
|
+
const one = (sql) => db.prepare(sql).get(projectId).n;
|
|
1452
|
+
return {
|
|
1453
|
+
sessions: one("SELECT COUNT(*) AS n FROM sessions WHERE project_id = ?"),
|
|
1454
|
+
compaction: one("SELECT COUNT(*) AS n FROM compaction_log WHERE project_id = ?"),
|
|
1455
|
+
aliases: one("SELECT COUNT(*) AS n FROM aliases WHERE project_id = ?"),
|
|
1456
|
+
links: one("SELECT COUNT(*) AS n FROM links WHERE target_project_id = ?"),
|
|
1457
|
+
tags: one("SELECT COUNT(*) AS n FROM project_tags WHERE project_id = ?"),
|
|
1458
|
+
children: one("SELECT COUNT(*) AS n FROM projects WHERE parent_id = ?")
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Choose which row survives.
|
|
1463
|
+
*
|
|
1464
|
+
* The row whose stored path already IS the canonical filesystem path wins:
|
|
1465
|
+
* keeping it means `root_path` ends up correct without rewriting it, and any
|
|
1466
|
+
* external reference to that path keeps resolving. Failing that, the row with
|
|
1467
|
+
* the most sessions wins, then the oldest.
|
|
1468
|
+
*/
|
|
1469
|
+
function pickCanonical(rows, realPath, db) {
|
|
1470
|
+
const exact = rows.filter((r) => r.root_path === realPath);
|
|
1471
|
+
return [...exact.length > 0 ? exact : rows].sort((a, b) => {
|
|
1472
|
+
const sa = countRefs(db, a.id).sessions;
|
|
1473
|
+
const sb = countRefs(db, b.id).sessions;
|
|
1474
|
+
if (sa !== sb) return sb - sa;
|
|
1475
|
+
return a.created_at - b.created_at;
|
|
1476
|
+
})[0];
|
|
1477
|
+
}
|
|
1478
|
+
function analyzeDuplicates(db) {
|
|
1479
|
+
const rows = db.prepare(`SELECT id, slug, display_name, root_path, encoded_dir, status,
|
|
1480
|
+
claude_notes_dir, session_config, created_at
|
|
1481
|
+
FROM projects ORDER BY id`).all();
|
|
1482
|
+
const byRealPath = /* @__PURE__ */ new Map();
|
|
1483
|
+
const stalePaths = [];
|
|
1484
|
+
for (const row of rows) {
|
|
1485
|
+
const rp = safeRealPath(row.root_path);
|
|
1486
|
+
if (!rp) {
|
|
1487
|
+
stalePaths.push(row);
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
const bucket = byRealPath.get(rp);
|
|
1491
|
+
if (bucket) bucket.push(row);
|
|
1492
|
+
else byRealPath.set(rp, [row]);
|
|
1493
|
+
}
|
|
1494
|
+
const groups = [];
|
|
1495
|
+
for (const [realPath, members] of byRealPath) {
|
|
1496
|
+
if (members.length < 2) continue;
|
|
1497
|
+
const canonical = pickCanonical(members, realPath, db);
|
|
1498
|
+
groups.push({
|
|
1499
|
+
realPath,
|
|
1500
|
+
canonical,
|
|
1501
|
+
duplicates: members.filter((m) => m.id !== canonical.id)
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
groups,
|
|
1506
|
+
stalePaths
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Fold a duplicate session row into the one the canonical project already has
|
|
1511
|
+
* for the same note file.
|
|
1512
|
+
*
|
|
1513
|
+
* Both rows describe one file on disk — the double registration produced two
|
|
1514
|
+
* registry rows for it, often identical and created milliseconds apart. Moving
|
|
1515
|
+
* both would leave the merged project with two entries per note and force one
|
|
1516
|
+
* of them to a number that no longer matches its own filename.
|
|
1517
|
+
*
|
|
1518
|
+
* Nothing is discarded: tags and links are repointed at the surviving row, and
|
|
1519
|
+
* any field the survivor is missing is backfilled from the duplicate before it
|
|
1520
|
+
* is deleted.
|
|
1521
|
+
*/
|
|
1522
|
+
function foldSession(db, keepId, dropId) {
|
|
1523
|
+
const tags = db.prepare("SELECT tag_id FROM session_tags WHERE session_id = ?").all(dropId);
|
|
1524
|
+
for (const t of tags) if (db.prepare("SELECT 1 FROM session_tags WHERE session_id = ? AND tag_id = ? LIMIT 1").get(keepId, t.tag_id)) db.prepare("DELETE FROM session_tags WHERE session_id = ? AND tag_id = ?").run(dropId, t.tag_id);
|
|
1525
|
+
else db.prepare("UPDATE session_tags SET session_id = ? WHERE session_id = ? AND tag_id = ?").run(keepId, dropId, t.tag_id);
|
|
1526
|
+
const links = db.prepare("SELECT id, target_project_id FROM links WHERE session_id = ?").all(dropId);
|
|
1527
|
+
for (const l of links) if (db.prepare("SELECT 1 FROM links WHERE session_id = ? AND target_project_id = ? LIMIT 1").get(keepId, l.target_project_id)) db.prepare("DELETE FROM links WHERE id = ?").run(l.id);
|
|
1528
|
+
else db.prepare("UPDATE links SET session_id = ? WHERE id = ?").run(keepId, l.id);
|
|
1529
|
+
db.prepare("UPDATE compaction_log SET session_id = ? WHERE session_id = ?").run(keepId, dropId);
|
|
1530
|
+
db.prepare(`UPDATE sessions SET
|
|
1531
|
+
claude_session_id = COALESCE(claude_session_id,
|
|
1532
|
+
(SELECT claude_session_id FROM sessions WHERE id = ?)),
|
|
1533
|
+
token_count = COALESCE(token_count,
|
|
1534
|
+
(SELECT token_count FROM sessions WHERE id = ?)),
|
|
1535
|
+
closed_at = COALESCE(closed_at,
|
|
1536
|
+
(SELECT closed_at FROM sessions WHERE id = ?)),
|
|
1537
|
+
status = CASE WHEN status = 'open'
|
|
1538
|
+
AND (SELECT status FROM sessions WHERE id = ?) != 'open'
|
|
1539
|
+
THEN (SELECT status FROM sessions WHERE id = ?)
|
|
1540
|
+
ELSE status END
|
|
1541
|
+
WHERE id = ?`).run(dropId, dropId, dropId, dropId, dropId, keepId);
|
|
1542
|
+
db.prepare("DELETE FROM sessions WHERE id = ?").run(dropId);
|
|
1543
|
+
}
|
|
1544
|
+
function mergeGroup(db, group) {
|
|
1545
|
+
const report = {
|
|
1546
|
+
movedSessions: 0,
|
|
1547
|
+
foldedSessions: 0,
|
|
1548
|
+
renumbered: [],
|
|
1549
|
+
movedCompaction: 0,
|
|
1550
|
+
movedAliases: 0,
|
|
1551
|
+
movedLinks: 0,
|
|
1552
|
+
movedTags: 0,
|
|
1553
|
+
movedChildren: 0,
|
|
1554
|
+
deletedRows: 0,
|
|
1555
|
+
reclaimedSlugs: []
|
|
1556
|
+
};
|
|
1557
|
+
const canonicalId = group.canonical.id;
|
|
1558
|
+
let nextNumber = (db.prepare("SELECT MAX(number) AS n FROM sessions WHERE project_id = ?").get(canonicalId).n ?? 0) + 1;
|
|
1559
|
+
for (const dup of group.duplicates) {
|
|
1560
|
+
const sessions = db.prepare("SELECT id, number, title, filename FROM sessions WHERE project_id = ? ORDER BY number").all(dup.id);
|
|
1561
|
+
for (const s of sessions) {
|
|
1562
|
+
const twin = db.prepare("SELECT id FROM sessions WHERE project_id = ? AND filename = ? LIMIT 1").get(canonicalId, s.filename);
|
|
1563
|
+
if (twin) {
|
|
1564
|
+
foldSession(db, twin.id, s.id);
|
|
1565
|
+
report.foldedSessions++;
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (db.prepare("SELECT 1 FROM sessions WHERE project_id = ? AND number = ? LIMIT 1").get(canonicalId, s.number)) {
|
|
1569
|
+
const assigned = nextNumber++;
|
|
1570
|
+
db.prepare("UPDATE sessions SET project_id = ?, number = ? WHERE id = ?").run(canonicalId, assigned, s.id);
|
|
1571
|
+
report.renumbered.push({
|
|
1572
|
+
from: s.number,
|
|
1573
|
+
to: assigned,
|
|
1574
|
+
title: s.title
|
|
1575
|
+
});
|
|
1576
|
+
} else {
|
|
1577
|
+
db.prepare("UPDATE sessions SET project_id = ? WHERE id = ?").run(canonicalId, s.id);
|
|
1578
|
+
if (s.number >= nextNumber) nextNumber = s.number + 1;
|
|
1579
|
+
}
|
|
1580
|
+
report.movedSessions++;
|
|
1581
|
+
}
|
|
1582
|
+
report.movedCompaction += db.prepare("UPDATE compaction_log SET project_id = ? WHERE project_id = ?").run(canonicalId, dup.id).changes;
|
|
1583
|
+
const dupAliases = db.prepare("SELECT alias FROM aliases WHERE project_id = ?").all(dup.id);
|
|
1584
|
+
for (const a of dupAliases) {
|
|
1585
|
+
const existing = db.prepare("SELECT project_id FROM aliases WHERE alias = ?").get(a.alias);
|
|
1586
|
+
if (existing && existing.project_id === canonicalId) continue;
|
|
1587
|
+
db.prepare("UPDATE aliases SET project_id = ? WHERE alias = ?").run(canonicalId, a.alias);
|
|
1588
|
+
report.movedAliases++;
|
|
1589
|
+
}
|
|
1590
|
+
const dupLinks = db.prepare("SELECT id, session_id FROM links WHERE target_project_id = ?").all(dup.id);
|
|
1591
|
+
for (const l of dupLinks) {
|
|
1592
|
+
if (db.prepare("SELECT 1 FROM links WHERE session_id = ? AND target_project_id = ? LIMIT 1").get(l.session_id, canonicalId)) {
|
|
1593
|
+
db.prepare("DELETE FROM links WHERE id = ?").run(l.id);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
db.prepare("UPDATE links SET target_project_id = ? WHERE id = ?").run(canonicalId, l.id);
|
|
1597
|
+
report.movedLinks++;
|
|
1598
|
+
}
|
|
1599
|
+
const dupTags = db.prepare("SELECT tag_id FROM project_tags WHERE project_id = ?").all(dup.id);
|
|
1600
|
+
for (const t of dupTags) {
|
|
1601
|
+
if (db.prepare("SELECT 1 FROM project_tags WHERE project_id = ? AND tag_id = ? LIMIT 1").get(canonicalId, t.tag_id)) {
|
|
1602
|
+
db.prepare("DELETE FROM project_tags WHERE project_id = ? AND tag_id = ?").run(dup.id, t.tag_id);
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
db.prepare("UPDATE project_tags SET project_id = ? WHERE project_id = ? AND tag_id = ?").run(canonicalId, dup.id, t.tag_id);
|
|
1606
|
+
report.movedTags++;
|
|
1607
|
+
}
|
|
1608
|
+
report.movedChildren += db.prepare("UPDATE projects SET parent_id = ? WHERE parent_id = ?").run(canonicalId, dup.id).changes;
|
|
1609
|
+
if (!group.canonical.claude_notes_dir && dup.claude_notes_dir) db.prepare("UPDATE projects SET claude_notes_dir = ? WHERE id = ?").run(dup.claude_notes_dir, canonicalId);
|
|
1610
|
+
if (!group.canonical.session_config && dup.session_config) db.prepare("UPDATE projects SET session_config = ? WHERE id = ?").run(dup.session_config, canonicalId);
|
|
1611
|
+
db.prepare("DELETE FROM projects WHERE id = ?").run(dup.id);
|
|
1612
|
+
report.deletedRows++;
|
|
1613
|
+
}
|
|
1614
|
+
if ((group.canonical.status === "active" || group.duplicates.some((d) => d.status === "active")) && group.canonical.status !== "active") db.prepare("UPDATE projects SET status = 'active' WHERE id = ?").run(canonicalId);
|
|
1615
|
+
const suffixed = /^(.*)-\d+$/.exec(group.canonical.slug);
|
|
1616
|
+
if (suffixed) {
|
|
1617
|
+
const candidates = [suffixed[1], ...group.duplicates.map((d) => d.slug).filter((s) => !/-\d+$/.test(s))];
|
|
1618
|
+
for (const candidate of candidates) {
|
|
1619
|
+
if (db.prepare("SELECT 1 FROM projects WHERE slug = ? LIMIT 1").get(candidate)) continue;
|
|
1620
|
+
db.prepare("UPDATE projects SET slug = ?, updated_at = ? WHERE id = ?").run(candidate, Date.now(), canonicalId);
|
|
1621
|
+
report.reclaimedSlugs.push({
|
|
1622
|
+
from: group.canonical.slug,
|
|
1623
|
+
to: candidate
|
|
1624
|
+
});
|
|
1625
|
+
break;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (group.canonical.root_path !== group.realPath) {
|
|
1629
|
+
if (!db.prepare("SELECT id FROM projects WHERE root_path = ? AND id != ?").get(group.realPath, canonicalId)) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(group.realPath, Date.now(), canonicalId);
|
|
1630
|
+
}
|
|
1631
|
+
return report;
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Rewrite any remaining root_path that is not already its canonical form.
|
|
1635
|
+
*
|
|
1636
|
+
* Merging fixes the rows that already split. This stops the rest from
|
|
1637
|
+
* splitting later: a project registered only as `~/dev/ai/PAILot` still has a
|
|
1638
|
+
* non-canonical path, so the first session started from the other spelling
|
|
1639
|
+
* would create a second row and the whole problem recurs.
|
|
1640
|
+
*
|
|
1641
|
+
* Only rows whose canonical path is free are touched — a collision would mean
|
|
1642
|
+
* a duplicate, and those have already been merged.
|
|
1643
|
+
*/
|
|
1644
|
+
function canonicalizePaths(db, dryRun) {
|
|
1645
|
+
const rows = db.prepare("SELECT id, slug, root_path FROM projects").all();
|
|
1646
|
+
const changed = [];
|
|
1647
|
+
for (const row of rows) {
|
|
1648
|
+
const rp = safeRealPath(row.root_path);
|
|
1649
|
+
if (!rp || rp === row.root_path) continue;
|
|
1650
|
+
if (db.prepare("SELECT 1 FROM projects WHERE root_path = ? AND id != ? LIMIT 1").get(rp, row.id)) continue;
|
|
1651
|
+
changed.push({
|
|
1652
|
+
slug: row.slug,
|
|
1653
|
+
from: row.root_path,
|
|
1654
|
+
to: rp
|
|
1655
|
+
});
|
|
1656
|
+
if (!dryRun) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(rp, Date.now(), row.id);
|
|
1657
|
+
}
|
|
1658
|
+
return changed;
|
|
1659
|
+
}
|
|
1660
|
+
function backupDb(dbPath) {
|
|
1661
|
+
if (!existsSync(dbPath)) return null;
|
|
1662
|
+
const dest = `${dbPath}.bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
1663
|
+
try {
|
|
1664
|
+
copyFileSync(dbPath, dest);
|
|
1665
|
+
for (const suffix of ["-wal", "-shm"]) if (existsSync(dbPath + suffix)) copyFileSync(dbPath + suffix, dest + suffix);
|
|
1666
|
+
return dest;
|
|
1667
|
+
} catch {
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
function cmdDedupe(db, opts) {
|
|
1672
|
+
const { groups, stalePaths } = analyzeDuplicates(db);
|
|
1673
|
+
console.log();
|
|
1674
|
+
console.log(bold$1(opts.execute ? " pai registry dedupe" : " pai registry dedupe — DRY RUN (no changes will be made)"));
|
|
1675
|
+
console.log();
|
|
1676
|
+
if (groups.length === 0) console.log(ok("No duplicate projects found — every root_path resolves uniquely."));
|
|
1677
|
+
for (const g of groups) {
|
|
1678
|
+
console.log(` ${bold$1(g.realPath)}`);
|
|
1679
|
+
const cCounts = countRefs(db, g.canonical.id);
|
|
1680
|
+
console.log(` ${ok("keep")} #${g.canonical.id} ${bold$1(g.canonical.slug)} ` + dim$1(`(${g.canonical.root_path}) — ${cCounts.sessions} sessions, ${g.canonical.status}`));
|
|
1681
|
+
for (const d of g.duplicates) {
|
|
1682
|
+
const dc = countRefs(db, d.id);
|
|
1683
|
+
const moving = [
|
|
1684
|
+
dc.sessions ? `${dc.sessions} sessions` : null,
|
|
1685
|
+
dc.compaction ? `${dc.compaction} compaction rows` : null,
|
|
1686
|
+
dc.aliases ? `${dc.aliases} aliases` : null,
|
|
1687
|
+
dc.links ? `${dc.links} links` : null,
|
|
1688
|
+
dc.tags ? `${dc.tags} tags` : null,
|
|
1689
|
+
dc.children ? `${dc.children} child projects` : null
|
|
1690
|
+
].filter(Boolean).join(", ");
|
|
1691
|
+
console.log(` ${warn("merge")} #${d.id} ${bold$1(d.slug)} ` + dim$1(`(${d.root_path}) — ${moving || "nothing to move"}`));
|
|
1692
|
+
}
|
|
1693
|
+
console.log();
|
|
1694
|
+
}
|
|
1695
|
+
if (stalePaths.length > 0) {
|
|
1696
|
+
console.log(dim$1(` ${stalePaths.length} project(s) point at a path that no longer exists.`));
|
|
1697
|
+
console.log(dim$1(" These are never merged automatically — a missing directory cannot be"));
|
|
1698
|
+
console.log(dim$1(" proven identical to anything else. Listed for review:"));
|
|
1699
|
+
for (const s of stalePaths) {
|
|
1700
|
+
const sc = countRefs(db, s.id);
|
|
1701
|
+
if (sc.sessions === 0 && sc.compaction === 0) continue;
|
|
1702
|
+
console.log(dim$1(` #${s.id} ${s.slug} (${s.root_path}) — ${sc.sessions} sessions, ${sc.compaction} compaction rows`));
|
|
1703
|
+
}
|
|
1704
|
+
console.log();
|
|
1705
|
+
}
|
|
1706
|
+
const pathFixes = canonicalizePaths(db, true);
|
|
1707
|
+
if (pathFixes.length > 0) {
|
|
1708
|
+
console.log(dim$1(` ${pathFixes.length} project(s) stored under a non-canonical path — these would`));
|
|
1709
|
+
console.log(dim$1(" split into a second row the first time a session runs from the other"));
|
|
1710
|
+
console.log(dim$1(" spelling. They will be rewritten to their resolved path:"));
|
|
1711
|
+
for (const p of pathFixes.slice(0, 8)) console.log(dim$1(` ${p.slug}: ${p.from} -> ${p.to}`));
|
|
1712
|
+
if (pathFixes.length > 8) console.log(dim$1(` ... and ${pathFixes.length - 8} more`));
|
|
1713
|
+
console.log();
|
|
1714
|
+
}
|
|
1715
|
+
if (!opts.execute) {
|
|
1716
|
+
if (groups.length > 0 || pathFixes.length > 0) {
|
|
1717
|
+
console.log(dim$1(" Re-run with --execute to apply. The registry is backed up first."));
|
|
1718
|
+
console.log();
|
|
1719
|
+
}
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (groups.length === 0 && pathFixes.length === 0) return;
|
|
1723
|
+
const dbPath = opts.dbPath;
|
|
1724
|
+
if (dbPath) {
|
|
1725
|
+
const backup = backupDb(dbPath);
|
|
1726
|
+
if (backup) console.log(ok(`Registry backed up to ${bold$1(backup)}`));
|
|
1727
|
+
else {
|
|
1728
|
+
console.error(err("Could not back up the registry — refusing to merge without one."));
|
|
1729
|
+
process.exit(1);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
const totals = {
|
|
1733
|
+
movedSessions: 0,
|
|
1734
|
+
foldedSessions: 0,
|
|
1735
|
+
renumbered: [],
|
|
1736
|
+
movedCompaction: 0,
|
|
1737
|
+
movedAliases: 0,
|
|
1738
|
+
movedLinks: 0,
|
|
1739
|
+
movedTags: 0,
|
|
1740
|
+
movedChildren: 0,
|
|
1741
|
+
deletedRows: 0,
|
|
1742
|
+
reclaimedSlugs: []
|
|
1743
|
+
};
|
|
1744
|
+
let canonicalized = 0;
|
|
1745
|
+
const run = db.transaction(() => {
|
|
1746
|
+
for (const g of groups) {
|
|
1747
|
+
const r = mergeGroup(db, g);
|
|
1748
|
+
totals.movedSessions += r.movedSessions;
|
|
1749
|
+
totals.foldedSessions += r.foldedSessions;
|
|
1750
|
+
totals.renumbered.push(...r.renumbered);
|
|
1751
|
+
totals.movedCompaction += r.movedCompaction;
|
|
1752
|
+
totals.movedAliases += r.movedAliases;
|
|
1753
|
+
totals.movedLinks += r.movedLinks;
|
|
1754
|
+
totals.movedTags += r.movedTags;
|
|
1755
|
+
totals.movedChildren += r.movedChildren;
|
|
1756
|
+
totals.deletedRows += r.deletedRows;
|
|
1757
|
+
totals.reclaimedSlugs.push(...r.reclaimedSlugs);
|
|
1758
|
+
}
|
|
1759
|
+
canonicalized = canonicalizePaths(db, false).length;
|
|
1760
|
+
});
|
|
1761
|
+
try {
|
|
1762
|
+
run();
|
|
1763
|
+
} catch (e) {
|
|
1764
|
+
console.error(err(`Merge failed and was rolled back: ${String(e)}`));
|
|
1765
|
+
process.exit(1);
|
|
1766
|
+
}
|
|
1767
|
+
console.log();
|
|
1768
|
+
console.log(ok(`Merged ${totals.deletedRows} duplicate row(s).`));
|
|
1769
|
+
console.log(` sessions moved: ${totals.movedSessions}`);
|
|
1770
|
+
console.log(` sessions folded: ${totals.foldedSessions} ` + dim$1("(same note file registered twice)"));
|
|
1771
|
+
console.log(` compaction rows moved: ${totals.movedCompaction}`);
|
|
1772
|
+
console.log(` aliases moved: ${totals.movedAliases}`);
|
|
1773
|
+
console.log(` links moved: ${totals.movedLinks}`);
|
|
1774
|
+
console.log(` tags moved: ${totals.movedTags}`);
|
|
1775
|
+
console.log(` child projects moved: ${totals.movedChildren}`);
|
|
1776
|
+
console.log(` paths canonicalized: ${canonicalized}`);
|
|
1777
|
+
if (totals.reclaimedSlugs.length > 0) {
|
|
1778
|
+
console.log();
|
|
1779
|
+
console.log(ok("Slugs reclaimed from the merged rows:"));
|
|
1780
|
+
for (const s of totals.reclaimedSlugs) console.log(dim$1(` ${s.from} -> ${s.to}`));
|
|
1781
|
+
}
|
|
1782
|
+
if (totals.renumbered.length > 0) {
|
|
1783
|
+
console.log();
|
|
1784
|
+
console.log(warn(`${totals.renumbered.length} session(s) renumbered to avoid collisions:`));
|
|
1785
|
+
for (const r of totals.renumbered) console.log(dim$1(` ${r.from} -> ${r.to} ${r.title}`));
|
|
1786
|
+
console.log(dim$1(" Session note filenames on disk are unchanged — only the registry number moved."));
|
|
1787
|
+
console.log();
|
|
1788
|
+
console.log(warn(" A renumbered session's registry number no longer matches the number in its"));
|
|
1789
|
+
console.log(dim$1(" filename. The scanner keys sessions by (project, number), so a note whose\n number is now occupied cannot be re-registered until numbering is reconciled.\n Run `pai session cleanup <slug> --execute` on the affected projects to renumber\n notes and registry together."));
|
|
1790
|
+
}
|
|
1791
|
+
console.log();
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1438
1794
|
//#endregion
|
|
1439
1795
|
//#region src/cli/commands/registry/index.ts
|
|
1440
1796
|
function cmdStats$2(db) {
|
|
@@ -1529,6 +1885,12 @@ function registerRegistryCommands(registryCmd, getDb) {
|
|
|
1529
1885
|
registryCmd.command("rebuild").description("Erase all registry data and rebuild from the filesystem (destructive)").action(() => {
|
|
1530
1886
|
cmdRebuild(getDb());
|
|
1531
1887
|
});
|
|
1888
|
+
registryCmd.command("dedupe").description("Merge registry rows that describe the same project.\nTwo spellings of one directory (e.g. via a symlinked path prefix) register\nas separate projects and split session history between them. Rows are grouped\nby resolved path, so a merge only happens when the paths are provably identical.\nDry-run by default; --execute backs up the registry first and merges in one transaction.").option("--execute", "Actually perform the merge (default is dry-run)").action((opts) => {
|
|
1889
|
+
cmdDedupe(getDb(), {
|
|
1890
|
+
execute: opts.execute,
|
|
1891
|
+
dbPath: join(homedir(), ".pai", "registry.db")
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1532
1894
|
registryCmd.command("lookup").description("Find the project slug for a filesystem path (for use in scripts)").requiredOption("--path <path>", "Filesystem path to look up").action((opts) => {
|
|
1533
1895
|
cmdLookup(getDb(), opts.path);
|
|
1534
1896
|
});
|
|
@@ -1655,7 +2017,7 @@ function registerSearchCommand(memoryCmd, getDb) {
|
|
|
1655
2017
|
else if (mode === "semantic" || mode === "hybrid") {
|
|
1656
2018
|
const backend = await createStorageBackend(config);
|
|
1657
2019
|
try {
|
|
1658
|
-
const { generateEmbedding } = await import("./embeddings-
|
|
2020
|
+
const { generateEmbedding } = await import("./embeddings-DGRAPAYb.mjs").then((n) => n.i);
|
|
1659
2021
|
console.log(dim$1("Generating query embedding..."));
|
|
1660
2022
|
const queryEmbedding = await generateEmbedding(query, true);
|
|
1661
2023
|
if (mode === "semantic") results = await backend.searchSemantic(queryEmbedding, searchOpts);
|
|
@@ -1704,13 +2066,13 @@ function registerSearchCommand(memoryCmd, getDb) {
|
|
|
1704
2066
|
return;
|
|
1705
2067
|
}
|
|
1706
2068
|
if (opts.rerank !== false) {
|
|
1707
|
-
const { rerankResults } = await import("./reranker-
|
|
2069
|
+
const { rerankResults } = await import("./reranker-CMNZcfVx.mjs").then((n) => n.r);
|
|
1708
2070
|
console.log(dim$1("Reranking with cross-encoder..."));
|
|
1709
2071
|
results = await rerankResults(query, results, { topK: maxResults });
|
|
1710
2072
|
}
|
|
1711
2073
|
const recencyDays = parseInt(opts.recency ?? String(searchConfig.recencyBoostDays), 10);
|
|
1712
2074
|
if (recencyDays > 0) {
|
|
1713
|
-
const { applyRecencyBoost } = await import("./search-
|
|
2075
|
+
const { applyRecencyBoost } = await import("./search-i2nlQ-JM.mjs").then((n) => n.o);
|
|
1714
2076
|
console.log(dim$1(`Applying recency boost (half-life: ${recencyDays} days)...`));
|
|
1715
2077
|
results = applyRecencyBoost(results, recencyDays);
|
|
1716
2078
|
}
|
|
@@ -2305,8 +2667,8 @@ function cmdLogs(opts) {
|
|
|
2305
2667
|
}
|
|
2306
2668
|
function registerDaemonCommands(daemonCmd) {
|
|
2307
2669
|
daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
|
|
2308
|
-
const { serve } = await import("./daemon-
|
|
2309
|
-
const { loadConfig: lc, ensureConfigDir } = await import("./config-
|
|
2670
|
+
const { serve } = await import("./daemon-1rKbN-fd.mjs").then((n) => n.t);
|
|
2671
|
+
const { loadConfig: lc, ensureConfigDir } = await import("./config-DGOfoGfm.mjs").then((n) => n.r);
|
|
2310
2672
|
ensureConfigDir();
|
|
2311
2673
|
await serve(lc());
|
|
2312
2674
|
});
|
|
@@ -4219,6 +4581,10 @@ async function listProjects(token) {
|
|
|
4219
4581
|
name: p.name
|
|
4220
4582
|
}));
|
|
4221
4583
|
}
|
|
4584
|
+
/** Strip emoji and case so "Whazaa 🐝" and "whazaa" compare equal. */
|
|
4585
|
+
function normalizeName(raw) {
|
|
4586
|
+
return raw.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{200D}]/gu, "").trim().toLowerCase();
|
|
4587
|
+
}
|
|
4222
4588
|
/** Todoist priority is inverted: 4 is urgent, 1 is lowest. */
|
|
4223
4589
|
function toPriority(wire) {
|
|
4224
4590
|
switch (wire) {
|
|
@@ -4301,13 +4667,14 @@ var TodoistProvider = class {
|
|
|
4301
4667
|
if (!token || !this.config.rootProjectId) throw new Error("Todoist provider is not configured — run `pai setup`.");
|
|
4302
4668
|
const labels = [...task.labels ?? []];
|
|
4303
4669
|
if (task.owner && !labels.some((l) => l.toLowerCase() === `pai:${task.owner.toLowerCase()}`)) labels.push(`pai:${task.owner}`);
|
|
4304
|
-
const sectionId = task.owner ? void 0 : this.config.findingsSectionId;
|
|
4670
|
+
const sectionId = task.owner || task.into ? void 0 : this.config.findingsSectionId;
|
|
4671
|
+
const projectId = task.into ? (await this.findOrCreateSubProject(task.into)).id : this.config.rootProjectId;
|
|
4305
4672
|
const created = await call(token, "/tasks", {
|
|
4306
4673
|
method: "POST",
|
|
4307
4674
|
body: {
|
|
4308
4675
|
content: task.title,
|
|
4309
4676
|
description: task.body,
|
|
4310
|
-
project_id:
|
|
4677
|
+
project_id: projectId,
|
|
4311
4678
|
...sectionId ? { section_id: sectionId } : {},
|
|
4312
4679
|
labels,
|
|
4313
4680
|
priority: fromPriority(task.priority),
|
|
@@ -4343,6 +4710,42 @@ var TodoistProvider = class {
|
|
|
4343
4710
|
body: { labels }
|
|
4344
4711
|
});
|
|
4345
4712
|
}
|
|
4713
|
+
/**
|
|
4714
|
+
* Find the sub-project named `name` under the bus root, creating it if absent.
|
|
4715
|
+
*
|
|
4716
|
+
* Per-project sub-projects are the filing convention: a flat pile in the root
|
|
4717
|
+
* buries findings across projects. This exists so the convention can be
|
|
4718
|
+
* executed rather than re-derived — a session that has to infer structure
|
|
4719
|
+
* from the existing project list will sometimes infer cautiously and file
|
|
4720
|
+
* flat, which is the mess the convention prevents.
|
|
4721
|
+
*
|
|
4722
|
+
* Matching is case-insensitive and ignores decoration, so "Whazaa" finds an
|
|
4723
|
+
* existing "Whazaa 🐝" rather than creating a near-duplicate.
|
|
4724
|
+
*/
|
|
4725
|
+
async findOrCreateSubProject(name) {
|
|
4726
|
+
const token = this.token();
|
|
4727
|
+
if (!token || !this.config.rootProjectId) throw new Error("Todoist provider is not configured — run `pai task config`.");
|
|
4728
|
+
const root = this.config.rootProjectId;
|
|
4729
|
+
const projects = await collect(token, "/projects");
|
|
4730
|
+
const want = normalizeName(name);
|
|
4731
|
+
for (const p of projects) {
|
|
4732
|
+
if (p.is_archived || p.is_deleted) continue;
|
|
4733
|
+
if (p.parent_id === root && normalizeName(p.name) === want) return {
|
|
4734
|
+
id: p.id,
|
|
4735
|
+
created: false
|
|
4736
|
+
};
|
|
4737
|
+
}
|
|
4738
|
+
return {
|
|
4739
|
+
id: (await call(token, "/projects", {
|
|
4740
|
+
method: "POST",
|
|
4741
|
+
body: {
|
|
4742
|
+
name,
|
|
4743
|
+
parent_id: root
|
|
4744
|
+
}
|
|
4745
|
+
})).id,
|
|
4746
|
+
created: true
|
|
4747
|
+
};
|
|
4748
|
+
}
|
|
4346
4749
|
/** Append a comment — used to keep run history on the task itself. */
|
|
4347
4750
|
async comment(id, content) {
|
|
4348
4751
|
const token = this.token();
|
|
@@ -4505,7 +4908,7 @@ function registerSetupCommand(program) {
|
|
|
4505
4908
|
//#region src/obsidian/sync/symlinks.ts
|
|
4506
4909
|
/** Symlink management: create, validate, migrate, and clean vault project symlinks. */
|
|
4507
4910
|
/** Find the project-root Notes directory for a project. */
|
|
4508
|
-
function findNotesDir
|
|
4911
|
+
function findNotesDir(rootPath) {
|
|
4509
4912
|
const canonical = join(rootPath, "Notes");
|
|
4510
4913
|
if (existsSync(canonical)) return canonical;
|
|
4511
4914
|
const alt = join(rootPath, ".claude", "Notes");
|
|
@@ -4516,7 +4919,7 @@ function findNotesDir$1(rootPath) {
|
|
|
4516
4919
|
* Find the Claude Code session notes directory from the registry-stored value.
|
|
4517
4920
|
* Returns null if not set, missing on disk, or identical to notesDir.
|
|
4518
4921
|
*/
|
|
4519
|
-
function findClaudeNotesDir(claudeNotesDirFromRegistry, notesDir) {
|
|
4922
|
+
function findClaudeNotesDir$1(claudeNotesDirFromRegistry, notesDir) {
|
|
4520
4923
|
if (!claudeNotesDirFromRegistry) return null;
|
|
4521
4924
|
if (!existsSync(claudeNotesDirFromRegistry)) return null;
|
|
4522
4925
|
if (notesDir && claudeNotesDirFromRegistry === notesDir) return null;
|
|
@@ -4629,8 +5032,8 @@ function syncVault(vaultPath, db) {
|
|
|
4629
5032
|
ORDER BY status ASC, slug ASC`).all();
|
|
4630
5033
|
const takenSlugs = /* @__PURE__ */ new Set();
|
|
4631
5034
|
for (const project of projects) if (project.status === "active") {
|
|
4632
|
-
const notesDir = findNotesDir
|
|
4633
|
-
const claudeNotesDir = findClaudeNotesDir(project.claude_notes_dir, notesDir);
|
|
5035
|
+
const notesDir = findNotesDir(project.root_path);
|
|
5036
|
+
const claudeNotesDir = findClaudeNotesDir$1(project.claude_notes_dir, notesDir);
|
|
4634
5037
|
if (!notesDir && !claudeNotesDir) continue;
|
|
4635
5038
|
const slug = uniqueSlug(project.slug, takenSlugs);
|
|
4636
5039
|
takenSlugs.add(slug);
|
|
@@ -4708,8 +5111,8 @@ function generateIndex(vaultPath, db) {
|
|
|
4708
5111
|
for (const row of active) {
|
|
4709
5112
|
const tags = getTagsForProject.all(row.id).map((t) => `\`${t.name}\``).join(" ");
|
|
4710
5113
|
const lastActive = fmtDate(row.last_active);
|
|
4711
|
-
const notesDir = findNotesDir
|
|
4712
|
-
const claudeNotesDir = findClaudeNotesDir(row.claude_notes_dir, notesDir);
|
|
5114
|
+
const notesDir = findNotesDir(row.root_path);
|
|
5115
|
+
const claudeNotesDir = findClaudeNotesDir$1(row.claude_notes_dir, notesDir);
|
|
4713
5116
|
const sources = [];
|
|
4714
5117
|
if (notesDir) sources.push("notes");
|
|
4715
5118
|
if (claudeNotesDir) sources.push("sessions");
|
|
@@ -5393,7 +5796,7 @@ async function cmdExplore(note, opts) {
|
|
|
5393
5796
|
const depth = parseInt(opts.depth ?? "3", 10);
|
|
5394
5797
|
const direction = opts.direction ?? "both";
|
|
5395
5798
|
const mode = opts.mode ?? "all";
|
|
5396
|
-
const { zettelExplore } = await import("./zettelkasten-
|
|
5799
|
+
const { zettelExplore } = await import("./zettelkasten-CTlt1dGv.mjs");
|
|
5397
5800
|
const result = zettelExplore(getFedDb(), {
|
|
5398
5801
|
startNote: note,
|
|
5399
5802
|
depth,
|
|
@@ -5457,7 +5860,7 @@ async function cmdHealth(opts) {
|
|
|
5457
5860
|
const projectPath = opts.project;
|
|
5458
5861
|
const recentDays = parseInt(opts.days ?? "30", 10);
|
|
5459
5862
|
const includeTypes = opts.include ? opts.include.split(",").map((s) => s.trim()) : void 0;
|
|
5460
|
-
const { zettelHealth } = await import("./zettelkasten-
|
|
5863
|
+
const { zettelHealth } = await import("./zettelkasten-CTlt1dGv.mjs");
|
|
5461
5864
|
const result = zettelHealth(getFedDb(), {
|
|
5462
5865
|
scope,
|
|
5463
5866
|
projectPath,
|
|
@@ -5526,7 +5929,7 @@ async function cmdSurprise(note, opts) {
|
|
|
5526
5929
|
const limit = parseInt(opts.limit ?? "10", 10);
|
|
5527
5930
|
const minSimilarity = parseFloat(opts.minSimilarity ?? "0.3");
|
|
5528
5931
|
const minGraphDistance = parseInt(opts.minDistance ?? "3", 10);
|
|
5529
|
-
const { zettelSurprise } = await import("./zettelkasten-
|
|
5932
|
+
const { zettelSurprise } = await import("./zettelkasten-CTlt1dGv.mjs");
|
|
5530
5933
|
const db = getFedDb();
|
|
5531
5934
|
console.log();
|
|
5532
5935
|
console.log(header(" PAI Zettel Surprise"));
|
|
@@ -5579,7 +5982,7 @@ async function cmdSuggest(note, opts) {
|
|
|
5579
5982
|
const vaultProjectId = parseInt(opts.vaultProjectId, 10);
|
|
5580
5983
|
const limit = parseInt(opts.limit ?? "5", 10);
|
|
5581
5984
|
const excludeLinked = opts.excludeLinked !== false;
|
|
5582
|
-
const { zettelSuggest } = await import("./zettelkasten-
|
|
5985
|
+
const { zettelSuggest } = await import("./zettelkasten-CTlt1dGv.mjs");
|
|
5583
5986
|
const db = getFedDb();
|
|
5584
5987
|
console.log();
|
|
5585
5988
|
console.log(header(" PAI Zettel Suggest"));
|
|
@@ -5630,7 +6033,7 @@ async function cmdConverse(question, opts) {
|
|
|
5630
6033
|
const vaultProjectId = parseInt(opts.vaultProjectId, 10);
|
|
5631
6034
|
const depth = parseInt(opts.depth ?? "2", 10);
|
|
5632
6035
|
const limit = parseInt(opts.limit ?? "15", 10);
|
|
5633
|
-
const { zettelConverse } = await import("./zettelkasten-
|
|
6036
|
+
const { zettelConverse } = await import("./zettelkasten-CTlt1dGv.mjs");
|
|
5634
6037
|
const db = getFedDb();
|
|
5635
6038
|
console.log();
|
|
5636
6039
|
console.log(header(" PAI Zettel Converse"));
|
|
@@ -5691,7 +6094,7 @@ async function cmdThemes(opts) {
|
|
|
5691
6094
|
const minClusterSize = parseInt(opts.minSize ?? "3", 10);
|
|
5692
6095
|
const maxThemes = parseInt(opts.maxThemes ?? "10", 10);
|
|
5693
6096
|
const similarityThreshold = parseFloat(opts.threshold ?? "0.65");
|
|
5694
|
-
const { zettelThemes } = await import("./zettelkasten-
|
|
6097
|
+
const { zettelThemes } = await import("./zettelkasten-CTlt1dGv.mjs");
|
|
5695
6098
|
const db = getFedDb();
|
|
5696
6099
|
console.log();
|
|
5697
6100
|
console.log(header(" PAI Zettel Themes"));
|
|
@@ -7387,7 +7790,7 @@ function registerTaskCommands(taskCmd) {
|
|
|
7387
7790
|
limit: opts.limit
|
|
7388
7791
|
}));
|
|
7389
7792
|
});
|
|
7390
|
-
taskCmd.command("add <title>").description("File a task onto the bus").option("--owner <project>", "PAI project that owns this (adds a pai: label)").option("--body <text>", "Full procedure and reasoning — not just a restatement of the title").option("--due <date>", "Due date (ISO or natural language)").option("--priority <p>", "p1 (highest) … p4 (default)").option("--url <url>", "Reference — prefer a hook:// URL over a file path").action(async (title, opts) => {
|
|
7793
|
+
taskCmd.command("add <title>").description("File a task onto the bus").option("--owner <project>", "PAI project that owns this (adds a pai: label)").option("--body <text>", "Full procedure and reasoning — not just a restatement of the title").option("--due <date>", "Due date (ISO or natural language)").option("--priority <p>", "p1 (highest) … p4 (default)").option("--url <url>", "Reference — prefer a hook:// URL over a file path").option("--into <sub-project>", "File into this sub-project under the bus root, creating it if absent").action(async (title, opts) => {
|
|
7391
7794
|
const provider = buildProvider();
|
|
7392
7795
|
if (!provider) return reportUnconfigured();
|
|
7393
7796
|
if (!opts.body) {
|
|
@@ -7400,7 +7803,8 @@ function registerTaskCommands(taskCmd) {
|
|
|
7400
7803
|
owner: opts.owner ?? null,
|
|
7401
7804
|
due: opts.due,
|
|
7402
7805
|
priority: opts.priority,
|
|
7403
|
-
sourceUrl: opts.url
|
|
7806
|
+
sourceUrl: opts.url,
|
|
7807
|
+
into: opts.into
|
|
7404
7808
|
});
|
|
7405
7809
|
console.log(chalk.green(` Filed: ${created.title}`));
|
|
7406
7810
|
console.log(` ${renderOwner(created)} ${dim("· " + created.id)}`);
|
|
@@ -8277,211 +8681,1888 @@ function registerHelpCommand(program) {
|
|
|
8277
8681
|
}
|
|
8278
8682
|
|
|
8279
8683
|
//#endregion
|
|
8280
|
-
//#region src/
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8684
|
+
//#region src/session/slug-generator.ts
|
|
8685
|
+
/**
|
|
8686
|
+
* Slug generator for PAI session notes.
|
|
8687
|
+
*
|
|
8688
|
+
* Extracts a 1-3 word descriptive slug from Claude Code JSONL transcripts
|
|
8689
|
+
* using keyword frequency analysis — no LLM required.
|
|
8690
|
+
*
|
|
8691
|
+
* JSONL format (one JSON object per line):
|
|
8692
|
+
* - type "user": { type: "user", message: { role: "user", content: string | [{ type, text }] } }
|
|
8693
|
+
* - type "assistant": { type: "assistant", message: { role: "assistant", content: [{ type, text }] } }
|
|
8694
|
+
*/
|
|
8695
|
+
/**
|
|
8696
|
+
* Extract plain text from a parsed JSONL message object.
|
|
8697
|
+
*/
|
|
8698
|
+
function extractText(obj) {
|
|
8699
|
+
const msg = obj.message;
|
|
8700
|
+
if (!msg) return null;
|
|
8701
|
+
const content = msg.content;
|
|
8702
|
+
if (Array.isArray(content)) {
|
|
8703
|
+
const texts = [];
|
|
8704
|
+
for (const block of content) {
|
|
8705
|
+
const b = block;
|
|
8706
|
+
if (b.type === "text" && typeof b.text === "string") texts.push(b.text);
|
|
8707
|
+
}
|
|
8708
|
+
return texts.join(" ") || null;
|
|
8709
|
+
}
|
|
8710
|
+
if (typeof content === "string") return content || null;
|
|
8297
8711
|
return null;
|
|
8298
8712
|
}
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
if (
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
}
|
|
8310
|
-
}
|
|
8311
|
-
let trailingEnd = endIdx;
|
|
8312
|
-
if (trailingEnd < lines.length && lines[trailingEnd].trim() === "---") trailingEnd += 1;
|
|
8313
|
-
const before = lines.slice(0, startIdx);
|
|
8314
|
-
const after = lines.slice(trailingEnd);
|
|
8315
|
-
while (after.length > 0 && after[0].trim() === "") after.shift();
|
|
8316
|
-
return [...before, ...after].join("\n");
|
|
8713
|
+
/**
|
|
8714
|
+
* Tokenize a string into lowercase words, filtering stop words and short tokens.
|
|
8715
|
+
*/
|
|
8716
|
+
function tokenize$1(text) {
|
|
8717
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, " ").split(/\s+/).filter((w) => {
|
|
8718
|
+
if (w.length < 3) return false;
|
|
8719
|
+
if (/^\d+$/.test(w)) return false;
|
|
8720
|
+
if (STOP_WORDS.has(w)) return false;
|
|
8721
|
+
return true;
|
|
8722
|
+
});
|
|
8317
8723
|
}
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8724
|
+
/**
|
|
8725
|
+
* Find the latest JSONL transcript for a project's Claude encoded directory.
|
|
8726
|
+
*
|
|
8727
|
+
* @param encodedDir Claude-encoded project directory name,
|
|
8728
|
+
* e.g. "-Users-alice-dev-ai-PAI"
|
|
8729
|
+
* @returns Absolute path to the most recently modified .jsonl file, or null.
|
|
8730
|
+
*/
|
|
8731
|
+
function findLatestTranscript(encodedDir) {
|
|
8732
|
+
const sessionsDir = join(homedir(), ".claude", "projects", encodedDir, "sessions");
|
|
8733
|
+
if (!existsSync(sessionsDir)) return null;
|
|
8734
|
+
let entries;
|
|
8735
|
+
try {
|
|
8736
|
+
entries = readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
8737
|
+
} catch {
|
|
8738
|
+
return null;
|
|
8329
8739
|
}
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
process.exit(1);
|
|
8740
|
+
if (!entries.length) return null;
|
|
8741
|
+
return entries.map((f) => {
|
|
8742
|
+
const fullPath = join(sessionsDir, f);
|
|
8743
|
+
try {
|
|
8744
|
+
return {
|
|
8745
|
+
path: fullPath,
|
|
8746
|
+
mtime: statSync(fullPath).mtimeMs
|
|
8747
|
+
};
|
|
8748
|
+
} catch {
|
|
8749
|
+
return {
|
|
8750
|
+
path: fullPath,
|
|
8751
|
+
mtime: 0
|
|
8752
|
+
};
|
|
8344
8753
|
}
|
|
8345
|
-
|
|
8346
|
-
|
|
8754
|
+
}).sort((a, b) => b.mtime - a.mtime)[0].path;
|
|
8755
|
+
}
|
|
8756
|
+
/**
|
|
8757
|
+
* Read the last `count` human/assistant message pairs from a JSONL file.
|
|
8758
|
+
*
|
|
8759
|
+
* Synchronous — reads the whole file in memory. JSONL transcripts are
|
|
8760
|
+
* bounded by context window size so this is safe even for large sessions.
|
|
8761
|
+
*
|
|
8762
|
+
* @param jsonlPath Absolute path to a Claude Code .jsonl transcript file.
|
|
8763
|
+
* @param count Number of conversation pairs to read (default: 15).
|
|
8764
|
+
* @returns Array of plain-text message strings (up to count*2 entries).
|
|
8765
|
+
*/
|
|
8766
|
+
function readLastMessages(jsonlPath, count = 15) {
|
|
8767
|
+
if (!existsSync(jsonlPath)) return [];
|
|
8768
|
+
let raw;
|
|
8769
|
+
try {
|
|
8770
|
+
raw = readFileSync(jsonlPath, "utf8");
|
|
8771
|
+
} catch {
|
|
8772
|
+
return [];
|
|
8347
8773
|
}
|
|
8348
|
-
const
|
|
8349
|
-
|
|
8350
|
-
|
|
8351
|
-
|
|
8352
|
-
|
|
8353
|
-
sessionLine = `${num} - ${session.date} - ${titlePart}`;
|
|
8354
|
-
} else sessionLine = "Unknown session";
|
|
8355
|
-
const continueBlock = [
|
|
8356
|
-
"## Continue",
|
|
8357
|
-
"",
|
|
8358
|
-
`> **Last session:** ${sessionLine}`,
|
|
8359
|
-
`> **Paused at:** ${timestamp}`,
|
|
8360
|
-
">",
|
|
8361
|
-
`> Working directory: ${cwd}. Check the latest session note for details.`,
|
|
8362
|
-
"",
|
|
8363
|
-
"---",
|
|
8364
|
-
""
|
|
8365
|
-
].join("\n");
|
|
8366
|
-
const newContent = continueBlock + stripContinueSection(existingContent).trimStart();
|
|
8367
|
-
if (opts.dryRun) {
|
|
8368
|
-
console.log("\n" + chalk.bold("Dry run — would write to:") + " " + chalk.cyan(todoPath));
|
|
8369
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
8370
|
-
console.log(chalk.dim(continueBlock));
|
|
8371
|
-
console.log(chalk.dim("─".repeat(60)));
|
|
8372
|
-
} else {
|
|
8373
|
-
const tmpPath = `${todoPath}.pause.tmp`;
|
|
8774
|
+
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
8775
|
+
const messages = [];
|
|
8776
|
+
const limit = count * 2;
|
|
8777
|
+
for (let i = lines.length - 1; i >= 0 && messages.length < limit; i--) {
|
|
8778
|
+
let obj;
|
|
8374
8779
|
try {
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
try {
|
|
8379
|
-
if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
|
|
8380
|
-
} catch {}
|
|
8381
|
-
console.error(chalk.red("pai session pause: ") + `Failed to write TODO.md: ${String(writeErr)}`);
|
|
8382
|
-
process.exit(1);
|
|
8780
|
+
obj = JSON.parse(lines[i]);
|
|
8781
|
+
} catch {
|
|
8782
|
+
continue;
|
|
8383
8783
|
}
|
|
8384
|
-
|
|
8784
|
+
const type = obj.type;
|
|
8785
|
+
if (type !== "assistant" && type !== "user") continue;
|
|
8786
|
+
const text = extractText(obj);
|
|
8787
|
+
if (text && text.trim().length > 0) messages.unshift(text);
|
|
8385
8788
|
}
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8789
|
+
return messages;
|
|
8790
|
+
}
|
|
8791
|
+
/**
|
|
8792
|
+
* Generate a descriptive 1-3 word slug from conversation message strings.
|
|
8793
|
+
*
|
|
8794
|
+
* Algorithm:
|
|
8795
|
+
* 1. Tokenize all messages, remove stop words and short tokens
|
|
8796
|
+
* 2. Count word frequency
|
|
8797
|
+
* 3. Pick the top `maxWords` most frequent tokens
|
|
8798
|
+
* 4. Join with hyphen, lowercase
|
|
8799
|
+
*
|
|
8800
|
+
* @param messages Array of plain-text conversation messages.
|
|
8801
|
+
* @param opts Optional slug configuration.
|
|
8802
|
+
* @returns A lowercase hyphen-joined slug, e.g. "memory-engine-refactor",
|
|
8803
|
+
* or "unnamed-session" if no meaningful words are found.
|
|
8804
|
+
*/
|
|
8805
|
+
function generateSlug(messages, opts) {
|
|
8806
|
+
const maxWords = opts?.maxWords ?? 3;
|
|
8807
|
+
const maxLength = opts?.maxLength ?? 30;
|
|
8808
|
+
if (!messages.length) return "unnamed-session";
|
|
8809
|
+
const freq = /* @__PURE__ */ new Map();
|
|
8810
|
+
for (const msg of messages) for (const token of tokenize$1(msg)) freq.set(token, (freq.get(token) ?? 0) + 1);
|
|
8811
|
+
if (!freq.size) return "unnamed-session";
|
|
8812
|
+
const topWords = Array.from(freq.entries()).sort((a, b) => b[1] - a[1]).slice(0, maxWords).map(([word]) => word);
|
|
8813
|
+
if (!topWords.length) return "unnamed-session";
|
|
8814
|
+
let slug = topWords.join("-");
|
|
8815
|
+
if (slug.length > maxLength) slug = slug.slice(0, maxLength).replace(/-[^-]*$/, "");
|
|
8816
|
+
return slug || "unnamed-session";
|
|
8404
8817
|
}
|
|
8405
8818
|
|
|
8406
8819
|
//#endregion
|
|
8407
|
-
//#region src/cli/commands/session/
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
return
|
|
8820
|
+
//#region src/cli/commands/session/helpers.ts
|
|
8821
|
+
function getProject$1(db, slug) {
|
|
8822
|
+
return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(slug);
|
|
8823
|
+
}
|
|
8824
|
+
function statusColor(status) {
|
|
8825
|
+
switch (status) {
|
|
8826
|
+
case "completed": return chalk.green(status);
|
|
8827
|
+
case "compacted": return chalk.blue(status);
|
|
8828
|
+
default: return chalk.yellow(status);
|
|
8415
8829
|
}
|
|
8416
|
-
return join(homedir(), ".claude");
|
|
8417
8830
|
}
|
|
8418
|
-
/**
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
*/
|
|
8422
|
-
function findNotesDir(rootPath, encodedDir) {
|
|
8423
|
-
for (const rel of [
|
|
8424
|
-
"Notes",
|
|
8425
|
-
"notes",
|
|
8426
|
-
".claude/Notes"
|
|
8427
|
-
]) {
|
|
8428
|
-
const p = join(rootPath, rel);
|
|
8429
|
-
if (existsSync(p)) return p;
|
|
8430
|
-
}
|
|
8431
|
-
const central = join(getPaiDir(), "projects", encodedDir, "Notes");
|
|
8432
|
-
if (existsSync(central)) return central;
|
|
8433
|
-
return null;
|
|
8831
|
+
/** Convert a slug to title-cased display name: "memory-engine" → "Memory Engine" */
|
|
8832
|
+
function toTitleCase$1(slug) {
|
|
8833
|
+
return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
8434
8834
|
}
|
|
8435
|
-
/**
|
|
8436
|
-
|
|
8437
|
-
|
|
8438
|
-
* then flat notesDir as legacy fallback.
|
|
8439
|
-
*/
|
|
8440
|
-
function findLatestNote(notesDir) {
|
|
8441
|
-
const findIn = (dir) => {
|
|
8442
|
-
if (!existsSync(dir)) return null;
|
|
8443
|
-
const files = readdirSync(dir).filter((f) => /^\d{3,4}[\s_-].*\.md$/.test(f)).sort((a, b) => {
|
|
8444
|
-
return parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
|
|
8445
|
-
});
|
|
8446
|
-
return files.length > 0 ? join(dir, files[files.length - 1]) : null;
|
|
8447
|
-
};
|
|
8448
|
-
const now = /* @__PURE__ */ new Date();
|
|
8449
|
-
const current = findIn(join(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
|
|
8450
|
-
if (current) return current;
|
|
8451
|
-
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
8452
|
-
const prevFound = findIn(join(notesDir, String(prev.getFullYear()), String(prev.getMonth() + 1).padStart(2, "0")));
|
|
8453
|
-
if (prevFound) return prevFound;
|
|
8454
|
-
return findIn(notesDir);
|
|
8835
|
+
/** Notes directory for a project: ~/.claude/projects/<encoded_dir>/Notes/ */
|
|
8836
|
+
function getNotesDir(project) {
|
|
8837
|
+
return join(homedir(), ".claude", "projects", project.encoded_dir, "Notes");
|
|
8455
8838
|
}
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
let
|
|
8463
|
-
if (
|
|
8464
|
-
|
|
8465
|
-
|
|
8839
|
+
/** Format a session filename: number=27, date="2026-02-23" → "0027 - 2026-02-23 - Title.md" */
|
|
8840
|
+
function formatFilename(number, date, titleSlug) {
|
|
8841
|
+
return `${String(number).padStart(4, "0")} - ${date} - ${titleSlug}.md`;
|
|
8842
|
+
}
|
|
8843
|
+
/** Resolve a session by project + number or "latest". Exits on failure. */
|
|
8844
|
+
function resolveSession(db, project, numberOrLatest) {
|
|
8845
|
+
let session;
|
|
8846
|
+
if (numberOrLatest === "latest") session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
|
|
8847
|
+
else {
|
|
8848
|
+
const num = parseInt(numberOrLatest, 10);
|
|
8849
|
+
if (isNaN(num)) {
|
|
8850
|
+
console.error(err(`Invalid session number: ${numberOrLatest}`));
|
|
8851
|
+
process.exit(1);
|
|
8852
|
+
}
|
|
8853
|
+
session = db.prepare("SELECT * FROM sessions WHERE project_id = ? AND number = ?").get(project.id, num);
|
|
8466
8854
|
}
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
path: notePath
|
|
8473
|
-
};
|
|
8855
|
+
if (!session) {
|
|
8856
|
+
console.error(err(`Session ${numberOrLatest} not found in project ${project.slug}`));
|
|
8857
|
+
process.exit(1);
|
|
8858
|
+
}
|
|
8859
|
+
return session;
|
|
8474
8860
|
}
|
|
8475
|
-
function
|
|
8476
|
-
|
|
8477
|
-
|
|
8478
|
-
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
8861
|
+
function upsertTag(db, tagName) {
|
|
8862
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tagName);
|
|
8863
|
+
return db.prepare("SELECT id FROM tags WHERE name = ?").get(tagName).id;
|
|
8864
|
+
}
|
|
8865
|
+
function getSessionTags(db, sessionId) {
|
|
8866
|
+
return db.prepare(`SELECT t.name FROM tags t
|
|
8867
|
+
JOIN session_tags st ON st.tag_id = t.id
|
|
8868
|
+
WHERE st.session_id = ?
|
|
8869
|
+
ORDER BY t.name`).all(sessionId).map((r) => r.name);
|
|
8870
|
+
}
|
|
8871
|
+
|
|
8872
|
+
//#endregion
|
|
8873
|
+
//#region src/cli/commands/session/commands.ts
|
|
8874
|
+
function cmdList(db, projectSlug, opts) {
|
|
8875
|
+
const limit = parseInt(opts.limit ?? "20", 10);
|
|
8876
|
+
const params = [];
|
|
8877
|
+
let query = `
|
|
8878
|
+
SELECT s.*, p.slug AS project_slug, p.display_name AS project_name
|
|
8879
|
+
FROM sessions s
|
|
8880
|
+
JOIN projects p ON p.id = s.project_id
|
|
8881
|
+
`;
|
|
8882
|
+
const where = [];
|
|
8883
|
+
if (projectSlug) {
|
|
8884
|
+
const project = getProject$1(db, projectSlug);
|
|
8885
|
+
if (!project) {
|
|
8886
|
+
console.error(err(`Project not found: ${projectSlug}`));
|
|
8887
|
+
process.exit(1);
|
|
8888
|
+
}
|
|
8889
|
+
where.push("s.project_id = ?");
|
|
8890
|
+
params.push(project.id);
|
|
8891
|
+
}
|
|
8892
|
+
if (opts.status) {
|
|
8893
|
+
where.push("s.status = ?");
|
|
8894
|
+
params.push(opts.status);
|
|
8895
|
+
}
|
|
8896
|
+
if (where.length) query += " WHERE " + where.join(" AND ");
|
|
8897
|
+
query += " ORDER BY s.date DESC, s.number DESC";
|
|
8898
|
+
query += ` LIMIT ${limit}`;
|
|
8899
|
+
const rows = db.prepare(query).all(...params);
|
|
8900
|
+
if (!rows.length) {
|
|
8901
|
+
console.log(warn("No sessions found."));
|
|
8902
|
+
return;
|
|
8903
|
+
}
|
|
8904
|
+
const showProject = !projectSlug;
|
|
8905
|
+
const headers = showProject ? [
|
|
8906
|
+
"#",
|
|
8907
|
+
"Project",
|
|
8908
|
+
"Date",
|
|
8909
|
+
"Title",
|
|
8910
|
+
"Status",
|
|
8911
|
+
"Tokens"
|
|
8912
|
+
] : [
|
|
8913
|
+
"#",
|
|
8914
|
+
"Date",
|
|
8915
|
+
"Title",
|
|
8916
|
+
"Status",
|
|
8917
|
+
"Tokens"
|
|
8918
|
+
];
|
|
8919
|
+
const tableRows = rows.map((r) => {
|
|
8920
|
+
const title = r.title.length > 45 ? r.title.slice(0, 42) + "..." : r.title;
|
|
8921
|
+
const tokens = r.token_count != null ? dim$1(r.token_count.toLocaleString()) : dim$1("—");
|
|
8922
|
+
if (showProject) return [
|
|
8923
|
+
dim$1(`#${r.number}`),
|
|
8924
|
+
r.project_slug,
|
|
8925
|
+
r.date,
|
|
8926
|
+
title,
|
|
8927
|
+
statusColor(r.status),
|
|
8928
|
+
tokens
|
|
8929
|
+
];
|
|
8930
|
+
return [
|
|
8931
|
+
dim$1(`#${r.number}`),
|
|
8932
|
+
r.date,
|
|
8933
|
+
title,
|
|
8934
|
+
statusColor(r.status),
|
|
8935
|
+
tokens
|
|
8936
|
+
];
|
|
8937
|
+
});
|
|
8938
|
+
console.log();
|
|
8939
|
+
if (projectSlug) {
|
|
8940
|
+
const project = getProject$1(db, projectSlug);
|
|
8941
|
+
console.log(` ${bold$1(project.display_name)} sessions:`);
|
|
8942
|
+
console.log();
|
|
8943
|
+
}
|
|
8944
|
+
console.log(renderTable(headers, tableRows));
|
|
8945
|
+
console.log();
|
|
8946
|
+
console.log(dim$1(` ${rows.length} session(s) shown (limit: ${limit})`));
|
|
8947
|
+
}
|
|
8948
|
+
function cmdInfo(db, projectSlug, sessionNumber) {
|
|
8949
|
+
const project = getProject$1(db, projectSlug);
|
|
8950
|
+
if (!project) {
|
|
8951
|
+
console.error(err(`Project not found: ${projectSlug}`));
|
|
8952
|
+
process.exit(1);
|
|
8953
|
+
}
|
|
8954
|
+
const session = resolveSession(db, project, sessionNumber);
|
|
8955
|
+
console.log();
|
|
8956
|
+
console.log(header(` Session #${session.number}: ${session.title}`));
|
|
8957
|
+
console.log();
|
|
8958
|
+
console.log(` ${bold$1("Project:")} ${project.display_name} (${project.slug})`);
|
|
8959
|
+
console.log(` ${bold$1("Date:")} ${session.date}`);
|
|
8960
|
+
console.log(` ${bold$1("Status:")} ${statusColor(session.status)}`);
|
|
8961
|
+
console.log(` ${bold$1("Filename:")} ${session.filename}`);
|
|
8962
|
+
console.log(` ${bold$1("Slug:")} ${session.slug}`);
|
|
8963
|
+
if (session.claude_session_id) console.log(` ${bold$1("Claude ID:")} ${dim$1(session.claude_session_id)}`);
|
|
8964
|
+
if (session.token_count != null) console.log(` ${bold$1("Tokens:")} ${session.token_count.toLocaleString()}`);
|
|
8965
|
+
console.log(` ${bold$1("Created:")} ${fmtDate(session.created_at)}`);
|
|
8966
|
+
if (session.closed_at) console.log(` ${bold$1("Closed:")} ${fmtDate(session.closed_at)}`);
|
|
8967
|
+
console.log();
|
|
8968
|
+
}
|
|
8969
|
+
function cmdRename(db, projectSlug, numberOrLatest, newSlug) {
|
|
8970
|
+
const project = getProject$1(db, projectSlug);
|
|
8971
|
+
if (!project) {
|
|
8972
|
+
console.error(err(`Project not found: ${projectSlug}`));
|
|
8973
|
+
process.exit(1);
|
|
8974
|
+
}
|
|
8975
|
+
const session = resolveSession(db, project, numberOrLatest);
|
|
8976
|
+
const notesDir = getNotesDir(project);
|
|
8977
|
+
if (!existsSync(notesDir)) {
|
|
8978
|
+
console.error(err(`Notes directory not found: ${notesDir}`));
|
|
8979
|
+
process.exit(1);
|
|
8980
|
+
}
|
|
8981
|
+
const titleSlug = toTitleCase$1(newSlug);
|
|
8982
|
+
const newFilename = formatFilename(session.number, session.date, titleSlug);
|
|
8983
|
+
const oldPath = join(notesDir, session.filename);
|
|
8984
|
+
const newPath = join(notesDir, newFilename);
|
|
8985
|
+
if (existsSync(oldPath)) {
|
|
8986
|
+
if (oldPath !== newPath) try {
|
|
8987
|
+
renameSync(oldPath, newPath);
|
|
8988
|
+
} catch (e) {
|
|
8989
|
+
console.error(err(`Failed to rename file: ${e}`));
|
|
8990
|
+
process.exit(1);
|
|
8991
|
+
}
|
|
8992
|
+
} else {
|
|
8993
|
+
console.log(warn(` Note: file not found at expected path: ${session.filename}`));
|
|
8994
|
+
console.log(warn(` Skipping disk rename. Database will still be updated.`));
|
|
8995
|
+
}
|
|
8996
|
+
if (existsSync(newPath)) try {
|
|
8997
|
+
const lines = readFileSync(newPath, "utf8").split("\n");
|
|
8998
|
+
let h1Updated = false;
|
|
8999
|
+
const updated = lines.map((line) => {
|
|
9000
|
+
if (!h1Updated && line.startsWith("# ")) {
|
|
9001
|
+
h1Updated = true;
|
|
9002
|
+
return `# ${titleSlug}`;
|
|
9003
|
+
}
|
|
9004
|
+
return line;
|
|
9005
|
+
});
|
|
9006
|
+
if (!h1Updated) updated.unshift(`# ${titleSlug}`, "");
|
|
9007
|
+
writeFileSync(newPath, updated.join("\n"), "utf8");
|
|
9008
|
+
} catch (e) {
|
|
9009
|
+
console.error(err(`Failed to update H1 in file: ${e}`));
|
|
9010
|
+
}
|
|
9011
|
+
const normalizedSlug = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
9012
|
+
db.prepare("UPDATE sessions SET slug = ?, title = ?, filename = ? WHERE id = ?").run(normalizedSlug, titleSlug, newFilename, session.id);
|
|
9013
|
+
console.log();
|
|
9014
|
+
console.log(ok(` Session #${session.number} renamed.`));
|
|
9015
|
+
console.log(` ${bold$1("Old:")} ${session.filename}`);
|
|
9016
|
+
console.log(` ${bold$1("New:")} ${newFilename}`);
|
|
9017
|
+
console.log(` ${bold$1("Slug:")} ${normalizedSlug}`);
|
|
9018
|
+
console.log(` ${bold$1("Title:")} ${titleSlug}`);
|
|
9019
|
+
console.log();
|
|
9020
|
+
}
|
|
9021
|
+
function cmdSlug(db, projectSlug, numberOrLatest, opts) {
|
|
9022
|
+
const project = getProject$1(db, projectSlug);
|
|
9023
|
+
if (!project) {
|
|
9024
|
+
console.error(err(`Project not found: ${projectSlug}`));
|
|
9025
|
+
process.exit(1);
|
|
9026
|
+
}
|
|
9027
|
+
const session = resolveSession(db, project, numberOrLatest);
|
|
9028
|
+
const transcriptPath = findLatestTranscript(project.encoded_dir);
|
|
9029
|
+
if (!transcriptPath) {
|
|
9030
|
+
console.log(warn(` No JSONL transcripts found for project ${projectSlug}`));
|
|
9031
|
+
console.log("unnamed-session");
|
|
9032
|
+
return;
|
|
9033
|
+
}
|
|
9034
|
+
const messages = readLastMessages(transcriptPath);
|
|
9035
|
+
if (messages.length < 2) {
|
|
9036
|
+
console.log(warn(` Too few messages found (${messages.length}) in transcript`));
|
|
9037
|
+
console.log("unnamed-session");
|
|
9038
|
+
return;
|
|
9039
|
+
}
|
|
9040
|
+
const generatedSlug = generateSlug(messages);
|
|
9041
|
+
console.log(generatedSlug);
|
|
9042
|
+
if (opts.apply) {
|
|
9043
|
+
console.log();
|
|
9044
|
+
console.log(dim$1(` Applying slug to session #${session.number}...`));
|
|
9045
|
+
cmdRename(db, projectSlug, String(session.number), generatedSlug);
|
|
9046
|
+
}
|
|
9047
|
+
}
|
|
9048
|
+
function cmdTag(db, projectSlug, sessionNumber, rawTags) {
|
|
9049
|
+
const project = getProject$1(db, projectSlug);
|
|
9050
|
+
if (!project) {
|
|
9051
|
+
console.error(err(`Project not found: ${projectSlug}`));
|
|
9052
|
+
process.exit(1);
|
|
9053
|
+
}
|
|
9054
|
+
const session = resolveSession(db, project, sessionNumber);
|
|
9055
|
+
if (rawTags.length === 0) {
|
|
9056
|
+
const current = getSessionTags(db, session.id);
|
|
9057
|
+
console.log();
|
|
9058
|
+
if (current.length === 0) console.log(dim$1(` Session #${session.number} has no tags.`));
|
|
9059
|
+
else console.log(` ${bold$1(`Session #${session.number}`)} tags: ${current.map((t) => chalk.cyan(t)).join(", ")}`);
|
|
9060
|
+
console.log();
|
|
9061
|
+
return;
|
|
9062
|
+
}
|
|
9063
|
+
const tags = rawTags.flatMap((t) => t.split(",")).map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
|
|
9064
|
+
if (tags.length === 0) {
|
|
9065
|
+
console.log(warn("No valid tags provided."));
|
|
9066
|
+
return;
|
|
9067
|
+
}
|
|
9068
|
+
const added = [];
|
|
9069
|
+
const skipped = [];
|
|
9070
|
+
for (const tagName of tags) {
|
|
9071
|
+
const tagId = upsertTag(db, tagName);
|
|
9072
|
+
if (db.prepare("SELECT 1 FROM session_tags WHERE session_id = ? AND tag_id = ?").get(session.id, tagId)) skipped.push(tagName);
|
|
9073
|
+
else {
|
|
9074
|
+
db.prepare("INSERT INTO session_tags (session_id, tag_id) VALUES (?, ?)").run(session.id, tagId);
|
|
9075
|
+
added.push(tagName);
|
|
9076
|
+
}
|
|
9077
|
+
}
|
|
9078
|
+
console.log();
|
|
9079
|
+
if (added.length) console.log(ok(` Tagged session #${session.number}: ${added.map((t) => chalk.cyan(t)).join(", ")}`));
|
|
9080
|
+
if (skipped.length) console.log(dim$1(` Already present: ${skipped.join(", ")}`));
|
|
9081
|
+
const allTags = getSessionTags(db, session.id);
|
|
9082
|
+
console.log(` ${bold$1("All tags:")} ${allTags.map((t) => chalk.cyan(t)).join(", ")}`);
|
|
9083
|
+
console.log();
|
|
9084
|
+
}
|
|
9085
|
+
function cmdRoute(db, projectSlug, sessionNumber, targetProjectSlug, opts) {
|
|
9086
|
+
const project = getProject$1(db, projectSlug);
|
|
9087
|
+
if (!project) {
|
|
9088
|
+
console.error(err(`Project not found: ${projectSlug}`));
|
|
9089
|
+
process.exit(1);
|
|
9090
|
+
}
|
|
9091
|
+
const session = resolveSession(db, project, sessionNumber);
|
|
9092
|
+
const targetProject = db.prepare("SELECT id, slug, display_name FROM projects WHERE slug = ?").get(targetProjectSlug);
|
|
9093
|
+
if (!targetProject) {
|
|
9094
|
+
console.error(err(`Target project not found: ${targetProjectSlug}`));
|
|
9095
|
+
process.exit(1);
|
|
9096
|
+
}
|
|
9097
|
+
const validTypes = [
|
|
9098
|
+
"related",
|
|
9099
|
+
"follow-up",
|
|
9100
|
+
"reference"
|
|
9101
|
+
];
|
|
9102
|
+
const linkType = opts.type ?? "related";
|
|
9103
|
+
if (!validTypes.includes(linkType)) {
|
|
9104
|
+
console.error(err(`Invalid link type "${linkType}". Valid: ${validTypes.join(", ")}`));
|
|
9105
|
+
process.exit(1);
|
|
9106
|
+
}
|
|
9107
|
+
try {
|
|
9108
|
+
db.prepare(`INSERT INTO links (session_id, target_project_id, link_type, created_at)
|
|
9109
|
+
VALUES (?, ?, ?, ?)`).run(session.id, targetProject.id, linkType, Date.now());
|
|
9110
|
+
} catch {
|
|
9111
|
+
console.log(warn(` Link already exists: session #${session.number} → ${targetProjectSlug}`));
|
|
9112
|
+
return;
|
|
9113
|
+
}
|
|
9114
|
+
console.log();
|
|
9115
|
+
console.log(ok(` Linked session #${session.number} (${project.slug}) → ${targetProject.display_name} (${targetProjectSlug})`));
|
|
9116
|
+
console.log(dim$1(` Link type: ${linkType}`));
|
|
9117
|
+
console.log();
|
|
9118
|
+
}
|
|
9119
|
+
function cmdActive(db, opts) {
|
|
9120
|
+
const minutes = parseInt(opts.minutes ?? "60", 10);
|
|
9121
|
+
const cutoff = Date.now() - minutes * 60 * 1e3;
|
|
9122
|
+
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
9123
|
+
if (!existsSync(claudeProjectsDir)) {
|
|
9124
|
+
console.log(err("Claude projects directory not found."));
|
|
9125
|
+
return;
|
|
9126
|
+
}
|
|
9127
|
+
const active = [];
|
|
9128
|
+
const entries = readdirSync(claudeProjectsDir);
|
|
9129
|
+
for (const entry of entries) {
|
|
9130
|
+
const projectDir = join(claudeProjectsDir, entry);
|
|
9131
|
+
try {
|
|
9132
|
+
if (!statSync(projectDir).isDirectory()) continue;
|
|
9133
|
+
} catch {
|
|
9134
|
+
continue;
|
|
9135
|
+
}
|
|
9136
|
+
let latestJsonl = null;
|
|
9137
|
+
let latestMtime = 0;
|
|
9138
|
+
try {
|
|
9139
|
+
for (const file of readdirSync(projectDir)) {
|
|
9140
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
9141
|
+
const filePath = join(projectDir, file);
|
|
9142
|
+
try {
|
|
9143
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
9144
|
+
if (mtime > latestMtime) {
|
|
9145
|
+
latestMtime = mtime;
|
|
9146
|
+
latestJsonl = filePath;
|
|
9147
|
+
}
|
|
9148
|
+
} catch {
|
|
9149
|
+
continue;
|
|
9150
|
+
}
|
|
9151
|
+
}
|
|
9152
|
+
} catch {
|
|
9153
|
+
continue;
|
|
9154
|
+
}
|
|
9155
|
+
if (!latestJsonl || latestMtime < cutoff) continue;
|
|
9156
|
+
const project = db.prepare("SELECT slug, display_name, root_path FROM projects WHERE encoded_dir = ?").get(entry);
|
|
9157
|
+
active.push({
|
|
9158
|
+
slug: project?.slug ?? entry,
|
|
9159
|
+
displayName: project?.display_name ?? project?.slug ?? entry,
|
|
9160
|
+
rootPath: project?.root_path ?? "",
|
|
9161
|
+
encodedDir: entry,
|
|
9162
|
+
lastModified: new Date(latestMtime),
|
|
9163
|
+
jsonlFile: latestJsonl
|
|
9164
|
+
});
|
|
9165
|
+
}
|
|
9166
|
+
active.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
9167
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9168
|
+
const deduped = active.filter((a) => {
|
|
9169
|
+
const key = a.slug.replace(/-\d+$/, "");
|
|
9170
|
+
if (seen.has(key)) return false;
|
|
9171
|
+
seen.add(key);
|
|
9172
|
+
return true;
|
|
9173
|
+
});
|
|
9174
|
+
if (opts.json) {
|
|
9175
|
+
console.log(JSON.stringify(deduped.map((a) => ({
|
|
9176
|
+
slug: a.slug,
|
|
9177
|
+
display_name: a.displayName,
|
|
9178
|
+
root_path: a.rootPath,
|
|
9179
|
+
last_modified: a.lastModified.toISOString()
|
|
9180
|
+
})), null, 2));
|
|
9181
|
+
return;
|
|
9182
|
+
}
|
|
9183
|
+
if (deduped.length === 0) {
|
|
9184
|
+
console.log(dim$1(`No active sessions in the last ${minutes} minutes.`));
|
|
9185
|
+
return;
|
|
9186
|
+
}
|
|
9187
|
+
console.log(header(`Currently Active Sessions`) + dim$1(` (modified in last ${minutes}min)`));
|
|
9188
|
+
console.log();
|
|
9189
|
+
const rows = deduped.map((a) => {
|
|
9190
|
+
const time = a.lastModified.toTimeString().slice(0, 5);
|
|
9191
|
+
const dirName = a.rootPath ? a.rootPath.replace(homedir(), "~").split("/").pop() ?? a.slug : a.slug;
|
|
9192
|
+
return [
|
|
9193
|
+
chalk.cyan(dirName),
|
|
9194
|
+
dim$1(a.slug),
|
|
9195
|
+
chalk.green(time)
|
|
9196
|
+
];
|
|
9197
|
+
});
|
|
9198
|
+
console.log(renderTable([
|
|
9199
|
+
"Directory",
|
|
9200
|
+
"Project",
|
|
9201
|
+
"Last Active"
|
|
9202
|
+
], rows));
|
|
9203
|
+
}
|
|
9204
|
+
async function cmdAutoRoute(opts) {
|
|
9205
|
+
const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("./auto-route-CdvjEWs5.mjs");
|
|
9206
|
+
const { openRegistry } = await import("./db-BtuN768f.mjs").then((n) => n.t);
|
|
9207
|
+
const { createStorageBackend } = await import("./factory-HcQsQqh0.mjs").then((n) => n.n);
|
|
9208
|
+
const { loadConfig } = await import("./config-DGOfoGfm.mjs").then((n) => n.r);
|
|
9209
|
+
const config = loadConfig();
|
|
9210
|
+
const registryDb = openRegistry();
|
|
9211
|
+
const federation = await createStorageBackend(config);
|
|
9212
|
+
const targetCwd = opts.cwd ?? process.cwd();
|
|
9213
|
+
const result = await autoRoute(registryDb, federation, targetCwd, opts.context);
|
|
9214
|
+
if (!result) {
|
|
9215
|
+
console.log();
|
|
9216
|
+
console.log(warn(" No project match found for: " + targetCwd));
|
|
9217
|
+
console.log();
|
|
9218
|
+
console.log(dim$1(" Tried: path match, PAI.md marker walk") + (opts.context ? dim$1(", topic detection") : ""));
|
|
9219
|
+
console.log();
|
|
9220
|
+
console.log(dim$1(" Run 'pai project add .' to register this directory."));
|
|
9221
|
+
console.log();
|
|
9222
|
+
return;
|
|
9223
|
+
}
|
|
9224
|
+
if (opts.json) {
|
|
9225
|
+
console.log(formatAutoRouteJson(result));
|
|
9226
|
+
return;
|
|
9227
|
+
}
|
|
9228
|
+
console.log();
|
|
9229
|
+
console.log(header(" PAI Auto-Route"));
|
|
9230
|
+
console.log();
|
|
9231
|
+
console.log(` ${bold$1("Project:")} ${result.display_name}`);
|
|
9232
|
+
console.log(` ${bold$1("Slug:")} ${result.slug}`);
|
|
9233
|
+
console.log(` ${bold$1("Root path:")} ${result.root_path}`);
|
|
9234
|
+
console.log(` ${bold$1("Method:")} ${result.method}`);
|
|
9235
|
+
console.log(` ${bold$1("Confidence:")} ${(result.confidence * 100).toFixed(0)}%`);
|
|
9236
|
+
console.log();
|
|
9237
|
+
console.log(ok(" Routed to: ") + bold$1(result.slug));
|
|
9238
|
+
console.log();
|
|
9239
|
+
}
|
|
9240
|
+
|
|
9241
|
+
//#endregion
|
|
9242
|
+
//#region src/cli/commands/session/checkpoint.ts
|
|
9243
|
+
/**
|
|
9244
|
+
* Session checkpoint command — appends a timestamped block to the active
|
|
9245
|
+
* session note. Designed for use in hooks; fast, silent, rate-limited.
|
|
9246
|
+
*/
|
|
9247
|
+
function findNotesDirForCwd() {
|
|
9248
|
+
const cwd = process.cwd();
|
|
9249
|
+
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
9250
|
+
if (!existsSync(claudeProjectsDir)) return null;
|
|
9251
|
+
const expectedEncoded = cwd.replace(/[/\s.\-]/g, "-");
|
|
9252
|
+
let encodedDir = null;
|
|
9253
|
+
try {
|
|
9254
|
+
const entries = readdirSync(claudeProjectsDir);
|
|
9255
|
+
if (entries.includes(expectedEncoded)) encodedDir = expectedEncoded;
|
|
9256
|
+
else for (const entry of entries) {
|
|
9257
|
+
const full = join(claudeProjectsDir, entry);
|
|
9258
|
+
try {
|
|
9259
|
+
if (!statSync(full).isDirectory()) continue;
|
|
9260
|
+
} catch {
|
|
9261
|
+
continue;
|
|
9262
|
+
}
|
|
9263
|
+
if (entry.replace(/-+$/, "") === expectedEncoded.replace(/-+$/, "")) {
|
|
9264
|
+
encodedDir = entry;
|
|
9265
|
+
break;
|
|
9266
|
+
}
|
|
9267
|
+
}
|
|
9268
|
+
} catch {
|
|
9269
|
+
return null;
|
|
9270
|
+
}
|
|
9271
|
+
if (!encodedDir) return null;
|
|
9272
|
+
const notesDir = join(claudeProjectsDir, encodedDir, "Notes");
|
|
9273
|
+
return existsSync(notesDir) ? notesDir : null;
|
|
9274
|
+
}
|
|
9275
|
+
function findLatestNoteFile(notesDir) {
|
|
9276
|
+
let entries;
|
|
9277
|
+
try {
|
|
9278
|
+
entries = readdirSync(notesDir);
|
|
9279
|
+
} catch {
|
|
9280
|
+
return null;
|
|
9281
|
+
}
|
|
9282
|
+
const mdFiles = entries.filter((e) => e.endsWith(".md"));
|
|
9283
|
+
if (mdFiles.length === 0) return null;
|
|
9284
|
+
let latestPath = null;
|
|
9285
|
+
let latestMtime = 0;
|
|
9286
|
+
for (const file of mdFiles) {
|
|
9287
|
+
const full = join(notesDir, file);
|
|
9288
|
+
try {
|
|
9289
|
+
const { mtimeMs } = statSync(full);
|
|
9290
|
+
if (mtimeMs > latestMtime) {
|
|
9291
|
+
latestMtime = mtimeMs;
|
|
9292
|
+
latestPath = full;
|
|
9293
|
+
}
|
|
9294
|
+
} catch {}
|
|
9295
|
+
}
|
|
9296
|
+
return latestPath;
|
|
9297
|
+
}
|
|
9298
|
+
function checkpointTooRecent(notesDir, minGapSeconds) {
|
|
9299
|
+
const safeKey = notesDir.replace(/[^a-zA-Z0-9]/g, "-").slice(-80);
|
|
9300
|
+
const tmpFile = join(tmpdir(), `pai-checkpoint-${safeKey}`);
|
|
9301
|
+
if (!existsSync(tmpFile)) return false;
|
|
9302
|
+
try {
|
|
9303
|
+
const { mtimeMs } = statSync(tmpFile);
|
|
9304
|
+
return Date.now() - mtimeMs < minGapSeconds * 1e3;
|
|
9305
|
+
} catch {
|
|
9306
|
+
return false;
|
|
9307
|
+
}
|
|
9308
|
+
}
|
|
9309
|
+
function touchCheckpointSentinel(notesDir) {
|
|
9310
|
+
const safeKey = notesDir.replace(/[^a-zA-Z0-9]/g, "-").slice(-80);
|
|
9311
|
+
const tmpFile = join(tmpdir(), `pai-checkpoint-${safeKey}`);
|
|
9312
|
+
try {
|
|
9313
|
+
writeFileSync(tmpFile, String(Date.now()), "utf8");
|
|
9314
|
+
} catch {}
|
|
9315
|
+
}
|
|
9316
|
+
function cmdCheckpoint(message, opts) {
|
|
9317
|
+
const minGapSeconds = parseInt(opts.minGap ?? "300", 10);
|
|
9318
|
+
const notesDir = findNotesDirForCwd();
|
|
9319
|
+
if (!notesDir) process.exit(0);
|
|
9320
|
+
if (checkpointTooRecent(notesDir, minGapSeconds)) process.exit(0);
|
|
9321
|
+
const notePath = findLatestNoteFile(notesDir);
|
|
9322
|
+
if (!notePath) process.exit(0);
|
|
9323
|
+
const block = `\n## Checkpoint — ${(/* @__PURE__ */ new Date()).toISOString()}\n${message}\n`;
|
|
9324
|
+
const tmpPath = `${notePath}.checkpoint.tmp`;
|
|
9325
|
+
try {
|
|
9326
|
+
writeFileSync(tmpPath, readFileSync(notePath, "utf8") + block, "utf8");
|
|
9327
|
+
renameSync(tmpPath, notePath);
|
|
9328
|
+
} catch {
|
|
9329
|
+
try {
|
|
9330
|
+
if (existsSync(tmpPath)) renameSync(tmpPath, tmpPath + ".dead");
|
|
9331
|
+
} catch {}
|
|
9332
|
+
process.exit(0);
|
|
9333
|
+
}
|
|
9334
|
+
touchCheckpointSentinel(notesDir);
|
|
9335
|
+
process.exit(0);
|
|
9336
|
+
}
|
|
9337
|
+
|
|
9338
|
+
//#endregion
|
|
9339
|
+
//#region src/cli/commands/session/pause.ts
|
|
9340
|
+
/** Format a session row as "0003 - 2026-08-01 - Title". */
|
|
9341
|
+
function formatSessionLine(session) {
|
|
9342
|
+
if (!session) return "Unknown session";
|
|
9343
|
+
const num = String(session.number).padStart(4, "0");
|
|
9344
|
+
const titlePart = session.title || session.slug || "Session";
|
|
9345
|
+
return `${num} - ${session.date} - ${titlePart}`;
|
|
9346
|
+
}
|
|
9347
|
+
/**
|
|
9348
|
+
* Resolve the string that identifies a session in a ## Continue block.
|
|
9349
|
+
*
|
|
9350
|
+
* THIS IS THE PRESERVATION KEY. It decides whether an authored checkpoint
|
|
9351
|
+
* belongs to the current session and must be left alone, so every writer must
|
|
9352
|
+
* derive it identically — `pai pause`, `pai session handover`, and the hooks'
|
|
9353
|
+
* updateTodoContinue. Deriving it in more than one place is how a checkpoint
|
|
9354
|
+
* gets clobbered: one writer says "0006 - …" and the other says
|
|
9355
|
+
* "Unknown session", the keys do not match, and the unattended writer replaces
|
|
9356
|
+
* work it should have preserved.
|
|
9357
|
+
*
|
|
9358
|
+
* The registry row is preferred. When the project has no session rows — which
|
|
9359
|
+
* happens whenever one codebase is registered under several paths — it falls
|
|
9360
|
+
* back to the session note's filename, which is what the hooks use.
|
|
9361
|
+
*/
|
|
9362
|
+
function resolveSessionLine(project, session, notePath) {
|
|
9363
|
+
if (session) return formatSessionLine(session);
|
|
9364
|
+
if (notePath) return basename(notePath).replace(/\.md$/, "");
|
|
9365
|
+
return "Unknown session";
|
|
9366
|
+
}
|
|
9367
|
+
/** Locate the latest session note for a project, or null. */
|
|
9368
|
+
function resolveNotePath(project) {
|
|
9369
|
+
const notesDir = findNotesDir$1(project.root_path, project.encoded_dir ?? "");
|
|
9370
|
+
return notesDir ? findLatestNote(notesDir) : null;
|
|
9371
|
+
}
|
|
9372
|
+
/**
|
|
9373
|
+
* Resolve the project containing cwd, or undefined.
|
|
9374
|
+
*
|
|
9375
|
+
* Tries the literal path first, then its resolved form. A symlinked path
|
|
9376
|
+
* prefix (`~/dev -> Cloud/Development`) makes one directory reachable under
|
|
9377
|
+
* two spellings; matching only the literal string means a session started from
|
|
9378
|
+
* the other spelling finds nothing, and the scanner then registers a second
|
|
9379
|
+
* project for the same directory — which splits session history in half.
|
|
9380
|
+
*/
|
|
9381
|
+
function resolveProjectByCwd(db, cwd) {
|
|
9382
|
+
const stmt = db.prepare(`SELECT id, slug, display_name, root_path, encoded_dir
|
|
9383
|
+
FROM projects
|
|
9384
|
+
WHERE ? LIKE root_path || '%'
|
|
9385
|
+
ORDER BY length(root_path) DESC
|
|
9386
|
+
LIMIT 1`);
|
|
9387
|
+
const direct = stmt.get(cwd);
|
|
9388
|
+
if (direct) return direct;
|
|
9389
|
+
let resolved;
|
|
9390
|
+
try {
|
|
9391
|
+
resolved = realpathSync(cwd);
|
|
9392
|
+
} catch {
|
|
9393
|
+
return;
|
|
9394
|
+
}
|
|
9395
|
+
if (resolved === cwd) return void 0;
|
|
9396
|
+
return stmt.get(resolved);
|
|
9397
|
+
}
|
|
9398
|
+
function printMissingBodyError() {
|
|
9399
|
+
console.error("\n" + chalk.bgRed.white.bold(" CHECKPOINT BODY MISSING — nothing was written ") + "\n\n" + chalk.yellow(" A checkpoint without a body is just a timestamp. The next session\n would learn nothing about what was decided or what is blocked.\n") + "\n" + chalk.white(" Write your checkpoint markdown to a file, then re-run:\n") + "\n" + chalk.cyan(" pai pause --body-file /path/to/checkpoint.md \\\n") + chalk.cyan(" --session-id <claude-session-uuid>\n") + "\n" + chalk.dim(" Or pipe it: ") + chalk.cyan("cat checkpoint.md | pai pause --body-file -\n") + "\n" + chalk.dim(" If you genuinely want a metadata-only checkpoint, pass --no-body.\n"));
|
|
9400
|
+
}
|
|
9401
|
+
function cmdPause(db, opts) {
|
|
9402
|
+
let body = "";
|
|
9403
|
+
if (opts.bodyFile) {
|
|
9404
|
+
try {
|
|
9405
|
+
body = readBodyFile(opts.bodyFile).trim();
|
|
9406
|
+
} catch (err) {
|
|
9407
|
+
console.error(chalk.red("pai pause: ") + `Could not read checkpoint body from ${opts.bodyFile}: ${String(err)}`);
|
|
9408
|
+
process.exit(1);
|
|
9409
|
+
}
|
|
9410
|
+
if (!body) {
|
|
9411
|
+
console.error(chalk.red("pai pause: ") + `Checkpoint body at ${opts.bodyFile} is empty.`);
|
|
9412
|
+
process.exit(1);
|
|
9413
|
+
}
|
|
9414
|
+
} else if (!opts.noBody) {
|
|
9415
|
+
printMissingBodyError();
|
|
9416
|
+
process.exit(1);
|
|
9417
|
+
}
|
|
9418
|
+
const cwd = process.cwd();
|
|
9419
|
+
const project = resolveProjectByCwd(db, cwd);
|
|
9420
|
+
if (!project) {
|
|
9421
|
+
console.error(chalk.red("pai pause: ") + `Current directory is not within a registered PAI project.
|
|
9422
|
+
cwd: ${cwd}`);
|
|
9423
|
+
process.exit(1);
|
|
9424
|
+
}
|
|
9425
|
+
const session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
|
|
9426
|
+
const notePath = resolveNotePath(project);
|
|
9427
|
+
const sessionLine = resolveSessionLine(project, session, notePath);
|
|
9428
|
+
if (!session) {
|
|
9429
|
+
const siblings = db.prepare(`SELECT p.slug, p.root_path, COUNT(s.id) AS n
|
|
9430
|
+
FROM projects p LEFT JOIN sessions s ON s.project_id = p.id
|
|
9431
|
+
WHERE p.id != ? AND (p.slug = ? OR p.slug LIKE ? || '-%' OR ? LIKE p.slug || '-%')
|
|
9432
|
+
GROUP BY p.id HAVING n > 0
|
|
9433
|
+
ORDER BY n DESC LIMIT 3`).all(project.id, project.slug, project.slug, project.slug);
|
|
9434
|
+
console.log(chalk.yellow(` Note: project '${project.slug}' has no registered sessions; the checkpoint is labelled from the session note instead ("${sessionLine}").`));
|
|
9435
|
+
for (const s of siblings) console.log(chalk.dim(` '${s.slug}' (${s.root_path}) has ${s.n} — likely the same project under another path.`));
|
|
9436
|
+
}
|
|
9437
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
9438
|
+
const result = applyContinue({
|
|
9439
|
+
rootPath: project.root_path,
|
|
9440
|
+
authored: body ? "model" : "auto",
|
|
9441
|
+
sessionLine,
|
|
9442
|
+
sessionId: opts.sessionId,
|
|
9443
|
+
cwd,
|
|
9444
|
+
body,
|
|
9445
|
+
timestamp,
|
|
9446
|
+
dryRun: opts.dryRun
|
|
9447
|
+
});
|
|
9448
|
+
if (result.action === "failed") {
|
|
9449
|
+
console.error(chalk.red("pai pause: ") + `Failed to write TODO.md: ${result.error}`);
|
|
9450
|
+
process.exit(1);
|
|
9451
|
+
}
|
|
9452
|
+
if (opts.dryRun) {
|
|
9453
|
+
console.log("\n" + chalk.bold("Dry run — would write to:") + " " + chalk.cyan(result.path ?? "?"));
|
|
9454
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
9455
|
+
console.log(chalk.dim(result.block));
|
|
9456
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
9457
|
+
} else console.log(chalk.green(" ## Continue written to: ") + chalk.cyan(result.path ?? "?"));
|
|
9458
|
+
if (body) if (!notePath) console.log(chalk.yellow(" (No session note found — checkpoint saved to TODO.md only.)"));
|
|
9459
|
+
else if (opts.dryRun) console.log(chalk.dim(` Would append checkpoint to: ${basename(notePath)}`));
|
|
9460
|
+
else {
|
|
9461
|
+
const { appended, error } = appendCheckpointToNote(notePath, body, timestamp);
|
|
9462
|
+
if (appended) console.log(chalk.green(" Checkpoint appended to note: ") + chalk.cyan(basename(notePath)));
|
|
9463
|
+
else if (error) console.log(chalk.yellow(` (Could not append to session note: ${error})`));
|
|
9464
|
+
}
|
|
9465
|
+
const box = [
|
|
9466
|
+
"",
|
|
9467
|
+
chalk.bgRed.white.bold(" IMPORTANT: How to exit safely "),
|
|
9468
|
+
"",
|
|
9469
|
+
chalk.yellow(" Inside the Claude Code session, type: " + chalk.white.bold("/exit") + chalk.yellow(" (then press Enter)")),
|
|
9470
|
+
"",
|
|
9471
|
+
chalk.red.bold(" DO NOT press Ctrl+C."),
|
|
9472
|
+
"",
|
|
9473
|
+
chalk.dim(" Ctrl+C bypasses PAI's stop-hook, which means:"),
|
|
9474
|
+
chalk.dim(" - No project snapshot is written to the top-level jsonl"),
|
|
9475
|
+
chalk.dim(" - The session becomes orphaned (cannot --resume next time)"),
|
|
9476
|
+
chalk.dim(" - pai session goto will launch a fresh session instead of resuming"),
|
|
9477
|
+
"",
|
|
9478
|
+
chalk.dim(" The ## Continue checkpoint in TODO.md is already saved."),
|
|
9479
|
+
chalk.dim(" Use /exit to preserve full session resumability."),
|
|
9480
|
+
""
|
|
9481
|
+
].join("\n");
|
|
9482
|
+
console.log(box);
|
|
9483
|
+
}
|
|
9484
|
+
|
|
9485
|
+
//#endregion
|
|
9486
|
+
//#region src/cli/commands/session/handover.ts
|
|
9487
|
+
function cmdHandover(db, projectSlug, numberOrLatest) {
|
|
9488
|
+
let project;
|
|
9489
|
+
if (projectSlug) {
|
|
9490
|
+
project = db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(projectSlug);
|
|
9491
|
+
if (!project) process.exit(0);
|
|
9492
|
+
} else {
|
|
9493
|
+
const row = resolveProjectByCwd(db, process.cwd());
|
|
9494
|
+
if (!row) process.exit(0);
|
|
9495
|
+
project = row;
|
|
9496
|
+
}
|
|
9497
|
+
let session;
|
|
9498
|
+
const nol = numberOrLatest ?? "latest";
|
|
9499
|
+
if (nol === "latest") session = db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number DESC LIMIT 1").get(project.id);
|
|
9500
|
+
else {
|
|
9501
|
+
const num = parseInt(nol, 10);
|
|
9502
|
+
if (!isNaN(num)) session = db.prepare("SELECT * FROM sessions WHERE project_id = ? AND number = ?").get(project.id, num);
|
|
9503
|
+
}
|
|
9504
|
+
applyContinue({
|
|
9505
|
+
rootPath: project.root_path,
|
|
9506
|
+
authored: "auto",
|
|
9507
|
+
sessionLine: resolveSessionLine(project, session, resolveNotePath(project)),
|
|
9508
|
+
cwd: process.cwd()
|
|
9509
|
+
});
|
|
9510
|
+
process.exit(0);
|
|
9511
|
+
}
|
|
9512
|
+
|
|
9513
|
+
//#endregion
|
|
9514
|
+
//#region src/cli/commands/session/recent.ts
|
|
9515
|
+
function fmtSize(bytes) {
|
|
9516
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
9517
|
+
if (bytes < 1048576) return `${Math.round(bytes / 1024)}K`;
|
|
9518
|
+
return `${(bytes / 1048576).toFixed(1)}M`;
|
|
9519
|
+
}
|
|
9520
|
+
function fmtStatus(status) {
|
|
9521
|
+
switch (status) {
|
|
9522
|
+
case "resumable": return chalk.green("resumable");
|
|
9523
|
+
case "stub": return chalk.yellow("stub");
|
|
9524
|
+
case "transcript-only": return chalk.yellow("transcript-only");
|
|
9525
|
+
case "orphan": return chalk.dim("orphan");
|
|
9526
|
+
}
|
|
9527
|
+
}
|
|
9528
|
+
function renderLiveSessions(allLiveSessions, allTabs) {
|
|
9529
|
+
const displayed = allTabs ? allLiveSessions : allLiveSessions.filter((s) => s.kind !== "shell");
|
|
9530
|
+
const skippedCount = allLiveSessions.length - displayed.length;
|
|
9531
|
+
if (displayed.length === 0) return;
|
|
9532
|
+
console.log("\n" + header("Live Sessions") + "\n");
|
|
9533
|
+
const liveHeaders = [
|
|
9534
|
+
"#",
|
|
9535
|
+
"id",
|
|
9536
|
+
"name",
|
|
9537
|
+
"at prompt",
|
|
9538
|
+
"kind"
|
|
9539
|
+
];
|
|
9540
|
+
const liveRows = displayed.map((s, idx) => {
|
|
9541
|
+
const shortId = s.sessionId.slice(0, 8);
|
|
9542
|
+
const rawName = s.paiName ?? s.name;
|
|
9543
|
+
const name = rawName.length > 36 ? rawName.slice(0, 35) + "…" : rawName;
|
|
9544
|
+
const atPrompt = s.atPrompt ? chalk.green("yes") : chalk.yellow("busy");
|
|
9545
|
+
const kind = s.kind === "claude" ? chalk.cyan(s.kind) : chalk.dim(s.kind);
|
|
9546
|
+
return [
|
|
9547
|
+
chalk.dim(String(idx + 1)),
|
|
9548
|
+
chalk.cyan(shortId),
|
|
9549
|
+
name,
|
|
9550
|
+
atPrompt,
|
|
9551
|
+
kind
|
|
9552
|
+
];
|
|
9553
|
+
});
|
|
9554
|
+
console.log(renderTable(liveHeaders, liveRows));
|
|
9555
|
+
if (skippedCount > 0) console.log(dim$1(` (${skippedCount} shell tab${skippedCount === 1 ? "" : "s"} hidden — use --all-tabs to show)`));
|
|
9556
|
+
}
|
|
9557
|
+
async function cmdRecent(db, opts) {
|
|
9558
|
+
const limit = parseInt(opts.n ?? "20", 10);
|
|
9559
|
+
const includeAll = opts.all === true;
|
|
9560
|
+
const allTabs = opts.allTabs === true;
|
|
9561
|
+
const liveSessions = await fetchLiveSessions();
|
|
9562
|
+
const sessions = scanSessions(db, {
|
|
9563
|
+
limit,
|
|
9564
|
+
filter: includeAll ? "all" : "named"
|
|
9565
|
+
});
|
|
9566
|
+
if (opts.json) {
|
|
9567
|
+
const output = {
|
|
9568
|
+
live: liveSessions.map((s) => ({
|
|
9569
|
+
index: s.index,
|
|
9570
|
+
sessionId: s.sessionId,
|
|
9571
|
+
name: s.name,
|
|
9572
|
+
paiName: s.paiName,
|
|
9573
|
+
atPrompt: s.atPrompt,
|
|
9574
|
+
kind: s.kind,
|
|
9575
|
+
active: s.active
|
|
9576
|
+
})),
|
|
9577
|
+
paused: sessions.map((s, idx) => ({
|
|
9578
|
+
idx: idx + 1,
|
|
9579
|
+
uuid: s.uuid,
|
|
9580
|
+
shortId: s.shortId,
|
|
9581
|
+
resumable: s.resumable,
|
|
9582
|
+
sessionStatus: s.sessionStatus,
|
|
9583
|
+
age: fmtAge(s.mtime),
|
|
9584
|
+
mtime: s.mtime,
|
|
9585
|
+
name: s.friendlyName,
|
|
9586
|
+
lastUserPrompt: s.lastUserPrompt,
|
|
9587
|
+
userLines: s.userLines,
|
|
9588
|
+
msgCount: s.msgCount,
|
|
9589
|
+
topLevelSize: s.topLevelSize,
|
|
9590
|
+
decodedPath: s.decodedPath
|
|
9591
|
+
}))
|
|
9592
|
+
};
|
|
9593
|
+
console.log(JSON.stringify(output, null, 2));
|
|
9594
|
+
return;
|
|
9595
|
+
}
|
|
9596
|
+
const hasClaudeLive = liveSessions.some((s) => s.kind === "claude");
|
|
9597
|
+
if (liveSessions.length > 0) renderLiveSessions(liveSessions, allTabs);
|
|
9598
|
+
if (sessions.length === 0) {
|
|
9599
|
+
if (!hasClaudeLive) if (includeAll) console.log(err("No sessions found in ~/.claude/projects/."));
|
|
9600
|
+
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."));
|
|
9601
|
+
return;
|
|
9602
|
+
}
|
|
9603
|
+
const title = includeAll ? "Paused / All Sessions (named + orphans)" : "Paused / Resumable Sessions";
|
|
9604
|
+
console.log("\n" + header(title) + "\n");
|
|
9605
|
+
const headers = [
|
|
9606
|
+
"#",
|
|
9607
|
+
"id",
|
|
9608
|
+
"age",
|
|
9609
|
+
"name / project",
|
|
9610
|
+
"last prompt",
|
|
9611
|
+
"msgs",
|
|
9612
|
+
"size",
|
|
9613
|
+
"status"
|
|
9614
|
+
];
|
|
9615
|
+
const rows = sessions.map((s, idx) => {
|
|
9616
|
+
const name = s.friendlyName ?? s.decodedPath;
|
|
9617
|
+
const prompt = s.lastUserPrompt ? chalk.dim(s.lastUserPrompt.length > 40 ? s.lastUserPrompt.slice(0, 39) + "…" : s.lastUserPrompt) : dim$1("—");
|
|
9618
|
+
return [
|
|
9619
|
+
chalk.dim(String(idx + 1)),
|
|
9620
|
+
chalk.cyan(s.shortId),
|
|
9621
|
+
dim$1(fmtAge(s.mtime)),
|
|
9622
|
+
name.length > 26 ? name.slice(0, 25) + "…" : name,
|
|
9623
|
+
prompt,
|
|
9624
|
+
s.userLines > 0 ? String(s.userLines) : dim$1("—"),
|
|
9625
|
+
dim$1(fmtSize(s.topLevelSize)),
|
|
9626
|
+
fmtStatus(s.sessionStatus)
|
|
9627
|
+
];
|
|
9628
|
+
});
|
|
9629
|
+
console.log(renderTable(headers, rows));
|
|
9630
|
+
console.log("\n" + dim$1("Go to a session: ") + chalk.white("pai resume <name>") + "\n" + (hasClaudeLive ? dim$1("Pause all live: ") + chalk.white("pai pause all") + "\n" : "") + (includeAll ? "" : dim$1("Show all sessions: ") + chalk.white("pai sessions --all") + "\n"));
|
|
9631
|
+
}
|
|
9632
|
+
|
|
9633
|
+
//#endregion
|
|
9634
|
+
//#region src/cli/commands/session/goto.ts
|
|
9635
|
+
/**
|
|
9636
|
+
* Run claude --resume <uuid> --print --output-format=json "_" in the given cwd.
|
|
9637
|
+
* Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
|
|
9638
|
+
* in stderr). Timeout: 5 seconds.
|
|
9639
|
+
*/
|
|
9640
|
+
function probeResume$1(uuid, cwd) {
|
|
9641
|
+
const result = spawnSync("claude", [
|
|
9642
|
+
"--resume",
|
|
9643
|
+
uuid,
|
|
9644
|
+
"--print",
|
|
9645
|
+
"--output-format=json",
|
|
9646
|
+
"_"
|
|
9647
|
+
], {
|
|
9648
|
+
cwd,
|
|
9649
|
+
timeout: 5e3,
|
|
9650
|
+
env: process.env,
|
|
9651
|
+
stdio: [
|
|
9652
|
+
"ignore",
|
|
9653
|
+
"ignore",
|
|
9654
|
+
"pipe"
|
|
9655
|
+
]
|
|
9656
|
+
});
|
|
9657
|
+
if (result.error) return {
|
|
9658
|
+
ok: false,
|
|
9659
|
+
reason: `spawn error: ${result.error.message}`
|
|
9660
|
+
};
|
|
9661
|
+
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
9662
|
+
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
9663
|
+
ok: false,
|
|
9664
|
+
reason: "No conversation found for this UUID"
|
|
9665
|
+
};
|
|
9666
|
+
if (result.status !== 0) return {
|
|
9667
|
+
ok: false,
|
|
9668
|
+
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
9669
|
+
};
|
|
9670
|
+
return { ok: true };
|
|
9671
|
+
}
|
|
9672
|
+
function cmdGoto(db, query, opts) {
|
|
9673
|
+
const allSessions = scanSessions(db, {
|
|
9674
|
+
limit: 500,
|
|
9675
|
+
filter: "named"
|
|
9676
|
+
});
|
|
9677
|
+
let resolved;
|
|
9678
|
+
try {
|
|
9679
|
+
resolved = resolveSessionByNameOrId(allSessions, query);
|
|
9680
|
+
} catch (resolveErr) {
|
|
9681
|
+
let msg = String(resolveErr).replace(/^Error: /, "");
|
|
9682
|
+
if (msg.includes("No session found matching")) msg += `\n\nTip: pai find "${query}" — search prompt history by keywords`;
|
|
9683
|
+
console.error(err(msg));
|
|
9684
|
+
process.exit(1);
|
|
9685
|
+
}
|
|
9686
|
+
const { session: matchedSession, friendlyName } = resolved;
|
|
9687
|
+
let resumableUuid;
|
|
9688
|
+
let resumableSession;
|
|
9689
|
+
if (matchedSession.resumable) {
|
|
9690
|
+
resumableUuid = matchedSession.uuid;
|
|
9691
|
+
resumableSession = matchedSession;
|
|
9692
|
+
} else if (matchedSession.encodedDir) {
|
|
9693
|
+
const sameProject = allSessions.filter((s) => s.encodedDir === matchedSession.encodedDir && s.resumable);
|
|
9694
|
+
sameProject.sort((a, b) => b.mtime - a.mtime);
|
|
9695
|
+
if (sameProject.length > 0) {
|
|
9696
|
+
resumableSession = sameProject[0];
|
|
9697
|
+
resumableUuid = resumableSession.uuid;
|
|
9698
|
+
}
|
|
9699
|
+
}
|
|
9700
|
+
const rawDir = matchedSession.clcDirectory ?? matchedSession.registryRootPath ?? matchedSession.decodedPath;
|
|
9701
|
+
let projectDir;
|
|
9702
|
+
try {
|
|
9703
|
+
projectDir = realpathSync(rawDir);
|
|
9704
|
+
} catch {
|
|
9705
|
+
console.error(err(`session "${query}": directory does not exist or cannot be resolved.\n Registry says: ${rawDir}\n The directory may have moved or been deleted.`));
|
|
9706
|
+
process.exit(1);
|
|
9707
|
+
}
|
|
9708
|
+
const name = friendlyName ?? query;
|
|
9709
|
+
const promptArg = `/Name ${name}\ngo`;
|
|
9710
|
+
if (opts.dryRun) {
|
|
9711
|
+
if (resumableUuid) {
|
|
9712
|
+
const argvResume = `claude --resume ${resumableUuid} --name "${name}" "/Name ${name}\\ngo"`;
|
|
9713
|
+
const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
|
|
9714
|
+
console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
|
|
9715
|
+
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
9716
|
+
console.log(` probe: claude --resume ${resumableUuid} --print --output-format=json "_"`);
|
|
9717
|
+
console.log(` argv: ${chalk.white(argvResume)}`);
|
|
9718
|
+
console.log(` fallback: ${chalk.yellow(argvFresh)}`);
|
|
9719
|
+
if (resumableSession) {
|
|
9720
|
+
console.log(`\n uuid: ${resumableSession.uuid}`);
|
|
9721
|
+
console.log(` age: ${fmtAge(resumableSession.mtime)}`);
|
|
9722
|
+
console.log(` status: ${resumableSession.sessionStatus}`);
|
|
9723
|
+
console.log(` sys: ${resumableSession.topLevelSystemLines} system lines`);
|
|
9724
|
+
}
|
|
9725
|
+
} else {
|
|
9726
|
+
const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
|
|
9727
|
+
console.log("\n" + chalk.bold("Dry run — would exec (FRESH path, no resumable UUID):") + "\n");
|
|
9728
|
+
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
9729
|
+
console.log(` argv: ${chalk.white(argvFresh)}`);
|
|
9730
|
+
}
|
|
9731
|
+
console.log();
|
|
9732
|
+
return;
|
|
9733
|
+
}
|
|
9734
|
+
if (resumableUuid) {
|
|
9735
|
+
const probe = probeResume$1(resumableUuid, projectDir);
|
|
9736
|
+
if (probe.ok) {
|
|
9737
|
+
const result = spawnSync("claude", [
|
|
9738
|
+
"--resume",
|
|
9739
|
+
resumableUuid,
|
|
9740
|
+
"--name",
|
|
9741
|
+
name,
|
|
9742
|
+
promptArg
|
|
9743
|
+
], {
|
|
9744
|
+
cwd: projectDir,
|
|
9745
|
+
stdio: "inherit",
|
|
9746
|
+
env: process.env
|
|
9747
|
+
});
|
|
9748
|
+
if (result.error) {
|
|
9749
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
9750
|
+
process.exit(1);
|
|
9751
|
+
}
|
|
9752
|
+
printExitDir(projectDir);
|
|
9753
|
+
process.exit(result.status ?? 0);
|
|
9754
|
+
} else {
|
|
9755
|
+
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`));
|
|
9756
|
+
const result = spawnSync("claude", [
|
|
9757
|
+
"--name",
|
|
9758
|
+
name,
|
|
9759
|
+
promptArg
|
|
9760
|
+
], {
|
|
9761
|
+
cwd: projectDir,
|
|
9762
|
+
stdio: "inherit",
|
|
9763
|
+
env: process.env
|
|
9764
|
+
});
|
|
9765
|
+
if (result.error) {
|
|
9766
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
9767
|
+
process.exit(1);
|
|
9768
|
+
}
|
|
9769
|
+
printExitDir(projectDir);
|
|
9770
|
+
process.exit(result.status ?? 0);
|
|
9771
|
+
}
|
|
9772
|
+
} else {
|
|
9773
|
+
const result = spawnSync("claude", [
|
|
9774
|
+
"--name",
|
|
9775
|
+
name,
|
|
9776
|
+
promptArg
|
|
9777
|
+
], {
|
|
9778
|
+
cwd: projectDir,
|
|
9779
|
+
stdio: "inherit",
|
|
9780
|
+
env: process.env
|
|
9781
|
+
});
|
|
9782
|
+
if (result.error) {
|
|
9783
|
+
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
9784
|
+
process.exit(1);
|
|
9785
|
+
}
|
|
9786
|
+
printExitDir(projectDir);
|
|
9787
|
+
process.exit(result.status ?? 0);
|
|
9788
|
+
}
|
|
9789
|
+
}
|
|
9790
|
+
|
|
9791
|
+
//#endregion
|
|
9792
|
+
//#region src/cli/commands/session/index.ts
|
|
9793
|
+
function registerSessionCommands(sessionCmd, getDb) {
|
|
9794
|
+
sessionCmd.command("list").description("Resumable sessions catalog — named sessions with resume status.\nShort form: pai sessions\nDefault shows all sessions you've named or that are resumable.\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) => {
|
|
9795
|
+
await cmdRecent(getDb(), opts);
|
|
9796
|
+
});
|
|
9797
|
+
sessionCmd.command("info <project-slug> <number>").description("Show full details for a specific session").action((projectSlug, number) => {
|
|
9798
|
+
cmdInfo(getDb(), projectSlug, number);
|
|
9799
|
+
});
|
|
9800
|
+
sessionCmd.command("rename <project-slug> <number> <new-slug>").description("Rename a session note — updates file on disk, H1 title, and registry").action((projectSlug, number, newSlug) => {
|
|
9801
|
+
cmdRename(getDb(), projectSlug, number, newSlug);
|
|
9802
|
+
});
|
|
9803
|
+
sessionCmd.command("slug <project-slug> <number>").description("Generate a descriptive slug from the session JSONL transcript").option("--apply", "Rename the session note using the generated slug").action((projectSlug, number, opts) => {
|
|
9804
|
+
cmdSlug(getDb(), projectSlug, number, opts);
|
|
9805
|
+
});
|
|
9806
|
+
sessionCmd.command("tag <project-slug> <number> [tags...]").description("Set or show tags on a session. Tags can be space-separated or comma-separated.").action((projectSlug, number, tags) => {
|
|
9807
|
+
cmdTag(getDb(), projectSlug, number, tags);
|
|
9808
|
+
});
|
|
9809
|
+
sessionCmd.command("route <project-slug> <number> <target-project>").description("Create a cross-reference link from a session to a target project").option("--type <type>", "Link type: related | follow-up | reference", "related").action((projectSlug, number, targetProject, opts) => {
|
|
9810
|
+
cmdRoute(getDb(), projectSlug, number, targetProject, opts);
|
|
9811
|
+
});
|
|
9812
|
+
sessionCmd.command("handover [project-slug] [session-id]").description("Write a ## Continue section to the project's TODO.md.\nCalled automatically from session-stop and pre-compact hooks.\nRecords the last session identifier, timestamp, and working directory\nso the next session can resume from the correct context.").action((projectSlug, sessionId) => {
|
|
9813
|
+
cmdHandover(getDb(), projectSlug, sessionId);
|
|
9814
|
+
});
|
|
9815
|
+
sessionCmd.command("checkpoint <message>").description("Append a timestamped checkpoint to the active session note.\nDesigned for hooks (PostToolUse, UserPromptSubmit) — fast and silent.\nRate-limited: skips silently if last checkpoint was < --min-gap seconds ago.").option("--min-gap <seconds>", "Minimum seconds between checkpoints (default: 300 = 5 minutes)", "300").action((message, opts) => {
|
|
9816
|
+
cmdCheckpoint(message, opts);
|
|
9817
|
+
});
|
|
9818
|
+
sessionCmd.command("active").description("Show currently active Claude Code sessions.\nDetects live sessions by checking which JSONL transcript files\nwere recently modified in ~/.claude/projects/.").option("--minutes <n>", "Consider sessions active if modified within N minutes (default: 60)", "60").option("--json", "Output raw JSON instead of formatted display").action((opts) => {
|
|
9819
|
+
cmdActive(getDb(), opts);
|
|
9820
|
+
});
|
|
9821
|
+
sessionCmd.command("recent").description("[Deprecated] Use `pai session list` or `pai sessions` instead.").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) => {
|
|
9822
|
+
process.stderr.write("Deprecated: `pai session recent` — use `pai session list` or `pai sessions` instead.\n");
|
|
9823
|
+
await cmdRecent(getDb(), opts);
|
|
9824
|
+
});
|
|
9825
|
+
sessionCmd.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.\nSends '/Name <name>\\ngo' as the initial prompt so the PAI ## Continue hook fires.").option("--skip-name", "Do not prepend /Name to set the session name in chrome").option("--skip-go", "Do not append \\ngo to trigger PAI auto-resume (## Continue)").option("--dry-run", "Print the exact argv and cwd, then exit without launching").action((nameOrId, opts) => {
|
|
9826
|
+
process.stderr.write("Note: `pai session goto` works but `pai resume` is the new shorter form.\n");
|
|
9827
|
+
cmdGoto(getDb(), nameOrId, {
|
|
9828
|
+
noName: opts.skipName,
|
|
9829
|
+
noGo: opts.skipGo,
|
|
9830
|
+
dryRun: opts.dryRun
|
|
9831
|
+
});
|
|
9832
|
+
});
|
|
9833
|
+
sessionCmd.command("pause").description("Write a ## Continue checkpoint to the project's TODO.md.\nRecommended short form: pai pause\nCtrl+C bypasses the stop-hook, orphaning the session (cannot --resume).\nUse /exit inside Claude Code to preserve full session resumability.").option("--dry-run", "Preview the ## Continue block without writing it").option("--body-file <path>", "File holding the checkpoint markdown to persist ('-' reads stdin). Required unless --no-body.").option("--session-id <uuid>", "Claude Code session UUID — recorded as the `claude --resume` handle").option("--no-body", "Deliberately write a metadata-only checkpoint (no content)").action((opts) => {
|
|
9834
|
+
process.stderr.write("Note: `pai session pause` works but `pai pause` is the new shorter form.\n");
|
|
9835
|
+
cmdPause(getDb(), {
|
|
9836
|
+
dryRun: opts.dryRun,
|
|
9837
|
+
bodyFile: opts.bodyFile,
|
|
9838
|
+
sessionId: opts.sessionId,
|
|
9839
|
+
noBody: opts.body === false
|
|
9840
|
+
});
|
|
9841
|
+
});
|
|
9842
|
+
sessionCmd.command("auto-route").description("Auto-detect which project this session belongs to.\nTries: (1) path match in registry, (2) Notes/PAI.md marker walk, (3) topic detection.\nDesigned for use in CLAUDE.md session-start hooks.").option("--cwd <path>", "Working directory to detect from (default: process.cwd())").option("--context <text>", "Conversation context for topic-based fallback routing").option("--json", "Output raw JSON instead of formatted display").action(async (opts) => {
|
|
9843
|
+
await cmdAutoRoute(opts);
|
|
9844
|
+
});
|
|
9845
|
+
}
|
|
9846
|
+
|
|
9847
|
+
//#endregion
|
|
9848
|
+
//#region src/cli/commands/session-cleanup/types.ts
|
|
9849
|
+
const TEMPLATE_INDICATORS = [
|
|
9850
|
+
"<!-- PAI will add completed work here during session -->",
|
|
9851
|
+
"<!-- PAI will add completed work here -->",
|
|
9852
|
+
"Session completed.",
|
|
9853
|
+
"Session started and ready for your instructions"
|
|
9854
|
+
];
|
|
9855
|
+
const MODERN_PATTERN = /^(\d{4}) - (\d{4}-\d{2}-\d{2}) - (.+)\.md$/;
|
|
9856
|
+
const LEGACY_PATTERN = /^(\d{4})_(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
9857
|
+
|
|
9858
|
+
//#endregion
|
|
9859
|
+
//#region src/cli/commands/session-cleanup/rename.ts
|
|
9860
|
+
/**
|
|
9861
|
+
* Auto-name extraction and string helpers for session-cleanup.
|
|
9862
|
+
* Derives a meaningful session title from Markdown content.
|
|
9863
|
+
*/
|
|
9864
|
+
const META_PHRASE_PATTERNS = [
|
|
9865
|
+
/session initialized and ready for your instructions/i,
|
|
9866
|
+
/fresh session with no pending tasks/i,
|
|
9867
|
+
/starting new session.*checking for pending work/i,
|
|
9868
|
+
/fresh session with empty todo/i,
|
|
9869
|
+
/session started and ready/i,
|
|
9870
|
+
/^session\b.*\bready\b/i,
|
|
9871
|
+
/^session\b.*\binitialized\b/i,
|
|
9872
|
+
/^session\b.*\bno pending\b/i,
|
|
9873
|
+
/^session\b.*\bno prior work\b/i,
|
|
9874
|
+
/^no pending tasks/i,
|
|
9875
|
+
/^no prior work/i
|
|
9876
|
+
];
|
|
9877
|
+
const TITLE_CASE_MINOR_WORDS = new Set([
|
|
9878
|
+
"a",
|
|
9879
|
+
"an",
|
|
9880
|
+
"the",
|
|
9881
|
+
"in",
|
|
9882
|
+
"on",
|
|
9883
|
+
"at",
|
|
9884
|
+
"for",
|
|
9885
|
+
"to",
|
|
9886
|
+
"of",
|
|
9887
|
+
"and",
|
|
9888
|
+
"or",
|
|
9889
|
+
"but",
|
|
9890
|
+
"via",
|
|
9891
|
+
"with",
|
|
9892
|
+
"from",
|
|
9893
|
+
"by",
|
|
9894
|
+
"as",
|
|
9895
|
+
"nor"
|
|
9896
|
+
]);
|
|
9897
|
+
function cleanMarkdownLine(raw) {
|
|
9898
|
+
let s = raw.trim();
|
|
9899
|
+
s = s.replace(/^[-*+]\s+\[[ xX]\]\s*/, "");
|
|
9900
|
+
s = s.replace(/^[-*+]\s+/, "");
|
|
9901
|
+
s = s.replace(/^\[[ xX]\]\s*/, "");
|
|
9902
|
+
s = s.replace(/\*\*\s*([^*]+?)\s*\*\*/g, "$1");
|
|
9903
|
+
s = s.replace(/\*\s*([^*]+?)\s*\*/g, "$1");
|
|
9904
|
+
s = s.replace(/`([^`]+)`/g, "$1");
|
|
9905
|
+
s = s.replace(/^\*+\s*/, "");
|
|
9906
|
+
s = s.replace(/^[.,;:]+/, "").replace(/[.,;:]+$/, "");
|
|
9907
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
9908
|
+
return s.length >= 4 ? s : null;
|
|
9909
|
+
}
|
|
9910
|
+
function isMetaPhrase(text) {
|
|
9911
|
+
return META_PHRASE_PATTERNS.some((re) => re.test(text));
|
|
9912
|
+
}
|
|
9913
|
+
function toTitleCase(text) {
|
|
9914
|
+
return text.split(" ").map((word, i) => {
|
|
9915
|
+
const lower = word.toLowerCase();
|
|
9916
|
+
if (i !== 0 && TITLE_CASE_MINOR_WORDS.has(lower)) return lower;
|
|
9917
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
9918
|
+
}).join(" ");
|
|
9919
|
+
}
|
|
9920
|
+
function sanitizeName(raw) {
|
|
9921
|
+
let s = raw.replace(/[\/\\:*?"<>|#`]/g, "");
|
|
9922
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
9923
|
+
if (s.length > 60) {
|
|
9924
|
+
const truncated = s.slice(0, 60);
|
|
9925
|
+
const lastSpace = truncated.lastIndexOf(" ");
|
|
9926
|
+
s = lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated;
|
|
9927
|
+
}
|
|
9928
|
+
s = s.trim();
|
|
9929
|
+
return toTitleCase(s);
|
|
9930
|
+
}
|
|
9931
|
+
function extractAutoName(content) {
|
|
9932
|
+
const lines = content.split("\n");
|
|
9933
|
+
const CONTENT_SECTION_HEADINGS = new Set([
|
|
9934
|
+
"Work Done",
|
|
9935
|
+
"Summary",
|
|
9936
|
+
"Completed",
|
|
9937
|
+
"What Was Done",
|
|
9938
|
+
"Results",
|
|
9939
|
+
"Outcomes",
|
|
9940
|
+
"Changes",
|
|
9941
|
+
"Progress"
|
|
9942
|
+
]);
|
|
9943
|
+
const SKIP_SECTION_HEADINGS = new Set([
|
|
9944
|
+
"Next Steps",
|
|
9945
|
+
"Tags",
|
|
9946
|
+
"TODO",
|
|
9947
|
+
"Blockers",
|
|
9948
|
+
"Notes",
|
|
9949
|
+
"Metadata",
|
|
9950
|
+
"Context",
|
|
9951
|
+
"Background"
|
|
9952
|
+
]);
|
|
9953
|
+
let pastH1 = false;
|
|
9954
|
+
let pastFirstHr = false;
|
|
9955
|
+
let currentSection = null;
|
|
9956
|
+
const contentSectionLines = [];
|
|
9957
|
+
const otherH2Headings = [];
|
|
9958
|
+
for (const line of lines) {
|
|
9959
|
+
const trimmed = line.trim();
|
|
9960
|
+
if (trimmed.startsWith("# ")) {
|
|
9961
|
+
pastH1 = true;
|
|
9962
|
+
continue;
|
|
9963
|
+
}
|
|
9964
|
+
if (!pastH1) continue;
|
|
9965
|
+
if (!pastFirstHr) {
|
|
9966
|
+
if (trimmed === "---") pastFirstHr = true;
|
|
9967
|
+
continue;
|
|
9968
|
+
}
|
|
9969
|
+
if (trimmed.startsWith("## ")) {
|
|
9970
|
+
const headingText = trimmed.slice(3).trim();
|
|
9971
|
+
if (CONTENT_SECTION_HEADINGS.has(headingText)) currentSection = "content";
|
|
9972
|
+
else if (SKIP_SECTION_HEADINGS.has(headingText)) currentSection = "skip";
|
|
9973
|
+
else {
|
|
9974
|
+
currentSection = null;
|
|
9975
|
+
otherH2Headings.push(headingText);
|
|
9976
|
+
}
|
|
9977
|
+
continue;
|
|
9978
|
+
}
|
|
9979
|
+
if (trimmed.startsWith("#")) continue;
|
|
9980
|
+
if (trimmed === "-->") continue;
|
|
9981
|
+
if (trimmed.startsWith("<!--") && /^<!--.*-->$/.test(trimmed)) continue;
|
|
9982
|
+
if (currentSection === "content" && trimmed.length > 0) {
|
|
9983
|
+
const withoutComment = trimmed.replace(/^<!--.*?-->\s*/, "");
|
|
9984
|
+
const effective = withoutComment.length > 0 ? withoutComment : trimmed;
|
|
9985
|
+
if (effective.length === 0) continue;
|
|
9986
|
+
contentSectionLines.push(effective);
|
|
9987
|
+
}
|
|
9988
|
+
}
|
|
9989
|
+
for (const raw of contentSectionLines) {
|
|
9990
|
+
const cleaned = cleanMarkdownLine(raw);
|
|
9991
|
+
if (!cleaned) continue;
|
|
9992
|
+
if (isMetaPhrase(cleaned)) continue;
|
|
9993
|
+
if (cleaned.startsWith("<!--") || cleaned.includes("PAI will add")) continue;
|
|
9994
|
+
if (cleaned.length < 5) continue;
|
|
9995
|
+
return sanitizeName(cleaned);
|
|
9996
|
+
}
|
|
9997
|
+
for (const heading of otherH2Headings) {
|
|
9998
|
+
const cleaned = cleanMarkdownLine(heading);
|
|
9999
|
+
if (!cleaned) continue;
|
|
10000
|
+
if (isMetaPhrase(cleaned)) continue;
|
|
10001
|
+
if (cleaned.length > 3 && cleaned.length < 80) return sanitizeName(cleaned);
|
|
10002
|
+
}
|
|
10003
|
+
for (const line of lines) {
|
|
10004
|
+
const trimmed = line.trim();
|
|
10005
|
+
if (trimmed.startsWith("# ")) {
|
|
10006
|
+
const title = trimmed.slice(2).trim();
|
|
10007
|
+
if (/^Session \d{4}/i.test(title)) continue;
|
|
10008
|
+
const cleaned = cleanMarkdownLine(title);
|
|
10009
|
+
if (cleaned && !isMetaPhrase(cleaned) && cleaned.length > 3) return sanitizeName(cleaned);
|
|
10010
|
+
}
|
|
10011
|
+
}
|
|
10012
|
+
return "Unnamed Session";
|
|
10013
|
+
}
|
|
10014
|
+
/** Format a 4-digit padded session number. */
|
|
10015
|
+
function padNum(n) {
|
|
10016
|
+
return String(n).padStart(4, "0");
|
|
10017
|
+
}
|
|
10018
|
+
|
|
10019
|
+
//#endregion
|
|
10020
|
+
//#region src/cli/commands/session-cleanup/scanner.ts
|
|
10021
|
+
function getAllProjects(db) {
|
|
10022
|
+
return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE status = 'active' ORDER BY slug").all();
|
|
10023
|
+
}
|
|
10024
|
+
function getProject(db, slug) {
|
|
10025
|
+
return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir, claude_notes_dir FROM projects WHERE slug = ?").get(slug);
|
|
10026
|
+
}
|
|
10027
|
+
function getProjectSessions(db, projectId) {
|
|
10028
|
+
return db.prepare("SELECT * FROM sessions WHERE project_id = ? ORDER BY number ASC").all(projectId);
|
|
10029
|
+
}
|
|
10030
|
+
function findRootNotesDir(rootPath) {
|
|
10031
|
+
const canonical = join(rootPath, "Notes");
|
|
10032
|
+
if (existsSync(canonical)) return canonical;
|
|
10033
|
+
const alt = join(rootPath, ".claude", "Notes");
|
|
10034
|
+
if (existsSync(alt)) return alt;
|
|
10035
|
+
return null;
|
|
10036
|
+
}
|
|
10037
|
+
function findClaudeNotesDir(project, rootNotesDir) {
|
|
10038
|
+
const candidate = project.claude_notes_dir ?? join(homedir(), ".claude", "projects", project.encoded_dir, "Notes");
|
|
10039
|
+
if (!existsSync(candidate)) return null;
|
|
10040
|
+
if (rootNotesDir && candidate === rootNotesDir) return null;
|
|
10041
|
+
return candidate;
|
|
10042
|
+
}
|
|
10043
|
+
function findAllNotesDirs(project) {
|
|
10044
|
+
const rootDir = findRootNotesDir(project.root_path);
|
|
10045
|
+
const claudeDir = findClaudeNotesDir(project, rootDir);
|
|
10046
|
+
const dirs = [];
|
|
10047
|
+
if (rootDir) dirs.push(rootDir);
|
|
10048
|
+
if (claudeDir) dirs.push(claudeDir);
|
|
10049
|
+
return dirs;
|
|
10050
|
+
}
|
|
10051
|
+
function isTemplateOnly(content) {
|
|
10052
|
+
if (!TEMPLATE_INDICATORS.some((ind) => content.includes(ind))) return false;
|
|
10053
|
+
const lines = content.split("\n");
|
|
10054
|
+
let inWorkDone = false;
|
|
10055
|
+
for (const line of lines) {
|
|
10056
|
+
const trimmed = line.trim();
|
|
10057
|
+
if (trimmed === "## Work Done") {
|
|
10058
|
+
inWorkDone = true;
|
|
10059
|
+
continue;
|
|
10060
|
+
}
|
|
10061
|
+
if (trimmed.startsWith("## ") && inWorkDone) break;
|
|
10062
|
+
if (!inWorkDone) continue;
|
|
10063
|
+
if (!trimmed) continue;
|
|
10064
|
+
if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) continue;
|
|
10065
|
+
if (trimmed.startsWith("<!--")) continue;
|
|
10066
|
+
if (trimmed === "-->") continue;
|
|
10067
|
+
if (trimmed === "Session completed.") continue;
|
|
10068
|
+
if (trimmed === "#Session" || trimmed === "**Tags:** #Session") continue;
|
|
10069
|
+
return false;
|
|
10070
|
+
}
|
|
10071
|
+
return true;
|
|
10072
|
+
}
|
|
10073
|
+
function scanNotesDir(notesDir, dbByFilename) {
|
|
10074
|
+
const candidates = [];
|
|
10075
|
+
let flatFiles = [];
|
|
10076
|
+
try {
|
|
10077
|
+
flatFiles = readdirSync(notesDir, { withFileTypes: true }).filter((d) => d.isFile() && d.name.endsWith(".md")).map((d) => d.name);
|
|
10078
|
+
} catch {}
|
|
10079
|
+
const subDirFiles = [];
|
|
10080
|
+
try {
|
|
10081
|
+
const topEntries = readdirSync(notesDir, { withFileTypes: true });
|
|
10082
|
+
for (const entry of topEntries) {
|
|
10083
|
+
if (!entry.isDirectory()) continue;
|
|
10084
|
+
if (!/^\d{4}$/.test(entry.name)) continue;
|
|
10085
|
+
const yearDir = join(notesDir, entry.name);
|
|
10086
|
+
const monthDirs = readdirSync(yearDir, { withFileTypes: true });
|
|
10087
|
+
for (const mEntry of monthDirs) {
|
|
10088
|
+
if (!mEntry.isDirectory()) continue;
|
|
10089
|
+
const monthDir = join(yearDir, mEntry.name);
|
|
10090
|
+
const files = readdirSync(monthDir).filter((f) => f.endsWith(".md"));
|
|
10091
|
+
for (const f of files) subDirFiles.push({
|
|
10092
|
+
filename: f,
|
|
10093
|
+
filepath: join(monthDir, f)
|
|
10094
|
+
});
|
|
10095
|
+
}
|
|
10096
|
+
}
|
|
10097
|
+
} catch {}
|
|
10098
|
+
const allFiles = [...flatFiles.map((f) => ({
|
|
10099
|
+
filename: f,
|
|
10100
|
+
filepath: join(notesDir, f)
|
|
10101
|
+
})), ...subDirFiles];
|
|
10102
|
+
for (const { filename, filepath } of allFiles) {
|
|
10103
|
+
let num;
|
|
10104
|
+
let date;
|
|
10105
|
+
let namepart;
|
|
10106
|
+
let classification;
|
|
10107
|
+
const modernMatch = MODERN_PATTERN.exec(filename);
|
|
10108
|
+
const legacyMatch = LEGACY_PATTERN.exec(filename);
|
|
10109
|
+
if (modernMatch) {
|
|
10110
|
+
num = parseInt(modernMatch[1], 10);
|
|
10111
|
+
date = modernMatch[2];
|
|
10112
|
+
namepart = modernMatch[3];
|
|
10113
|
+
classification = "NAMED";
|
|
10114
|
+
} else if (legacyMatch) {
|
|
10115
|
+
num = parseInt(legacyMatch[1], 10);
|
|
10116
|
+
date = legacyMatch[2];
|
|
10117
|
+
namepart = legacyMatch[3];
|
|
10118
|
+
classification = "LEGACY_FORMAT";
|
|
10119
|
+
} else continue;
|
|
10120
|
+
let sizeBytes = 0;
|
|
10121
|
+
let content = "";
|
|
10122
|
+
try {
|
|
10123
|
+
sizeBytes = statSync(filepath).size;
|
|
10124
|
+
content = readFileSync(filepath, "utf8");
|
|
10125
|
+
} catch {
|
|
10126
|
+
continue;
|
|
10127
|
+
}
|
|
10128
|
+
const dbSession = dbByFilename.get(filename) ?? dbByFilename.get(filepath.split(`${notesDir}/`)[1] ?? "") ?? null;
|
|
10129
|
+
if (classification !== "LEGACY_FORMAT") {
|
|
10130
|
+
if (sizeBytes < 400 || isTemplateOnly(content)) classification = "EMPTY";
|
|
10131
|
+
else if (namepart === "New Session" || namepart === (process.env.USER ?? "") || namepart === "session-started-and-ready-for-your-instructions") classification = "UNNAMED";
|
|
10132
|
+
}
|
|
10133
|
+
const candidate = {
|
|
10134
|
+
session: dbSession,
|
|
10135
|
+
filename,
|
|
10136
|
+
filepath,
|
|
10137
|
+
sizeBytes,
|
|
10138
|
+
classification,
|
|
10139
|
+
date,
|
|
10140
|
+
number: num
|
|
10141
|
+
};
|
|
10142
|
+
if (classification === "UNNAMED" || classification === "LEGACY_FORMAT") candidate.autoName = extractAutoName(content);
|
|
10143
|
+
candidates.push(candidate);
|
|
10144
|
+
}
|
|
10145
|
+
return candidates;
|
|
10146
|
+
}
|
|
10147
|
+
function buildRenumberMap(survivors) {
|
|
10148
|
+
const map = /* @__PURE__ */ new Map();
|
|
10149
|
+
[...survivors].sort((a, b) => a.number - b.number).forEach((s, idx) => {
|
|
10150
|
+
const newNum = idx + 1;
|
|
10151
|
+
if (s.number !== newNum) map.set(s.number, newNum);
|
|
10152
|
+
});
|
|
10153
|
+
return map;
|
|
10154
|
+
}
|
|
10155
|
+
function analyzeProject(db, project) {
|
|
10156
|
+
const notesDirPaths = findAllNotesDirs(project);
|
|
10157
|
+
if (notesDirPaths.length === 0) return null;
|
|
10158
|
+
const dbSessions = getProjectSessions(db, project.id);
|
|
10159
|
+
const dbByFilename = /* @__PURE__ */ new Map();
|
|
10160
|
+
for (const s of dbSessions) dbByFilename.set(s.filename, s);
|
|
10161
|
+
const notesDirPlans = [];
|
|
10162
|
+
const allSurvivors = [];
|
|
10163
|
+
for (const notesDir of notesDirPaths) {
|
|
10164
|
+
const candidates = scanNotesDir(notesDir, dbByFilename);
|
|
10165
|
+
if (candidates.length === 0) continue;
|
|
10166
|
+
const toDelete = candidates.filter((c) => c.classification === "EMPTY");
|
|
10167
|
+
const toRename = candidates.filter((c) => c.classification === "UNNAMED" || c.classification === "LEGACY_FORMAT");
|
|
10168
|
+
const survivors = candidates.filter((c) => c.classification !== "EMPTY");
|
|
10169
|
+
notesDirPlans.push({
|
|
10170
|
+
notesDir,
|
|
10171
|
+
toDelete,
|
|
10172
|
+
toRename,
|
|
10173
|
+
toMove: survivors
|
|
10174
|
+
});
|
|
10175
|
+
allSurvivors.push(...survivors);
|
|
10176
|
+
}
|
|
10177
|
+
if (notesDirPlans.length === 0) return null;
|
|
10178
|
+
return {
|
|
10179
|
+
project,
|
|
10180
|
+
notesDirs: notesDirPlans,
|
|
10181
|
+
renumberMap: buildRenumberMap(allSurvivors)
|
|
10182
|
+
};
|
|
10183
|
+
}
|
|
10184
|
+
|
|
10185
|
+
//#endregion
|
|
10186
|
+
//#region src/cli/commands/session-cleanup/executor.ts
|
|
10187
|
+
async function countVectorDbPaths(oldPaths) {
|
|
10188
|
+
if (oldPaths.length === 0) return 0;
|
|
10189
|
+
try {
|
|
10190
|
+
const { loadConfig } = await import("./config-DGOfoGfm.mjs").then((n) => n.r);
|
|
10191
|
+
const { PostgresBackend } = await import("./postgres-zjKqPl4X.mjs");
|
|
10192
|
+
const config = loadConfig();
|
|
10193
|
+
if (config.storageBackend !== "postgres") return 0;
|
|
10194
|
+
const pgBackend = new PostgresBackend(config.postgres ?? {});
|
|
10195
|
+
if (await pgBackend.testConnection()) {
|
|
10196
|
+
await pgBackend.close();
|
|
10197
|
+
return 0;
|
|
10198
|
+
}
|
|
10199
|
+
const pool = pgBackend.pool;
|
|
10200
|
+
const placeholders = oldPaths.map((_, i) => `$${i + 1}`).join(", ");
|
|
10201
|
+
const result = await pool.query(`SELECT COUNT(*)::text AS n FROM pai_files WHERE path IN (${placeholders})`, oldPaths);
|
|
10202
|
+
await pgBackend.close();
|
|
10203
|
+
return parseInt(result.rows[0]?.n ?? "0", 10);
|
|
10204
|
+
} catch {
|
|
10205
|
+
return 0;
|
|
10206
|
+
}
|
|
10207
|
+
}
|
|
10208
|
+
async function updateVectorDbPaths(moves) {
|
|
10209
|
+
if (moves.length === 0) return 0;
|
|
10210
|
+
try {
|
|
10211
|
+
const { loadConfig } = await import("./config-DGOfoGfm.mjs").then((n) => n.r);
|
|
10212
|
+
const { PostgresBackend } = await import("./postgres-zjKqPl4X.mjs");
|
|
10213
|
+
const config = loadConfig();
|
|
10214
|
+
if (config.storageBackend !== "postgres") return 0;
|
|
10215
|
+
const pgBackend = new PostgresBackend(config.postgres ?? {});
|
|
10216
|
+
const connErr = await pgBackend.testConnection();
|
|
10217
|
+
if (connErr) {
|
|
10218
|
+
process.stderr.write(`[session-cleanup] Postgres unavailable (${connErr}). Skipping vector DB path update.\n`);
|
|
10219
|
+
await pgBackend.close();
|
|
10220
|
+
return 0;
|
|
10221
|
+
}
|
|
10222
|
+
const client = await pgBackend.pool.connect();
|
|
10223
|
+
let filesUpdated = 0;
|
|
10224
|
+
try {
|
|
10225
|
+
await client.query("BEGIN", []);
|
|
10226
|
+
for (const { oldPath, newPath } of moves) {
|
|
10227
|
+
const r = await client.query("UPDATE pai_files SET path = $1 WHERE path = $2", [newPath, oldPath]);
|
|
10228
|
+
filesUpdated += r.rowCount ?? 0;
|
|
10229
|
+
await client.query("UPDATE pai_chunks SET path = $1 WHERE path = $2", [newPath, oldPath]);
|
|
10230
|
+
}
|
|
10231
|
+
await client.query("COMMIT", []);
|
|
10232
|
+
} catch (e) {
|
|
10233
|
+
await client.query("ROLLBACK", []);
|
|
10234
|
+
throw e;
|
|
10235
|
+
} finally {
|
|
10236
|
+
client.release();
|
|
10237
|
+
}
|
|
10238
|
+
await pgBackend.close();
|
|
10239
|
+
return filesUpdated;
|
|
10240
|
+
} catch (e) {
|
|
10241
|
+
process.stderr.write(`[session-cleanup] Failed to update vector DB paths: ${e}\n`);
|
|
10242
|
+
return -1;
|
|
10243
|
+
}
|
|
10244
|
+
}
|
|
10245
|
+
async function displayDryRun(plans) {
|
|
10246
|
+
let totalDelete = 0;
|
|
10247
|
+
let totalRename = 0;
|
|
10248
|
+
let totalMove = 0;
|
|
10249
|
+
let totalRenumber = 0;
|
|
10250
|
+
for (const plan of plans) {
|
|
10251
|
+
if (!(plan.notesDirs.some((d) => d.toDelete.length > 0 || d.toRename.length > 0 || d.toMove.length > 0) || plan.renumberMap.size > 0)) continue;
|
|
10252
|
+
console.log();
|
|
10253
|
+
console.log(header(` Project: ${plan.project.display_name} (${plan.project.slug})`));
|
|
10254
|
+
for (const dirPlan of plan.notesDirs) {
|
|
10255
|
+
console.log(dim$1(` Notes: ${dirPlan.notesDir}`));
|
|
10256
|
+
if (dirPlan.toDelete.length > 0) {
|
|
10257
|
+
console.log(bold$1(" DELETE (empty/template-only sessions):"));
|
|
10258
|
+
for (const c of dirPlan.toDelete) {
|
|
10259
|
+
console.log(` ${chalk.red("DEL")} ${dim$1(padNum(c.number))} - ${c.date} - ${c.filename.split(" - ").slice(2).join(" - ")} ${dim$1(`(${c.sizeBytes}b)`)}`);
|
|
10260
|
+
totalDelete++;
|
|
10261
|
+
}
|
|
10262
|
+
console.log();
|
|
10263
|
+
}
|
|
10264
|
+
if (dirPlan.toRename.length > 0) {
|
|
10265
|
+
console.log(bold$1(" RENAME (unnamed or legacy-format sessions):"));
|
|
10266
|
+
for (const c of dirPlan.toRename) {
|
|
10267
|
+
const autoName = c.autoName ?? "Unnamed Session";
|
|
10268
|
+
console.log(` ${chalk.yellow("REN")} ${c.filename}`);
|
|
10269
|
+
console.log(` → ${padNum(c.number)} - ${c.date} - ${autoName}.md`);
|
|
10270
|
+
totalRename++;
|
|
10271
|
+
}
|
|
10272
|
+
console.log();
|
|
10273
|
+
}
|
|
10274
|
+
if (dirPlan.toMove.length > 0) {
|
|
10275
|
+
console.log(bold$1(" MOVE TO YYYY/MM/ hierarchy:"));
|
|
10276
|
+
for (const c of dirPlan.toMove) {
|
|
10277
|
+
const [year, month] = c.date.split("-");
|
|
10278
|
+
console.log(` ${chalk.cyan("MOV")} ${c.filename}`);
|
|
10279
|
+
console.log(` → ${year}/${month}/${c.filename}`);
|
|
10280
|
+
totalMove++;
|
|
10281
|
+
}
|
|
10282
|
+
console.log();
|
|
10283
|
+
}
|
|
10284
|
+
}
|
|
10285
|
+
if (plan.renumberMap.size > 0) {
|
|
10286
|
+
console.log(bold$1(" RENUMBER (after deletions, global across all Notes/ dirs):"));
|
|
10287
|
+
for (const [oldN, newN] of plan.renumberMap) {
|
|
10288
|
+
console.log(` ${chalk.blue("NUM")} #${padNum(oldN)} → #${padNum(newN)}`);
|
|
10289
|
+
totalRenumber++;
|
|
10290
|
+
}
|
|
10291
|
+
console.log();
|
|
10292
|
+
}
|
|
10293
|
+
}
|
|
10294
|
+
const wouldMovePaths = [];
|
|
10295
|
+
for (const plan of plans) for (const dirPlan of plan.notesDirs) for (const c of dirPlan.toMove) {
|
|
10296
|
+
const [year, month] = c.date.split("-");
|
|
10297
|
+
const targetPath = join(dirPlan.notesDir, year, month, c.filename);
|
|
10298
|
+
if (c.filepath !== targetPath) wouldMovePaths.push(c.filepath);
|
|
10299
|
+
}
|
|
10300
|
+
const vectorDbCount = await countVectorDbPaths(wouldMovePaths);
|
|
10301
|
+
console.log();
|
|
10302
|
+
console.log(bold$1(" Summary (dry-run):"));
|
|
10303
|
+
console.log(` ${chalk.red("DEL")} ${totalDelete} empty sessions to delete`);
|
|
10304
|
+
console.log(` ${chalk.yellow("REN")} ${totalRename} unnamed sessions to rename`);
|
|
10305
|
+
console.log(` ${chalk.blue("NUM")} ${totalRenumber} sessions to renumber`);
|
|
10306
|
+
console.log(` ${chalk.cyan("MOV")} ${totalMove} sessions to move into YYYY/MM/ dirs`);
|
|
10307
|
+
if (vectorDbCount > 0) console.log(` ${chalk.magenta("VEC")} ${vectorDbCount} file path(s) will be updated in the vector DB (embeddings preserved)`);
|
|
10308
|
+
else if (wouldMovePaths.length > 0) console.log(` ${chalk.magenta("VEC")} 0 file path(s) found in vector DB for moved files (no embeddings to preserve)`);
|
|
10309
|
+
console.log();
|
|
10310
|
+
console.log(warn(" This is a dry-run. Add --execute to apply changes."));
|
|
10311
|
+
console.log();
|
|
10312
|
+
}
|
|
10313
|
+
async function executeCleanup(db, plans, skipReindex) {
|
|
10314
|
+
let deleted = 0;
|
|
10315
|
+
let renamed = 0;
|
|
10316
|
+
let moved = 0;
|
|
10317
|
+
let renumbered = 0;
|
|
10318
|
+
let dbUpdated = 0;
|
|
10319
|
+
const vectorDbMoves = [];
|
|
10320
|
+
for (const plan of plans) {
|
|
10321
|
+
console.log();
|
|
10322
|
+
console.log(header(` Project: ${plan.project.display_name} (${plan.project.slug})`));
|
|
10323
|
+
for (const dirPlan of plan.notesDirs) {
|
|
10324
|
+
const { notesDir } = dirPlan;
|
|
10325
|
+
if (plan.notesDirs.length > 1) console.log(dim$1(` Directory: ${notesDir}`));
|
|
10326
|
+
for (const c of dirPlan.toDelete) {
|
|
10327
|
+
try {
|
|
10328
|
+
unlinkSync(c.filepath);
|
|
10329
|
+
console.log(ok(` DEL ${c.filename}`));
|
|
10330
|
+
deleted++;
|
|
10331
|
+
} catch (e) {
|
|
10332
|
+
console.log(err(` FAIL to delete ${c.filename}: ${e}`));
|
|
10333
|
+
}
|
|
10334
|
+
if (c.session) try {
|
|
10335
|
+
db.prepare("DELETE FROM sessions WHERE id = ?").run(c.session.id);
|
|
10336
|
+
dbUpdated++;
|
|
10337
|
+
} catch (e) {
|
|
10338
|
+
console.log(err(` FAIL to remove session #${c.number} from DB: ${e}`));
|
|
10339
|
+
}
|
|
10340
|
+
}
|
|
10341
|
+
for (const c of dirPlan.toRename) {
|
|
10342
|
+
const autoName = c.autoName ?? "Unnamed Session";
|
|
10343
|
+
const newFilename = `${padNum(c.number)} - ${c.date} - ${autoName}.md`;
|
|
10344
|
+
const newPath = join(notesDir, newFilename);
|
|
10345
|
+
if (c.filepath !== newPath) try {
|
|
10346
|
+
renameSync(c.filepath, newPath);
|
|
10347
|
+
console.log(ok(` REN ${c.filename}`));
|
|
10348
|
+
console.log(dim$1(` → ${newFilename}`));
|
|
10349
|
+
renamed++;
|
|
10350
|
+
c.filename = newFilename;
|
|
10351
|
+
c.filepath = newPath;
|
|
10352
|
+
} catch (e) {
|
|
10353
|
+
console.log(err(` FAIL rename ${c.filename}: ${e}`));
|
|
10354
|
+
continue;
|
|
10355
|
+
}
|
|
10356
|
+
try {
|
|
10357
|
+
const lines = readFileSync(newPath, "utf8").split("\n");
|
|
10358
|
+
let h1Updated = false;
|
|
10359
|
+
const updated = lines.map((line) => {
|
|
10360
|
+
if (!h1Updated && line.startsWith("# ")) {
|
|
10361
|
+
h1Updated = true;
|
|
10362
|
+
return `# ${autoName}`;
|
|
10363
|
+
}
|
|
10364
|
+
return line;
|
|
10365
|
+
});
|
|
10366
|
+
if (!h1Updated) updated.unshift(`# ${autoName}`, "");
|
|
10367
|
+
writeFileSync(newPath, updated.join("\n"), "utf8");
|
|
10368
|
+
} catch {}
|
|
10369
|
+
if (c.session) {
|
|
10370
|
+
const normalizedSlug = autoName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
10371
|
+
try {
|
|
10372
|
+
db.prepare("UPDATE sessions SET slug = ?, title = ?, filename = ? WHERE id = ?").run(normalizedSlug, autoName, newFilename, c.session.id);
|
|
10373
|
+
dbUpdated++;
|
|
10374
|
+
} catch (e) {
|
|
10375
|
+
console.log(err(` FAIL DB update for session #${c.number}: ${e}`));
|
|
10376
|
+
}
|
|
10377
|
+
}
|
|
10378
|
+
}
|
|
10379
|
+
if (plan.renumberMap.size > 0) {
|
|
10380
|
+
const toRenumber = dirPlan.toMove.filter((c) => plan.renumberMap.has(c.number));
|
|
10381
|
+
const tempFiles = [];
|
|
10382
|
+
for (const c of toRenumber) {
|
|
10383
|
+
const newNum = plan.renumberMap.get(c.number);
|
|
10384
|
+
const tempPath = join(notesDir, `__tmp_${padNum(c.number)}_${c.filename}`);
|
|
10385
|
+
try {
|
|
10386
|
+
if (existsSync(c.filepath)) {
|
|
10387
|
+
renameSync(c.filepath, tempPath);
|
|
10388
|
+
tempFiles.push({
|
|
10389
|
+
candidate: c,
|
|
10390
|
+
tempPath,
|
|
10391
|
+
newNum
|
|
10392
|
+
});
|
|
10393
|
+
}
|
|
10394
|
+
} catch (e) {
|
|
10395
|
+
console.log(err(` FAIL temp-rename #${c.number}: ${e}`));
|
|
10396
|
+
}
|
|
10397
|
+
}
|
|
10398
|
+
for (const { candidate: c, tempPath, newNum } of tempFiles) {
|
|
10399
|
+
const newFilename = c.filename.replace(/^\d{4}/, padNum(newNum));
|
|
10400
|
+
const newPath = join(notesDir, newFilename);
|
|
10401
|
+
try {
|
|
10402
|
+
renameSync(tempPath, newPath);
|
|
10403
|
+
console.log(ok(` NUM #${padNum(c.number)} → #${padNum(newNum)}: ${newFilename}`));
|
|
10404
|
+
renumbered++;
|
|
10405
|
+
c.filename = newFilename;
|
|
10406
|
+
c.filepath = newPath;
|
|
10407
|
+
c.number = newNum;
|
|
10408
|
+
} catch (e) {
|
|
10409
|
+
console.log(err(` FAIL final-rename #${newNum}: ${e}`));
|
|
10410
|
+
}
|
|
10411
|
+
if (existsSync(newPath)) try {
|
|
10412
|
+
writeFileSync(newPath, readFileSync(newPath, "utf8").split("\n").map((line) => {
|
|
10413
|
+
if (line.match(/^# Session \d{4}:/)) return line.replace(/^# Session \d{4}:/, `# Session ${padNum(newNum)}:`);
|
|
10414
|
+
return line;
|
|
10415
|
+
}).join("\n"), "utf8");
|
|
10416
|
+
} catch {}
|
|
10417
|
+
}
|
|
10418
|
+
const dbRenumbers = tempFiles.filter(({ candidate: c }) => c.session != null).map(({ candidate: c, newNum }) => ({
|
|
10419
|
+
session: c.session,
|
|
10420
|
+
newNum,
|
|
10421
|
+
newFilename: c.filename
|
|
10422
|
+
}));
|
|
10423
|
+
if (dbRenumbers.length > 0) {
|
|
10424
|
+
const renumberDb = db.transaction(() => {
|
|
10425
|
+
for (const { session, newNum } of dbRenumbers) db.prepare("UPDATE sessions SET number = ? WHERE id = ?").run(-newNum, session.id);
|
|
10426
|
+
for (const { session, newNum, newFilename } of dbRenumbers) db.prepare("UPDATE sessions SET number = ?, filename = ? WHERE id = ?").run(newNum, newFilename, session.id);
|
|
10427
|
+
});
|
|
10428
|
+
try {
|
|
10429
|
+
renumberDb();
|
|
10430
|
+
dbUpdated += dbRenumbers.length;
|
|
10431
|
+
} catch (e) {
|
|
10432
|
+
console.log(err(` FAIL DB renumber transaction: ${e}`));
|
|
10433
|
+
}
|
|
10434
|
+
}
|
|
10435
|
+
}
|
|
10436
|
+
for (const c of dirPlan.toMove) {
|
|
10437
|
+
const [year, month] = c.date.split("-");
|
|
10438
|
+
const targetDir = join(notesDir, year, month);
|
|
10439
|
+
const targetPath = join(targetDir, c.filename);
|
|
10440
|
+
if (c.filepath === targetPath) continue;
|
|
10441
|
+
try {
|
|
10442
|
+
mkdirSync(targetDir, { recursive: true });
|
|
10443
|
+
} catch (e) {
|
|
10444
|
+
console.log(err(` FAIL mkdir ${targetDir}: ${e}`));
|
|
10445
|
+
continue;
|
|
10446
|
+
}
|
|
10447
|
+
const oldAbsPath = c.filepath;
|
|
10448
|
+
try {
|
|
10449
|
+
if (existsSync(c.filepath)) {
|
|
10450
|
+
renameSync(c.filepath, targetPath);
|
|
10451
|
+
console.log(ok(` MOV ${c.filename}`));
|
|
10452
|
+
console.log(dim$1(` → ${year}/${month}/${c.filename}`));
|
|
10453
|
+
moved++;
|
|
10454
|
+
vectorDbMoves.push({
|
|
10455
|
+
oldPath: oldAbsPath,
|
|
10456
|
+
newPath: targetPath
|
|
10457
|
+
});
|
|
10458
|
+
}
|
|
10459
|
+
} catch (e) {
|
|
10460
|
+
console.log(err(` FAIL move ${c.filename}: ${e}`));
|
|
10461
|
+
continue;
|
|
10462
|
+
}
|
|
10463
|
+
const newFilenameInDb = `${year}/${month}/${c.filename}`;
|
|
10464
|
+
if (c.session) try {
|
|
10465
|
+
db.prepare("UPDATE sessions SET filename = ? WHERE id = ?").run(newFilenameInDb, c.session.id);
|
|
10466
|
+
dbUpdated++;
|
|
10467
|
+
} catch (e) {
|
|
10468
|
+
console.log(err(` FAIL DB update path for ${c.filename}: ${e}`));
|
|
10469
|
+
}
|
|
10470
|
+
}
|
|
10471
|
+
}
|
|
10472
|
+
}
|
|
10473
|
+
let vectorDbUpdated = 0;
|
|
10474
|
+
if (vectorDbMoves.length > 0) {
|
|
10475
|
+
console.log();
|
|
10476
|
+
console.log(dim$1(` Updating ${vectorDbMoves.length} file path(s) in vector DB to preserve embeddings...`));
|
|
10477
|
+
const result = await updateVectorDbPaths(vectorDbMoves);
|
|
10478
|
+
if (result >= 0) {
|
|
10479
|
+
vectorDbUpdated = result;
|
|
10480
|
+
console.log(ok(` Updated ${vectorDbUpdated} file path(s) in Postgres (embeddings preserved)`));
|
|
10481
|
+
} else console.log(warn(" Vector DB path update failed — embeddings may be orphaned (check logs)"));
|
|
10482
|
+
}
|
|
10483
|
+
console.log();
|
|
10484
|
+
console.log(bold$1(" Cleanup complete:"));
|
|
10485
|
+
console.log(ok(` ${deleted} session(s) deleted`));
|
|
10486
|
+
console.log(ok(` ${renamed} session(s) renamed`));
|
|
10487
|
+
console.log(ok(` ${renumbered} session(s) renumbered`));
|
|
10488
|
+
console.log(ok(` ${moved} session(s) moved to YYYY/MM/ hierarchy`));
|
|
10489
|
+
console.log(ok(` ${dbUpdated} registry DB record(s) updated`));
|
|
10490
|
+
if (vectorDbMoves.length > 0) console.log(ok(` ${vectorDbUpdated} vector DB file path(s) updated (embeddings preserved)`));
|
|
10491
|
+
if (!skipReindex) {
|
|
10492
|
+
console.log();
|
|
10493
|
+
console.log(dim$1(" Memory re-index: the PAI daemon will pick up changes within 5 minutes."));
|
|
10494
|
+
console.log(dim$1(" To force immediate re-index: pai memory index --all"));
|
|
10495
|
+
}
|
|
10496
|
+
console.log();
|
|
10497
|
+
}
|
|
10498
|
+
|
|
10499
|
+
//#endregion
|
|
10500
|
+
//#region src/cli/commands/session-cleanup/index.ts
|
|
10501
|
+
function registerSessionCleanupCommand(sessionCmd, getDb) {
|
|
10502
|
+
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) => {
|
|
10503
|
+
const db = getDb();
|
|
10504
|
+
const dryRun = !opts.execute;
|
|
10505
|
+
const skipReindex = opts.reindex === false;
|
|
10506
|
+
let projects;
|
|
10507
|
+
if (projectSlug) {
|
|
10508
|
+
const p = getProject(db, projectSlug);
|
|
10509
|
+
if (!p) {
|
|
10510
|
+
console.error(err(`Project not found: ${projectSlug}`));
|
|
10511
|
+
process.exit(1);
|
|
10512
|
+
}
|
|
10513
|
+
projects = [p];
|
|
10514
|
+
} else projects = getAllProjects(db);
|
|
10515
|
+
console.log();
|
|
10516
|
+
console.log(header(dryRun ? " pai session cleanup — DRY RUN (no changes will be made)" : " pai session cleanup — EXECUTING"));
|
|
10517
|
+
console.log(dim$1(` Analyzing ${projects.length} project(s)...`));
|
|
10518
|
+
const plans = [];
|
|
10519
|
+
for (const project of projects) {
|
|
10520
|
+
const plan = analyzeProject(db, project);
|
|
10521
|
+
if (plan) plans.push(plan);
|
|
10522
|
+
}
|
|
10523
|
+
const activePlans = plans.filter((p) => p.notesDirs.some((d) => d.toDelete.length > 0 || d.toRename.length > 0 || d.toMove.length > 0) || p.renumberMap.size > 0);
|
|
10524
|
+
if (activePlans.length === 0) {
|
|
10525
|
+
console.log();
|
|
10526
|
+
console.log(ok(" Nothing to do — all session notes are clean!"));
|
|
10527
|
+
console.log();
|
|
10528
|
+
return;
|
|
10529
|
+
}
|
|
10530
|
+
if (dryRun) await displayDryRun(activePlans);
|
|
10531
|
+
else await executeCleanup(db, activePlans, skipReindex);
|
|
10532
|
+
});
|
|
10533
|
+
}
|
|
10534
|
+
|
|
10535
|
+
//#endregion
|
|
10536
|
+
//#region src/cli/commands/session/end.ts
|
|
10537
|
+
function finalizeNote(notePath) {
|
|
10538
|
+
const content = readFileSync(notePath, "utf-8");
|
|
10539
|
+
if (content.includes("**Status:** Completed")) return {
|
|
10540
|
+
finalized: false,
|
|
10541
|
+
path: notePath
|
|
10542
|
+
};
|
|
10543
|
+
let updated = content.replace("**Status:** In Progress", "**Status:** Completed");
|
|
10544
|
+
if (!updated.includes("**Completed:**")) {
|
|
10545
|
+
const completionTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
10546
|
+
updated = updated.replace("---\n\n## Work Done", `**Completed:** ${completionTime}\n\n---\n\n## Work Done`);
|
|
10547
|
+
}
|
|
10548
|
+
const tmp = `${notePath}.end.tmp`;
|
|
10549
|
+
writeFileSync(tmp, updated, "utf-8");
|
|
10550
|
+
renameSync(tmp, notePath);
|
|
10551
|
+
return {
|
|
10552
|
+
finalized: true,
|
|
10553
|
+
path: notePath
|
|
10554
|
+
};
|
|
10555
|
+
}
|
|
10556
|
+
function cmdEnd(db, opts) {
|
|
10557
|
+
cmdPause(db, opts);
|
|
10558
|
+
const cwd = process.cwd();
|
|
10559
|
+
const project = db.prepare(`SELECT id, slug, display_name, root_path, encoded_dir
|
|
10560
|
+
FROM projects
|
|
10561
|
+
WHERE ? LIKE root_path || '%'
|
|
10562
|
+
ORDER BY length(root_path) DESC
|
|
8482
10563
|
LIMIT 1`).get(cwd);
|
|
8483
10564
|
if (!project) return;
|
|
8484
|
-
const notesDir = findNotesDir(project.root_path, project.encoded_dir ?? "");
|
|
10565
|
+
const notesDir = findNotesDir$1(project.root_path, project.encoded_dir ?? "");
|
|
8485
10566
|
if (!notesDir) {
|
|
8486
10567
|
console.log(chalk.dim(" (No session note found — notes directory does not exist yet.)"));
|
|
8487
10568
|
printEndBox(opts.dryRun);
|
|
@@ -8526,164 +10607,6 @@ function printEndBox(dryRun) {
|
|
|
8526
10607
|
console.log(box);
|
|
8527
10608
|
}
|
|
8528
10609
|
|
|
8529
|
-
//#endregion
|
|
8530
|
-
//#region src/cli/commands/session/goto.ts
|
|
8531
|
-
/**
|
|
8532
|
-
* Run claude --resume <uuid> --print --output-format=json "_" in the given cwd.
|
|
8533
|
-
* Returns ok=true if the session is resumable (exit 0 and no "No conversation found"
|
|
8534
|
-
* in stderr). Timeout: 5 seconds.
|
|
8535
|
-
*/
|
|
8536
|
-
function probeResume$1(uuid, cwd) {
|
|
8537
|
-
const result = spawnSync("claude", [
|
|
8538
|
-
"--resume",
|
|
8539
|
-
uuid,
|
|
8540
|
-
"--print",
|
|
8541
|
-
"--output-format=json",
|
|
8542
|
-
"_"
|
|
8543
|
-
], {
|
|
8544
|
-
cwd,
|
|
8545
|
-
timeout: 5e3,
|
|
8546
|
-
env: process.env,
|
|
8547
|
-
stdio: [
|
|
8548
|
-
"ignore",
|
|
8549
|
-
"ignore",
|
|
8550
|
-
"pipe"
|
|
8551
|
-
]
|
|
8552
|
-
});
|
|
8553
|
-
if (result.error) return {
|
|
8554
|
-
ok: false,
|
|
8555
|
-
reason: `spawn error: ${result.error.message}`
|
|
8556
|
-
};
|
|
8557
|
-
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
8558
|
-
if (stderr.toLowerCase().includes("no conversation found") || stderr.toLowerCase().includes("session not found")) return {
|
|
8559
|
-
ok: false,
|
|
8560
|
-
reason: "No conversation found for this UUID"
|
|
8561
|
-
};
|
|
8562
|
-
if (result.status !== 0) return {
|
|
8563
|
-
ok: false,
|
|
8564
|
-
reason: `claude exited ${result.status ?? "signal"}${stderr ? `: ${stderr.slice(0, 120).trim()}` : ""}`
|
|
8565
|
-
};
|
|
8566
|
-
return { ok: true };
|
|
8567
|
-
}
|
|
8568
|
-
function cmdGoto(db, query, opts) {
|
|
8569
|
-
const allSessions = scanSessions(db, {
|
|
8570
|
-
limit: 500,
|
|
8571
|
-
filter: "named"
|
|
8572
|
-
});
|
|
8573
|
-
let resolved;
|
|
8574
|
-
try {
|
|
8575
|
-
resolved = resolveSessionByNameOrId(allSessions, query);
|
|
8576
|
-
} catch (resolveErr) {
|
|
8577
|
-
let msg = String(resolveErr).replace(/^Error: /, "");
|
|
8578
|
-
if (msg.includes("No session found matching")) msg += `\n\nTip: pai find "${query}" — search prompt history by keywords`;
|
|
8579
|
-
console.error(err(msg));
|
|
8580
|
-
process.exit(1);
|
|
8581
|
-
}
|
|
8582
|
-
const { session: matchedSession, friendlyName } = resolved;
|
|
8583
|
-
let resumableUuid;
|
|
8584
|
-
let resumableSession;
|
|
8585
|
-
if (matchedSession.resumable) {
|
|
8586
|
-
resumableUuid = matchedSession.uuid;
|
|
8587
|
-
resumableSession = matchedSession;
|
|
8588
|
-
} else if (matchedSession.encodedDir) {
|
|
8589
|
-
const sameProject = allSessions.filter((s) => s.encodedDir === matchedSession.encodedDir && s.resumable);
|
|
8590
|
-
sameProject.sort((a, b) => b.mtime - a.mtime);
|
|
8591
|
-
if (sameProject.length > 0) {
|
|
8592
|
-
resumableSession = sameProject[0];
|
|
8593
|
-
resumableUuid = resumableSession.uuid;
|
|
8594
|
-
}
|
|
8595
|
-
}
|
|
8596
|
-
const rawDir = matchedSession.clcDirectory ?? matchedSession.registryRootPath ?? matchedSession.decodedPath;
|
|
8597
|
-
let projectDir;
|
|
8598
|
-
try {
|
|
8599
|
-
projectDir = realpathSync(rawDir);
|
|
8600
|
-
} catch {
|
|
8601
|
-
console.error(err(`session "${query}": directory does not exist or cannot be resolved.\n Registry says: ${rawDir}\n The directory may have moved or been deleted.`));
|
|
8602
|
-
process.exit(1);
|
|
8603
|
-
}
|
|
8604
|
-
const name = friendlyName ?? query;
|
|
8605
|
-
const promptArg = `/Name ${name}\ngo`;
|
|
8606
|
-
if (opts.dryRun) {
|
|
8607
|
-
if (resumableUuid) {
|
|
8608
|
-
const argvResume = `claude --resume ${resumableUuid} --name "${name}" "/Name ${name}\\ngo"`;
|
|
8609
|
-
const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
|
|
8610
|
-
console.log("\n" + chalk.bold("Dry run — would probe then exec (RESUME path):") + "\n");
|
|
8611
|
-
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
8612
|
-
console.log(` probe: claude --resume ${resumableUuid} --print --output-format=json "_"`);
|
|
8613
|
-
console.log(` argv: ${chalk.white(argvResume)}`);
|
|
8614
|
-
console.log(` fallback: ${chalk.yellow(argvFresh)}`);
|
|
8615
|
-
if (resumableSession) {
|
|
8616
|
-
console.log(`\n uuid: ${resumableSession.uuid}`);
|
|
8617
|
-
console.log(` age: ${fmtAge(resumableSession.mtime)}`);
|
|
8618
|
-
console.log(` status: ${resumableSession.sessionStatus}`);
|
|
8619
|
-
console.log(` sys: ${resumableSession.topLevelSystemLines} system lines`);
|
|
8620
|
-
}
|
|
8621
|
-
} else {
|
|
8622
|
-
const argvFresh = `claude --name "${name}" "/Name ${name}\\ngo"`;
|
|
8623
|
-
console.log("\n" + chalk.bold("Dry run — would exec (FRESH path, no resumable UUID):") + "\n");
|
|
8624
|
-
console.log(` cwd: ${chalk.cyan(projectDir)}`);
|
|
8625
|
-
console.log(` argv: ${chalk.white(argvFresh)}`);
|
|
8626
|
-
}
|
|
8627
|
-
console.log();
|
|
8628
|
-
return;
|
|
8629
|
-
}
|
|
8630
|
-
if (resumableUuid) {
|
|
8631
|
-
const probe = probeResume$1(resumableUuid, projectDir);
|
|
8632
|
-
if (probe.ok) {
|
|
8633
|
-
const result = spawnSync("claude", [
|
|
8634
|
-
"--resume",
|
|
8635
|
-
resumableUuid,
|
|
8636
|
-
"--name",
|
|
8637
|
-
name,
|
|
8638
|
-
promptArg
|
|
8639
|
-
], {
|
|
8640
|
-
cwd: projectDir,
|
|
8641
|
-
stdio: "inherit",
|
|
8642
|
-
env: process.env
|
|
8643
|
-
});
|
|
8644
|
-
if (result.error) {
|
|
8645
|
-
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
8646
|
-
process.exit(1);
|
|
8647
|
-
}
|
|
8648
|
-
printExitDir(projectDir);
|
|
8649
|
-
process.exit(result.status ?? 0);
|
|
8650
|
-
} else {
|
|
8651
|
-
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`));
|
|
8652
|
-
const result = spawnSync("claude", [
|
|
8653
|
-
"--name",
|
|
8654
|
-
name,
|
|
8655
|
-
promptArg
|
|
8656
|
-
], {
|
|
8657
|
-
cwd: projectDir,
|
|
8658
|
-
stdio: "inherit",
|
|
8659
|
-
env: process.env
|
|
8660
|
-
});
|
|
8661
|
-
if (result.error) {
|
|
8662
|
-
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
8663
|
-
process.exit(1);
|
|
8664
|
-
}
|
|
8665
|
-
printExitDir(projectDir);
|
|
8666
|
-
process.exit(result.status ?? 0);
|
|
8667
|
-
}
|
|
8668
|
-
} else {
|
|
8669
|
-
const result = spawnSync("claude", [
|
|
8670
|
-
"--name",
|
|
8671
|
-
name,
|
|
8672
|
-
promptArg
|
|
8673
|
-
], {
|
|
8674
|
-
cwd: projectDir,
|
|
8675
|
-
stdio: "inherit",
|
|
8676
|
-
env: process.env
|
|
8677
|
-
});
|
|
8678
|
-
if (result.error) {
|
|
8679
|
-
console.error(err(`Failed to launch claude: ${result.error.message}`));
|
|
8680
|
-
process.exit(1);
|
|
8681
|
-
}
|
|
8682
|
-
printExitDir(projectDir);
|
|
8683
|
-
process.exit(result.status ?? 0);
|
|
8684
|
-
}
|
|
8685
|
-
}
|
|
8686
|
-
|
|
8687
10610
|
//#endregion
|
|
8688
10611
|
//#region src/cli/commands/session/pause-all.ts
|
|
8689
10612
|
/**
|
|
@@ -8845,96 +10768,6 @@ async function cmdClearNames(opts) {
|
|
|
8845
10768
|
} else console.log(ok("\nDone. Use /Name to re-label each tab."));
|
|
8846
10769
|
}
|
|
8847
10770
|
|
|
8848
|
-
//#endregion
|
|
8849
|
-
//#region src/cli/commands/session/helpers.ts
|
|
8850
|
-
function getProject(db, slug) {
|
|
8851
|
-
return db.prepare("SELECT id, slug, display_name, root_path, encoded_dir FROM projects WHERE slug = ?").get(slug);
|
|
8852
|
-
}
|
|
8853
|
-
function statusColor(status) {
|
|
8854
|
-
switch (status) {
|
|
8855
|
-
case "completed": return chalk.green(status);
|
|
8856
|
-
case "compacted": return chalk.blue(status);
|
|
8857
|
-
default: return chalk.yellow(status);
|
|
8858
|
-
}
|
|
8859
|
-
}
|
|
8860
|
-
|
|
8861
|
-
//#endregion
|
|
8862
|
-
//#region src/cli/commands/session/commands.ts
|
|
8863
|
-
function cmdList(db, projectSlug, opts) {
|
|
8864
|
-
const limit = parseInt(opts.limit ?? "20", 10);
|
|
8865
|
-
const params = [];
|
|
8866
|
-
let query = `
|
|
8867
|
-
SELECT s.*, p.slug AS project_slug, p.display_name AS project_name
|
|
8868
|
-
FROM sessions s
|
|
8869
|
-
JOIN projects p ON p.id = s.project_id
|
|
8870
|
-
`;
|
|
8871
|
-
const where = [];
|
|
8872
|
-
if (projectSlug) {
|
|
8873
|
-
const project = getProject(db, projectSlug);
|
|
8874
|
-
if (!project) {
|
|
8875
|
-
console.error(err(`Project not found: ${projectSlug}`));
|
|
8876
|
-
process.exit(1);
|
|
8877
|
-
}
|
|
8878
|
-
where.push("s.project_id = ?");
|
|
8879
|
-
params.push(project.id);
|
|
8880
|
-
}
|
|
8881
|
-
if (opts.status) {
|
|
8882
|
-
where.push("s.status = ?");
|
|
8883
|
-
params.push(opts.status);
|
|
8884
|
-
}
|
|
8885
|
-
if (where.length) query += " WHERE " + where.join(" AND ");
|
|
8886
|
-
query += " ORDER BY s.date DESC, s.number DESC";
|
|
8887
|
-
query += ` LIMIT ${limit}`;
|
|
8888
|
-
const rows = db.prepare(query).all(...params);
|
|
8889
|
-
if (!rows.length) {
|
|
8890
|
-
console.log(warn("No sessions found."));
|
|
8891
|
-
return;
|
|
8892
|
-
}
|
|
8893
|
-
const showProject = !projectSlug;
|
|
8894
|
-
const headers = showProject ? [
|
|
8895
|
-
"#",
|
|
8896
|
-
"Project",
|
|
8897
|
-
"Date",
|
|
8898
|
-
"Title",
|
|
8899
|
-
"Status",
|
|
8900
|
-
"Tokens"
|
|
8901
|
-
] : [
|
|
8902
|
-
"#",
|
|
8903
|
-
"Date",
|
|
8904
|
-
"Title",
|
|
8905
|
-
"Status",
|
|
8906
|
-
"Tokens"
|
|
8907
|
-
];
|
|
8908
|
-
const tableRows = rows.map((r) => {
|
|
8909
|
-
const title = r.title.length > 45 ? r.title.slice(0, 42) + "..." : r.title;
|
|
8910
|
-
const tokens = r.token_count != null ? dim$1(r.token_count.toLocaleString()) : dim$1("—");
|
|
8911
|
-
if (showProject) return [
|
|
8912
|
-
dim$1(`#${r.number}`),
|
|
8913
|
-
r.project_slug,
|
|
8914
|
-
r.date,
|
|
8915
|
-
title,
|
|
8916
|
-
statusColor(r.status),
|
|
8917
|
-
tokens
|
|
8918
|
-
];
|
|
8919
|
-
return [
|
|
8920
|
-
dim$1(`#${r.number}`),
|
|
8921
|
-
r.date,
|
|
8922
|
-
title,
|
|
8923
|
-
statusColor(r.status),
|
|
8924
|
-
tokens
|
|
8925
|
-
];
|
|
8926
|
-
});
|
|
8927
|
-
console.log();
|
|
8928
|
-
if (projectSlug) {
|
|
8929
|
-
const project = getProject(db, projectSlug);
|
|
8930
|
-
console.log(` ${bold$1(project.display_name)} sessions:`);
|
|
8931
|
-
console.log();
|
|
8932
|
-
}
|
|
8933
|
-
console.log(renderTable(headers, tableRows));
|
|
8934
|
-
console.log();
|
|
8935
|
-
console.log(dim$1(` ${rows.length} session(s) shown (limit: ${limit})`));
|
|
8936
|
-
}
|
|
8937
|
-
|
|
8938
10771
|
//#endregion
|
|
8939
10772
|
//#region src/cli/commands/find.ts
|
|
8940
10773
|
/**
|
|
@@ -9304,7 +11137,7 @@ function buildFeedFrom(allSessions, projects, liveSessions, allProjects) {
|
|
|
9304
11137
|
const nameRoot = /* @__PURE__ */ new Map();
|
|
9305
11138
|
for (const p of allProjects) {
|
|
9306
11139
|
slugRoot.set(p.slug, p.root_path);
|
|
9307
|
-
nameRoot.set(normalizeName(p.display_name ?? p.slug).toLowerCase(), p.root_path);
|
|
11140
|
+
nameRoot.set(normalizeName$1(p.display_name ?? p.slug).toLowerCase(), p.root_path);
|
|
9308
11141
|
}
|
|
9309
11142
|
const bestResumable = /* @__PURE__ */ new Map();
|
|
9310
11143
|
for (const s of allSessions) {
|
|
@@ -9706,5 +11539,5 @@ async function cmdPick(db, opts = {}) {
|
|
|
9706
11539
|
}
|
|
9707
11540
|
|
|
9708
11541
|
//#endregion
|
|
9709
|
-
export {
|
|
9710
|
-
//# sourceMappingURL=pick-
|
|
11542
|
+
export { findMovedPath as A, registerRestoreCommands as C, registerMemoryCommands as D, registerMcpCommands as E, registerRegistryCommands as O, registerSetupCommand as S, registerDaemonCommands as T, registerUpdateCommand as _, cmdEnd as a, registerZettelCommands as b, cmdGoto as c, registerHelpCommand as d, registerDbCommands as f, registerNotifyCommands as g, registerTaskCommands as h, cmdPauseAll as i, resolveIdentifier as j, registerProjectsCommands as k, cmdPause as l, registerTopicCommands as m, cmdFind as n, registerSessionCleanupCommand as o, registerKgCommands as p, cmdClearNames as r, registerSessionCommands as s, cmdPick as t, cmdList as u, registerSkillCommands as v, registerBackupCommands as w, registerObsidianCommands as x, registerObservationCommands as y };
|
|
11543
|
+
//# sourceMappingURL=pick-B3zEYjin.mjs.map
|