@tekmidian/pai 0.31.0 → 0.32.1

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.
@@ -11,12 +11,12 @@ import { _ as transcriptFiles, a as readBodyFile, c as loadScanConfig, d as save
11
11
  import { a as schedulerLogPath, i as paiSocketPath, n as daemonLogPath, r as daemonPidPath } from "./runtime-paths-B0P1TvUr.mjs";
12
12
  import { t as PaiClient } from "./ipc-client-aVKVERjJ.mjs";
13
13
  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-CcdkNSWa.mjs";
14
- import { i as humanDuration, t as createStorageBackend } from "./factory-BydJSrZJ.mjs";
14
+ import { i as humanDuration, t as createStorageBackend } from "./factory-Bba0CG2s.mjs";
15
15
  import { s as kgQuery } from "./kg-entity-r8duqhi9.mjs";
16
16
  import { _ as scanSessions, a as renderDedupedSessions, c as probeResume, d as callAiBroker, f as fetchLiveSessions, g as resolveSessionByNameOrId, h as fmtAge, i as normalizeName$2, l as restoreTopLevel, m as sendToSession, o as hasConversation, p as revealItermSession, r as buildDeduped, s as launchInDir, u as printExitDir } from "./main-resolver-DjyUDJrv.mjs";
17
17
  import { appendFileSync, chmodSync, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
18
18
  import { homedir, platform, tmpdir } from "node:os";
19
- import { basename, dirname, join, relative, resolve } from "node:path";
19
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
20
  import chalk from "chalk";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
@@ -24,6 +24,61 @@ import { createInterface } from "node:readline";
24
24
  import { createConnection } from "net";
25
25
  import { promisify } from "node:util";
26
26
 
27
+ //#region src/registry/registrable.ts
28
+ /**
29
+ * registrable.ts — directories that must never become registered projects.
30
+ *
31
+ * A project is a durable thing. The registry, however, has been accepting
32
+ * whatever directory a session happened to start in, and some of those
33
+ * directories are disposable by construction. Measured on the real registry,
34
+ * 2026-08-04:
35
+ *
36
+ * 08 - Others/MDF/Infrastruktur/.claude/worktrees/cool-haibt 1 session
37
+ * 08 - Others/MDF/Infrastruktur/.claude/worktrees/strange-haibt 7 sessions
38
+ * /private/tmp/ops-webui dead
39
+ * /private/tmp/claude-501/-Users-…-AIBroker/aae854c6-… dead
40
+ *
41
+ * The worktrees are agent isolation directories, created to be removed. The temp
42
+ * paths are exactly what their name says. Registering them attributes session
43
+ * history to a location with no future, and lets `pai <name>` route someone into
44
+ * a directory a cleanup can delete underneath them.
45
+ *
46
+ * So this refuses at the point of registration rather than reclassifying
47
+ * afterwards. It is a guard, not a policy about what health should call things —
48
+ * it stops the set growing while the vocabulary question (dead vs duplicate vs
49
+ * misnamed vs ephemeral) is decided separately.
50
+ *
51
+ * Found by the AIBroker session while we were dividing up the dead-path work.
52
+ */
53
+ /** Path fragments that mark a location as disposable, with why. */
54
+ const EPHEMERAL = [
55
+ {
56
+ fragment: `${sep}.claude${sep}worktrees${sep}`,
57
+ because: "a git worktree created for agent isolation — it is meant to be removed"
58
+ },
59
+ {
60
+ fragment: `${sep}private${sep}tmp${sep}`,
61
+ because: "a system temp directory"
62
+ },
63
+ {
64
+ fragment: `${sep}var${sep}folders${sep}`,
65
+ because: "a macOS per-user temp directory"
66
+ }
67
+ ];
68
+ /**
69
+ * Why this path cannot be a project, or undefined if it can.
70
+ *
71
+ * Returns the reason rather than a boolean so the caller can tell the user which
72
+ * rule caught them — "refused" without a reason invites someone to work around it
73
+ * rather than move their project somewhere durable.
74
+ */
75
+ function unregistrableReason(rootPath) {
76
+ const padded = rootPath.endsWith(sep) ? rootPath : rootPath + sep;
77
+ for (const { fragment, because } of EPHEMERAL) if (padded.includes(fragment)) return because;
78
+ if (padded.startsWith(`${sep}tmp${sep}`)) return "a system temp directory";
79
+ }
80
+
81
+ //#endregion
27
82
  //#region src/session/promote.ts
28
83
  /**
29
84
  * Derive a human-readable project name from a session note filename.
@@ -74,6 +129,13 @@ function cmdPromote(db, opts) {
74
129
  console.error(err(`Could not derive a valid slug from name: "${displayName}"`));
75
130
  process.exit(1);
76
131
  }
132
+ const ephemeral = unregistrableReason(targetPath);
133
+ if (ephemeral) {
134
+ console.error(err(`Refusing to promote into ${targetPath}`));
135
+ console.error(dim$1(` That is ${ephemeral}.`));
136
+ console.error(dim$1(` Pick a durable location for the new project.`));
137
+ process.exit(1);
138
+ }
77
139
  const encodedDir = encodeDir(targetPath);
78
140
  if (db.prepare("SELECT id FROM projects WHERE slug = ? OR root_path = ? OR encoded_dir = ?").get(slug, targetPath, encodedDir)) {
79
141
  console.error(err(`A project with slug "${slug}" or path "${targetPath}" is already registered.`));
@@ -360,6 +422,13 @@ function cmdAdd(db, rawPath, opts) {
360
422
  console.error(err(`Invalid type "${type}". Valid: ${validTypes.join(", ")}`));
361
423
  process.exit(1);
362
424
  }
425
+ const ephemeral = unregistrableReason(rootPath);
426
+ if (ephemeral) {
427
+ console.error(err(`Refusing to register ${shortenPath(rootPath, 60)}`));
428
+ console.error(dim$1(` That is ${ephemeral}.`));
429
+ console.error(dim$1(` A project needs a durable location — move it, then register.`));
430
+ process.exit(1);
431
+ }
363
432
  if (db.prepare("SELECT id FROM projects WHERE slug = ? OR root_path = ?").get(slug, rootPath)) {
364
433
  console.error(err(`Project already registered (slug: ${slug} or path: ${rootPath})`));
365
434
  process.exit(1);
@@ -1170,6 +1239,197 @@ function cmdConfig(db, identifier, opts) {
1170
1239
  console.log();
1171
1240
  }
1172
1241
 
1242
+ //#endregion
1243
+ //#region src/cli/commands/project/relocate.ts
1244
+ /**
1245
+ * relocate.ts — find a registered project whose ancestor directory was renamed.
1246
+ *
1247
+ * The registry stores absolute root paths. Rename any directory ABOVE a project
1248
+ * and every project underneath it goes missing at once — `existsSync` fails, and
1249
+ * the health check reported them as dead and offered to archive them. Renaming
1250
+ * `Ideaverse` to `🧠 Ideaverse` orphaned a whole subtree that way: 32 entries, all
1251
+ * still on disk, none of them findable.
1252
+ *
1253
+ * The old suggestion logic could not see this. It took the project's BASENAME and
1254
+ * looked for it in four hardcoded directories (`~/dev`, `~/dev/ai`, `~/Desktop`,
1255
+ * `~/Projects`), so it could only recognise "the leaf moved into a place I already
1256
+ * know about". A renamed ancestor leaves the leaf exactly where it was.
1257
+ *
1258
+ * So walk down from the root while the path still exists — that lands on the
1259
+ * deepest surviving ancestor, which is precisely where the rename happened — then
1260
+ * match the remaining segments by NORMALISED name. Normalising strips the emoji,
1261
+ * the space and the case, so `🧠 Ideaverse` and `Ideaverse` both reduce to
1262
+ * "ideaverse". The general case is "an ancestor was renamed decoratively", which
1263
+ * is the whole class of failure rather than this one instance of it.
1264
+ *
1265
+ * Algorithm and the measurements behind it come from the AIBroker session, which
1266
+ * probed it and handed it over rather than implementing it in a file that was not
1267
+ * theirs.
1268
+ */
1269
+ /**
1270
+ * A directory name reduced to what a rename is unlikely to have changed.
1271
+ *
1272
+ * NFC first because macOS hands back decomposed Unicode: an "ä" typed in one
1273
+ * place and read from a directory listing in another are different byte
1274
+ * sequences, and comparing them raw fails for reasons no one can see. Then case
1275
+ * and every non-alphanumeric are dropped, which is what makes `🧠 Ideaverse`
1276
+ * match `Ideaverse` — and, unavoidably, what makes `my-project` match
1277
+ * `my project`. That looseness is the point, and the uniqueness rule below is
1278
+ * what keeps it safe.
1279
+ */
1280
+ function norm(s) {
1281
+ return s.normalize("NFC").toLowerCase().replace(/[^\p{Letter}\p{Number}]+/gu, "");
1282
+ }
1283
+ /** Hidden by Unix convention — tool state rather than a project directory. */
1284
+ function isHidden(name) {
1285
+ return name.startsWith(".");
1286
+ }
1287
+ /**
1288
+ * Drop a leading ordering prefix: `20 - Webseiten` -> `Webseiten`.
1289
+ *
1290
+ * This vault numbers directories to force sort order — `04 - Ablage`,
1291
+ * `08 - Others`, `01 - Base Setup`, `20 - Webseiten` — and adding or removing that
1292
+ * prefix is a rename of exactly the same kind as adding an emoji. `norm()` alone
1293
+ * cannot see it, because the digits survive normalisation: "webseiten" against
1294
+ * "20webseiten".
1295
+ *
1296
+ * Found because `MDF.md` links to `Infrastruktur/20 - Webseiten` while the
1297
+ * registry has a dead entry for plain `Infrastruktur/Webseiten`. The note files
1298
+ * knew where it went.
1299
+ *
1300
+ * Deliberately NOT a general suffix match, and the reason is worth spelling out
1301
+ * because "why not just match on the suffix" is the obvious next question.
1302
+ *
1303
+ * `MDF/Infrastruktur/` is wall-to-wall numbered: `00 - Migration Analysis`,
1304
+ * `01 - Base Setup`, `02 - Storage Setup`, `03 - Network Setup`, and so on. A
1305
+ * suffix rule on a wanted `Setup` hits eight of them at once — so the uniqueness
1306
+ * rule below would veto it, and the danger would never show itself. The one that
1307
+ * bites is a wanted `Analysis` against `00 - Migration Analysis`: a single hit,
1308
+ * therefore silently relocated, therefore wrong, and uniqueness cannot save you
1309
+ * because there is nothing ambiguous about it.
1310
+ *
1311
+ * That is a failure mode this file's own safety rule would have HIDDEN rather than
1312
+ * caught. Only a leading run of digits with optional separator comes off.
1313
+ */
1314
+ function stripOrderingPrefix(name) {
1315
+ return name.replace(/^\d+\s*[-._)]?\s*/, "");
1316
+ }
1317
+ /**
1318
+ * The names under which a directory can be recognised.
1319
+ *
1320
+ * Both spellings are offered from both sides, so the prefix can have been added
1321
+ * OR removed since the path was registered.
1322
+ */
1323
+ function matchKeys(name) {
1324
+ const keys = /* @__PURE__ */ new Set();
1325
+ const bare = norm(name);
1326
+ if (bare) keys.add(bare);
1327
+ const stripped = norm(stripOrderingPrefix(name));
1328
+ if (stripped) keys.add(stripped);
1329
+ return keys;
1330
+ }
1331
+ function shareAKey(a, b) {
1332
+ for (const k of a) if (b.has(k)) return true;
1333
+ return false;
1334
+ }
1335
+ /**
1336
+ * Resolve symlinks so two spellings of one directory compare equal.
1337
+ *
1338
+ * Falls back to the input when it cannot resolve — a path that does not exist has
1339
+ * no real path, and for the duplicate check below "unresolvable" must not
1340
+ * accidentally equal some other unresolvable path.
1341
+ */
1342
+ function realOrSelf(path) {
1343
+ try {
1344
+ return realpathSync(path);
1345
+ } catch {
1346
+ return path;
1347
+ }
1348
+ }
1349
+ function isDir(path) {
1350
+ try {
1351
+ return statSync(path).isDirectory();
1352
+ } catch {
1353
+ return false;
1354
+ }
1355
+ }
1356
+ /**
1357
+ * Where a project went when a directory above it was renamed.
1358
+ *
1359
+ * Returns undefined rather than a guess whenever it cannot be certain — see the
1360
+ * uniqueness rule inside. undefined means "still dead as far as this can tell",
1361
+ * which leaves the entry exactly as it was.
1362
+ */
1363
+ function relocateRenamedAncestor(rootPath) {
1364
+ if (existsSync(rootPath)) return void 0;
1365
+ const segments = rootPath.split(sep).filter((s) => s.length > 0);
1366
+ if (segments.length === 0) return void 0;
1367
+ let current = rootPath.startsWith(sep) ? sep : "";
1368
+ let i = 0;
1369
+ for (; i < segments.length; i++) {
1370
+ const next = join(current, segments[i]);
1371
+ if (!existsSync(next)) break;
1372
+ current = next;
1373
+ }
1374
+ if (i === segments.length) return void 0;
1375
+ for (; i < segments.length; i++) {
1376
+ const wanted = matchKeys(segments[i]);
1377
+ if (wanted.size === 0) return void 0;
1378
+ let children;
1379
+ try {
1380
+ children = readdirSync(current);
1381
+ } catch {
1382
+ return;
1383
+ }
1384
+ const hits = children.filter((c) => isDir(join(current, c)) && shareAKey(matchKeys(c), wanted) && isHidden(c) === isHidden(segments[i]));
1385
+ if (hits.length !== 1) return void 0;
1386
+ current = join(current, hits[0]);
1387
+ }
1388
+ return current;
1389
+ }
1390
+ /**
1391
+ * The pre-existing guess: the project's leaf name in one of a few usual places.
1392
+ *
1393
+ * Kept, and kept SECOND, because it answers a different question — "the project
1394
+ * itself was moved somewhere I know about" rather than "an ancestor was renamed".
1395
+ * It is the weaker of the two: matching on basename alone can point at an
1396
+ * unrelated directory that happens to share a name, so it only gets to answer
1397
+ * when the ancestor walk has declined.
1398
+ */
1399
+ function suggestByBasename(rootPath) {
1400
+ const name = basename(rootPath);
1401
+ const candidates = [
1402
+ join(homedir(), "dev", name),
1403
+ join(homedir(), "dev", "ai", name),
1404
+ join(homedir(), "Desktop", name),
1405
+ join(homedir(), "Projects", name)
1406
+ ];
1407
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
1408
+ }
1409
+ /**
1410
+ * Where a missing project probably is, or undefined.
1411
+ *
1412
+ * Feeds the health check's `suggestedPath ? "stale" : "dead"` classification, so
1413
+ * an answer here moves an entry out of the dead list and into the one `--fix`
1414
+ * repairs.
1415
+ *
1416
+ * `otherRoots` is every OTHER registered project's root path, and it is what
1417
+ * stops a repair from becoming a duplicate. Caught by the AIBroker session on the
1418
+ * first version of this: `~/PAI` was "recovered" to `~/dev/ai/PAI`, which is a
1419
+ * second spelling of `~/Daten/Cloud/Development/ai/PAI` — a path an ACTIVE project
1420
+ * already owns. Two registry entries for one directory is the exact mess that was
1421
+ * merged out of this registry earlier the same day.
1422
+ *
1423
+ * The comparison must be by realpath, not string. String equality is precisely
1424
+ * what missed it: the two paths share not one character after `/Users/i052341/`.
1425
+ */
1426
+ function suggestMovedPath(rootPath, otherRoots = []) {
1427
+ const candidate = relocateRenamedAncestor(rootPath) ?? suggestByBasename(rootPath);
1428
+ if (!candidate) return void 0;
1429
+ if (new Set(otherRoots.map(realOrSelf)).has(realOrSelf(candidate))) return void 0;
1430
+ return candidate;
1431
+ }
1432
+
1173
1433
  //#endregion
1174
1434
  //#region src/cli/commands/project/health.ts
1175
1435
  function findOrphanedNotesDirs(project) {
@@ -1193,15 +1453,66 @@ function findOrphanedNotesDirs(project) {
1193
1453
  } catch {}
1194
1454
  return results;
1195
1455
  }
1196
- function suggestMovedPath(project) {
1197
- const name = basename(project.root_path);
1198
- const candidates = [
1199
- join(homedir(), "dev", name),
1200
- join(homedir(), "dev", "ai", name),
1201
- join(homedir(), "Desktop", name),
1202
- join(homedir(), "Projects", name)
1203
- ];
1204
- for (const candidate of candidates) if (existsSync(candidate)) return candidate;
1456
+ /**
1457
+ * Why a row is unhealthy, and what to do about it.
1458
+ *
1459
+ * `category` said only dead / stale / active, and "archive" was offered as the
1460
+ * remedy for everything. Four different situations were collapsed into that, and
1461
+ * archiving is right for exactly one of them:
1462
+ *
1463
+ * EPHEMERAL the path is a worktree or a temp dir — it should never have been
1464
+ * registered. Checked FIRST, deliberately: a temp path whose
1465
+ * directory has also vanished is both ephemeral and dead, and
1466
+ * "should never have been registered" is the stronger and more
1467
+ * actionable statement. Without a stated precedence the same row
1468
+ * gets different labels depending on evaluation order.
1469
+ * DUPLICATE the path is gone and another project owns where it went. Its
1470
+ * sessions are the only thing of value on it, so the remedy is
1471
+ * merge — archiving strands them.
1472
+ * MISNAMED the path EXISTS and another project owns a subtree of it, under a
1473
+ * slug that has nothing to do with it. `pferde` on `08 - Others/MDF`.
1474
+ * health never reported this at all, because existsSync says yes.
1475
+ * DEAD the path is gone and nothing claims it. Archive is correct here.
1476
+ *
1477
+ * The action must never name a command that destroys a row holding sessions —
1478
+ * a wrong command in an action field reads as vetted.
1479
+ */
1480
+ function diagnose(project, pathExists, others) {
1481
+ const ephemeral = unregistrableReason(project.root_path);
1482
+ if (ephemeral) return {
1483
+ reason: "ephemeral",
1484
+ action: project.session_count > 0 ? `holds ${project.session_count} session(s) — pai project merge ${project.slug} <durable-project> --execute, then unregister` : `pai project unregister ${project.slug} --execute (${ephemeral})`
1485
+ };
1486
+ if (pathExists) {
1487
+ const nested = others.find((o) => o.status === "active" && o.root_path.startsWith(project.root_path + "/") && existsSync(o.root_path));
1488
+ if (nested && project.status !== "active") return {
1489
+ reason: "misnamed",
1490
+ owner: nested.slug,
1491
+ action: `live directory, but ${nested.slug} owns a subtree of it — rename, or pai project merge ${project.slug} ${nested.slug}`
1492
+ };
1493
+ return {};
1494
+ }
1495
+ const suggestion = suggestMovedPath(project.root_path, []);
1496
+ if (suggestion) {
1497
+ const owner = others.find((o) => o.root_path === suggestion || realpathEq(o.root_path, suggestion));
1498
+ if (owner) return {
1499
+ reason: "duplicate",
1500
+ owner: owner.slug,
1501
+ action: project.session_count > 0 ? `pai project merge ${project.slug} ${owner.slug} (moves ${project.session_count} session(s))` : `pai project merge ${project.slug} ${owner.slug} (no sessions — a plain drop)`
1502
+ };
1503
+ }
1504
+ return {
1505
+ reason: "dead",
1506
+ action: project.session_count > 0 ? `holds ${project.session_count} session(s) — merge before archiving, or they become unreachable` : `pai project archive ${project.slug}`
1507
+ };
1508
+ }
1509
+ /** Same directory under two spellings — symlinks, not string equality. */
1510
+ function realpathEq(a, b) {
1511
+ try {
1512
+ return realpathSync(a) === realpathSync(b);
1513
+ } catch {
1514
+ return false;
1515
+ }
1205
1516
  }
1206
1517
  function cmdHealth$1(db, opts) {
1207
1518
  const rows = db.prepare(`SELECT p.*,
@@ -1211,19 +1522,24 @@ function cmdHealth$1(db, opts) {
1211
1522
  const results = rows.map((project) => {
1212
1523
  const pathExists = existsSync(project.root_path);
1213
1524
  const orphaned = findOrphanedNotesDirs(project);
1525
+ const others = rows.filter((r) => r.id !== project.id);
1214
1526
  let category;
1215
1527
  let suggestedPath;
1216
1528
  if (pathExists) category = "active";
1217
1529
  else {
1218
- suggestedPath = suggestMovedPath(project);
1530
+ suggestedPath = suggestMovedPath(project.root_path, others.map((r) => r.root_path));
1219
1531
  category = suggestedPath ? "stale" : "dead";
1220
1532
  }
1533
+ const { reason, owner, action } = diagnose(project, pathExists, others);
1221
1534
  return {
1222
1535
  project,
1223
1536
  category,
1224
1537
  suggestedPath,
1225
1538
  claudeNotesExists: orphaned.length > 0,
1226
- orphanedNotesDirs: orphaned
1539
+ orphanedNotesDirs: orphaned,
1540
+ reason,
1541
+ owner,
1542
+ action
1227
1543
  };
1228
1544
  });
1229
1545
  const filtered = opts.status ? results.filter((r) => r.category === opts.status) : results;
@@ -1236,7 +1552,10 @@ function cmdHealth$1(db, opts) {
1236
1552
  session_count: r.project.session_count,
1237
1553
  suggested_path: r.suggestedPath ?? null,
1238
1554
  claude_notes_exists: r.claudeNotesExists,
1239
- orphaned_notes_dirs: r.orphanedNotesDirs
1555
+ orphaned_notes_dirs: r.orphanedNotesDirs,
1556
+ reason: r.reason ?? null,
1557
+ owner: r.owner ?? null,
1558
+ action: r.action ?? null
1240
1559
  })), null, 2));
1241
1560
  return;
1242
1561
  }
@@ -1281,24 +1600,179 @@ function cmdHealth$1(db, opts) {
1281
1600
  console.log();
1282
1601
  }
1283
1602
  if (dead.length) {
1284
- console.log(err(" Dead projects (path missing, no match found):"));
1603
+ console.log(err(" Unreachable projects (path missing):"));
1285
1604
  for (const r of dead) {
1286
- console.log(` ${bold$1(r.project.slug)} ${dim$1(r.project.root_path)}`);
1605
+ const label = r.reason && r.reason !== "dead" ? warn$1(` [${r.reason}]`) : "";
1606
+ console.log(` ${bold$1(r.project.slug)} ${dim$1(r.project.root_path)}${label}`);
1287
1607
  if (r.claudeNotesExists) console.log(chalk.yellow(` Notes: ${r.orphanedNotesDirs.join(", ")}`));
1288
- if (r.project.session_count === 0 && opts.fix) {
1608
+ if (r.project.session_count === 0 && opts.fix && r.reason === "dead") {
1289
1609
  db.prepare("UPDATE projects SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), r.project.id);
1290
- console.log(ok$1(" Auto-fixed: archived (0 sessions, path gone)"));
1291
- } else console.log(dim$1(` Fix: pai project archive ${r.project.slug} (or pai project move ...)`));
1610
+ console.log(ok$1(" Auto-fixed: archived (0 sessions, path gone, nothing claims it)"));
1611
+ } else if (r.action) console.log(dim$1(` Do: ${r.action}`));
1292
1612
  }
1293
1613
  console.log();
1294
1614
  }
1295
- console.log(dim$1(` ${rows.length} total: ${active.length} active, ${stale.length} stale, ${dead.length} dead`));
1615
+ const archivedButPresent = results.filter((r) => r.category === "active" && r.project.status !== "active").length;
1616
+ console.log(dim$1(` ${rows.length} total: ${active.length} with the path present, ${stale.length} stale, ${dead.length} dead`));
1617
+ if (archivedButPresent > 0) console.log(dim$1(` ${archivedButPresent} of those ${active.length} are archived in the registry — "present" here is about the directory, not the registry status.`));
1296
1618
  if (!opts.fix && (stale.length > 0 || dead.length > 0)) {
1297
1619
  console.log();
1298
1620
  console.log(warn$1(" Run with --fix to auto-remediate where possible."));
1299
1621
  }
1300
1622
  }
1301
1623
 
1624
+ //#endregion
1625
+ //#region src/registry/merge.ts
1626
+ var MergeError = class extends Error {};
1627
+ /**
1628
+ * What a merge would do, without doing it.
1629
+ *
1630
+ * Built as a plan first because the session renumbering is the part a reader will
1631
+ * not predict, and printing "0001 -> 0016" is the difference between a command
1632
+ * that can be trusted and one that has to be taken on faith.
1633
+ */
1634
+ function planMerge(db, fromSlug, intoSlug) {
1635
+ if (fromSlug === intoSlug) throw new MergeError(`Cannot merge ${fromSlug} into itself.`);
1636
+ const from = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(fromSlug);
1637
+ const into = db.prepare("SELECT id, slug FROM projects WHERE slug = ?").get(intoSlug);
1638
+ if (!from) throw new MergeError(`No project with slug "${fromSlug}".`);
1639
+ if (!into) throw new MergeError(`No project with slug "${intoSlug}".`);
1640
+ let next = db.prepare("SELECT COALESCE(MAX(number), 0) AS n FROM sessions WHERE project_id = ?").get(into.id).n;
1641
+ const sessions = db.prepare("SELECT id, number FROM sessions WHERE project_id = ? ORDER BY number ASC").all(from.id).map((s) => ({
1642
+ id: s.id,
1643
+ from: s.number,
1644
+ to: ++next
1645
+ }));
1646
+ const count = (sql, ...args) => db.prepare(sql).get(...args).n;
1647
+ const aliasTaken = count("SELECT COUNT(*) AS n FROM aliases WHERE alias = ?", from.slug) > 0 || count("SELECT COUNT(*) AS n FROM projects WHERE slug = ?", from.slug) > 1;
1648
+ return {
1649
+ fromId: from.id,
1650
+ fromSlug: from.slug,
1651
+ intoId: into.id,
1652
+ intoSlug: into.slug,
1653
+ sessions,
1654
+ tags: count("SELECT COUNT(*) AS n FROM project_tags WHERE project_id = ?", from.id),
1655
+ aliases: count("SELECT COUNT(*) AS n FROM aliases WHERE project_id = ?", from.id),
1656
+ compactions: count("SELECT COUNT(*) AS n FROM compaction_log WHERE project_id = ?", from.id),
1657
+ links: count("SELECT COUNT(*) AS n FROM links WHERE target_project_id = ?", from.id),
1658
+ aliasToAdd: aliasTaken ? void 0 : from.slug
1659
+ };
1660
+ }
1661
+ /**
1662
+ * Apply a plan. One transaction: either the whole row is folded in or nothing is.
1663
+ *
1664
+ * A half-merged project is the worst outcome available here — sessions moved but
1665
+ * the row still present, or the row gone and its tags orphaned — and with foreign
1666
+ * keys off nothing would complain.
1667
+ */
1668
+ function applyMerge(db, plan) {
1669
+ db.transaction(() => {
1670
+ const move = db.prepare("UPDATE sessions SET project_id = ?, number = ? WHERE id = ?");
1671
+ for (const s of plan.sessions) move.run(plan.intoId, s.to, s.id);
1672
+ db.prepare(`INSERT OR IGNORE INTO project_tags (project_id, tag_id)
1673
+ SELECT ?, tag_id FROM project_tags WHERE project_id = ?`).run(plan.intoId, plan.fromId);
1674
+ db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(plan.fromId);
1675
+ db.prepare("UPDATE aliases SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
1676
+ db.prepare("UPDATE compaction_log SET project_id = ? WHERE project_id = ?").run(plan.intoId, plan.fromId);
1677
+ db.prepare(`UPDATE OR IGNORE links SET target_project_id = ? WHERE target_project_id = ?`).run(plan.intoId, plan.fromId);
1678
+ db.prepare("DELETE FROM links WHERE target_project_id = ?").run(plan.fromId);
1679
+ db.prepare(`DELETE FROM links WHERE target_project_id = ?
1680
+ AND session_id IN (SELECT id FROM sessions WHERE project_id = ?)`).run(plan.intoId, plan.intoId);
1681
+ if (plan.aliasToAdd) db.prepare("INSERT OR IGNORE INTO aliases (alias, project_id) VALUES (?, ?)").run(plan.aliasToAdd, plan.intoId);
1682
+ db.prepare("DELETE FROM projects WHERE id = ?").run(plan.fromId);
1683
+ })();
1684
+ }
1685
+
1686
+ //#endregion
1687
+ //#region src/cli/commands/project/merge.ts
1688
+ function cmdMerge(db, fromSlug, intoSlug, opts = {}) {
1689
+ let plan;
1690
+ try {
1691
+ plan = planMerge(db, fromSlug, intoSlug);
1692
+ } catch (e) {
1693
+ if (e instanceof MergeError) {
1694
+ console.error(err(` ${e.message}`));
1695
+ process.exit(1);
1696
+ }
1697
+ throw e;
1698
+ }
1699
+ const path = (id) => {
1700
+ const row = db.prepare("SELECT root_path FROM projects WHERE id = ?").get(id);
1701
+ return row ? shortenPath(row.root_path, 54) : "(unknown)";
1702
+ };
1703
+ console.log();
1704
+ console.log(bold$1(` Merge ${plan.fromSlug} into ${plan.intoSlug}`));
1705
+ console.log();
1706
+ console.log(dim$1(` from ${plan.fromSlug.padEnd(22)} ${path(plan.fromId)}`));
1707
+ console.log(dim$1(` into ${plan.intoSlug.padEnd(22)} ${path(plan.intoId)}`));
1708
+ console.log();
1709
+ if (plan.sessions.length > 0) {
1710
+ console.log(` ${plan.sessions.length} session(s) move and are renumbered:`);
1711
+ for (const s of plan.sessions) console.log(dim$1(` ${String(s.from).padStart(4)} -> ${String(s.to).padStart(4)}`));
1712
+ } else console.log(dim$1(` No sessions to move.`));
1713
+ const extras = [];
1714
+ if (plan.tags) extras.push(`${plan.tags} tag(s)`);
1715
+ if (plan.aliases) extras.push(`${plan.aliases} existing alias(es)`);
1716
+ if (plan.compactions) extras.push(`${plan.compactions} compaction record(s)`);
1717
+ if (plan.links) extras.push(`${plan.links} inbound link(s)`);
1718
+ if (extras.length > 0) console.log(dim$1(` Also repointed: ${extras.join(", ")}.`));
1719
+ if (plan.aliasToAdd) console.log(dim$1(` "${plan.aliasToAdd}" is kept as an alias, so the old name still resolves.`));
1720
+ else console.log(warn$1(` "${plan.fromSlug}" cannot be kept as an alias — the name is already taken.`));
1721
+ console.log(dim$1(` Then the ${plan.fromSlug} row is deleted.`));
1722
+ console.log();
1723
+ if (!opts.execute) {
1724
+ console.log(dim$1(" Preview — nothing was changed. Re-run with --execute to merge."));
1725
+ console.log();
1726
+ return;
1727
+ }
1728
+ applyMerge(db, plan);
1729
+ console.log(ok$1(` Merged. ${plan.sessions.length} session(s) now belong to ${plan.intoSlug}.`));
1730
+ console.log();
1731
+ }
1732
+
1733
+ //#endregion
1734
+ //#region src/cli/commands/project/unregister.ts
1735
+ function cmdUnregister(db, slug, opts = {}) {
1736
+ const row = db.prepare(`SELECT p.id, p.slug, p.root_path, p.status,
1737
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
1738
+ FROM projects p WHERE p.slug = ?`).get(slug);
1739
+ if (!row) {
1740
+ console.error(err(` No project with slug "${slug}".`));
1741
+ process.exit(1);
1742
+ }
1743
+ console.log();
1744
+ console.log(bold$1(` Unregister ${row.slug}`));
1745
+ console.log(dim$1(` ${row.root_path}`));
1746
+ console.log(dim$1(` status ${row.status}, ${row.session_count} session(s)`));
1747
+ console.log();
1748
+ if (row.session_count > 0 && !opts.force) {
1749
+ console.log(warn$1(` Refusing: ${row.session_count} session(s) would be stranded.`));
1750
+ console.log(dim$1(` Those sessions are the only thing of value on this row. Move them first:`));
1751
+ console.log(dim$1(` pai project merge ${row.slug} <into> --execute`));
1752
+ console.log(dim$1(` Or pass --force if the sessions are genuinely worthless.`));
1753
+ console.log();
1754
+ process.exit(1);
1755
+ }
1756
+ if (!opts.execute) {
1757
+ console.log(dim$1(" Preview — nothing was changed. Re-run with --execute to unregister."));
1758
+ if (row.session_count > 0) console.log(warn$1(` --force is set: ${row.session_count} session(s) WILL be deleted.`));
1759
+ console.log();
1760
+ return;
1761
+ }
1762
+ db.transaction(() => {
1763
+ db.prepare("DELETE FROM links WHERE target_project_id = ?").run(row.id);
1764
+ db.prepare("DELETE FROM links WHERE session_id IN (SELECT id FROM sessions WHERE project_id = ?)").run(row.id);
1765
+ db.prepare("DELETE FROM compaction_log WHERE project_id = ?").run(row.id);
1766
+ db.prepare("DELETE FROM project_tags WHERE project_id = ?").run(row.id);
1767
+ db.prepare("DELETE FROM aliases WHERE project_id = ?").run(row.id);
1768
+ db.prepare("DELETE FROM sessions WHERE project_id = ?").run(row.id);
1769
+ db.prepare("DELETE FROM projects WHERE id = ?").run(row.id);
1770
+ })();
1771
+ console.log(ok$1(` Unregistered ${row.slug}.`));
1772
+ console.log(dim$1(` The directory itself was not touched.`));
1773
+ console.log();
1774
+ }
1775
+
1302
1776
  //#endregion
1303
1777
  //#region src/cli/commands/project/projects-index.ts
1304
1778
  function registerProjectsCommands(projectsCmd, getDb) {
@@ -1351,6 +1825,12 @@ function registerProjectsCommands(projectsCmd, getDb) {
1351
1825
  projectsCmd.command("archive <slug>").description("Archive a project").action((slug) => {
1352
1826
  cmdArchive(getDb(), slug);
1353
1827
  });
1828
+ projectsCmd.command("merge <from> <into>").description("Fold a duplicate project into another: move its sessions (renumbered), repoint its tags, aliases, compaction records and links, keep the old slug as an alias, then delete the row. Preview unless --execute is given.").option("--execute", "Actually perform the merge").action((from, into, opts) => {
1829
+ cmdMerge(getDb(), from, into, opts);
1830
+ });
1831
+ projectsCmd.command("unregister <slug>").description("Remove a project row entirely, for paths that should never have been registered (worktrees, temp dirs). Refuses when the row holds sessions — merge those first. Preview unless --execute is given. The directory itself is never touched.").option("--execute", "Actually remove the row").option("--force", "Remove even though sessions would be deleted with it").action((slug, opts) => {
1832
+ cmdUnregister(getDb(), slug, opts);
1833
+ });
1354
1834
  projectsCmd.command("unarchive <slug>").description("Restore an archived project to active status").action((slug) => {
1355
1835
  cmdUnarchive(getDb(), slug);
1356
1836
  });
@@ -3004,7 +3484,7 @@ function cmdLogs(opts) {
3004
3484
  }
3005
3485
  function registerDaemonCommands(daemonCmd) {
3006
3486
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
3007
- const { serve } = await import("./daemon-iNuNMMKd.mjs").then((n) => n.t);
3487
+ const { serve } = await import("./daemon-1aYEoEQR.mjs").then((n) => n.t);
3008
3488
  const { loadConfig: lc, ensureConfigDir } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
3009
3489
  ensureConfigDir();
3010
3490
  await serve(lc());
@@ -10768,7 +11248,7 @@ function cmdActive(db, opts) {
10768
11248
  async function cmdAutoRoute(opts) {
10769
11249
  const { autoRoute, formatAutoRoute, formatAutoRouteJson } = await import("./auto-route-BWGvvpcP.mjs");
10770
11250
  const { openRegistry } = await import("./db-BtuN768f.mjs").then((n) => n.t);
10771
- const { createStorageBackend } = await import("./factory-BydJSrZJ.mjs").then((n) => n.n);
11251
+ const { createStorageBackend } = await import("./factory-Bba0CG2s.mjs").then((n) => n.n);
10772
11252
  const { loadConfig } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
10773
11253
  const config = loadConfig();
10774
11254
  const registryDb = openRegistry();
@@ -12277,7 +12757,7 @@ async function countVectorDbPaths(oldPaths) {
12277
12757
  if (oldPaths.length === 0) return 0;
12278
12758
  try {
12279
12759
  const { loadConfig } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
12280
- const { PostgresBackend } = await import("./postgres-DTyxU4B1.mjs");
12760
+ const { PostgresBackend } = await import("./postgres-5IpjMwo_.mjs");
12281
12761
  const config = loadConfig();
12282
12762
  if (config.storageBackend !== "postgres") return 0;
12283
12763
  const pgBackend = new PostgresBackend(config.postgres ?? {});
@@ -12298,7 +12778,7 @@ async function updateVectorDbPaths(moves) {
12298
12778
  if (moves.length === 0) return 0;
12299
12779
  try {
12300
12780
  const { loadConfig } = await import("./config-CcdkNSWa.mjs").then((n) => n.r);
12301
- const { PostgresBackend } = await import("./postgres-DTyxU4B1.mjs");
12781
+ const { PostgresBackend } = await import("./postgres-5IpjMwo_.mjs");
12302
12782
  const config = loadConfig();
12303
12783
  if (config.storageBackend !== "postgres") return 0;
12304
12784
  const pgBackend = new PostgresBackend(config.postgres ?? {});
@@ -13581,4 +14061,4 @@ async function cmdPick(db, opts = {}) {
13581
14061
 
13582
14062
  //#endregion
13583
14063
  export { registerProjectsCommands as A, registerRestoreCommands as C, registerIdentityCommands as D, registerMcpCommands as E, resolveIdentifier as M, registerMemoryCommands 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, findMovedPath as j, registerRegistryCommands 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 };
13584
- //# sourceMappingURL=pick-Bmz718uo.mjs.map
14064
+ //# sourceMappingURL=pick-B7UFLePe.mjs.map