eklavya 1.25.0 → 1.25.2

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.
Files changed (82) hide show
  1. package/dist/assets/dashboard.html +1 -1
  2. package/dist/assets/tutor/references/focus-and-level.md +7 -0
  3. package/dist/claude-mem.js +9 -7
  4. package/dist/claude-mem.js.map +1 -1
  5. package/dist/cli-memory.js +786 -0
  6. package/dist/cli-memory.js.map +1 -0
  7. package/dist/cli.js +95 -773
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.js +79 -13
  10. package/dist/config.js.map +1 -1
  11. package/dist/dashboard.js +36 -14
  12. package/dist/dashboard.js.map +1 -1
  13. package/dist/db.js +33 -6
  14. package/dist/db.js.map +1 -1
  15. package/dist/hooks/capture-lib.js +55 -0
  16. package/dist/hooks/capture-lib.js.map +1 -0
  17. package/dist/hooks/capture-tool.js +1 -1
  18. package/dist/hooks/capture-tool.js.map +1 -1
  19. package/dist/hooks/commit-lib.js +255 -0
  20. package/dist/hooks/commit-lib.js.map +1 -0
  21. package/dist/hooks/lib.js +37 -5
  22. package/dist/hooks/lib.js.map +1 -1
  23. package/dist/hooks/memory-lib.js +93 -78
  24. package/dist/hooks/memory-lib.js.map +1 -1
  25. package/dist/hooks/pre-tool-gate.js +5 -10
  26. package/dist/hooks/pre-tool-gate.js.map +1 -1
  27. package/dist/hooks/prompt-submit-nudge.js +26 -1
  28. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  29. package/dist/hooks/session-start.js +13 -4
  30. package/dist/hooks/session-start.js.map +1 -1
  31. package/dist/install.js +280 -67
  32. package/dist/install.js.map +1 -1
  33. package/dist/memory/capture.js +25 -5
  34. package/dist/memory/capture.js.map +1 -1
  35. package/dist/memory/privacy.js +105 -7
  36. package/dist/memory/privacy.js.map +1 -1
  37. package/dist/memory/provider.js +13 -3
  38. package/dist/memory/provider.js.map +1 -1
  39. package/dist/memory/recall.js +18 -6
  40. package/dist/memory/recall.js.map +1 -1
  41. package/dist/memory/spool.js +105 -23
  42. package/dist/memory/spool.js.map +1 -1
  43. package/dist/memory/store.js +32 -2
  44. package/dist/memory/store.js.map +1 -1
  45. package/dist/memory/summarize.js +5 -5
  46. package/dist/memory/summarize.js.map +1 -1
  47. package/dist/memory/worker.js +71 -10
  48. package/dist/memory/worker.js.map +1 -1
  49. package/dist/migrate.js +60 -14
  50. package/dist/migrate.js.map +1 -1
  51. package/dist/paths.js +49 -0
  52. package/dist/paths.js.map +1 -1
  53. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  54. package/dist/plugin/cli/CLAUDE.md +16 -4
  55. package/dist/plugin/hooks/CLAUDE.md +33 -3
  56. package/dist/plugin/hooks/run.mjs +69 -5
  57. package/dist/plugin/scripts/install-git-hook.sh +114 -24
  58. package/dist/plugin/skills/CLAUDE.md +1 -1
  59. package/dist/plugin/skills/setup/SKILL.md +9 -2
  60. package/dist/plugin/skills/tutor/references/focus-and-level.md +7 -0
  61. package/dist/safe-write.js +146 -0
  62. package/dist/safe-write.js.map +1 -0
  63. package/dist/slug.js +4 -1
  64. package/dist/slug.js.map +1 -1
  65. package/dist/srs.js +33 -1
  66. package/dist/srs.js.map +1 -1
  67. package/dist/store.js +29 -20
  68. package/dist/store.js.map +1 -1
  69. package/dist/tools/config_tools.js +23 -1
  70. package/dist/tools/config_tools.js.map +1 -1
  71. package/dist/tools/get_session_quiz_plan.js +51 -30
  72. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  73. package/dist/tools/log_session_concepts.js +30 -23
  74. package/dist/tools/log_session_concepts.js.map +1 -1
  75. package/dist/tools/record_attempt.js +18 -8
  76. package/dist/tools/record_attempt.js.map +1 -1
  77. package/dist/tools/types.js +29 -0
  78. package/dist/tools/types.js.map +1 -1
  79. package/dist/tools/upsert_concepts.js +12 -10
  80. package/dist/tools/upsert_concepts.js.map +1 -1
  81. package/dist/user-skill/eklavya/SKILL.md +26 -10
  82. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7,38 +7,32 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { openDb } from './db.js';
11
10
  import { dbPath, eklavyaHome } from './paths.js';
12
11
  import { readStdinBounded, stripBom, STATUSLINE_STDIN } from './stdin.js';
13
- import { loadConfig, writeConfigFile, readConfigFile, findRepoConfig, mainRepoRoot, migrateLegacyRepoConfig, } from './config.js';
12
+ import { loadConfig, writeConfigFile, readConfigFile, configFileProblem, findRepoConfig, mainRepoRoot, migrateLegacyRepoConfig, } from './config.js';
14
13
  import { isKnownKey, knownKeys, parseValue, patchFor } from './config-path.js';
15
- import { loadPacks, applyPacks } from './packs.js';
16
- import { levelStanding, projectKey } from './store.js';
14
+ import { levelStanding } from './store.js';
17
15
  import { statusLine } from './statusline.js';
18
16
  import { isSessionOff } from './session.js';
19
17
  import { START_LEVEL } from './srs.js';
20
18
  import Database from 'better-sqlite3';
21
- import { startDashboard, openInBrowser } from './dashboard.js';
22
- import { install, uninstall, health, claudeHome } from './install.js';
23
- import { guessProjectMap } from './claude-mem.js';
24
- import { check, dim, heading, spin, verdict } from './theme.js';
25
- import { importOffThread } from './memory/import-worker.js';
26
- import { isInternalObserver, renewWorker, reserveWorker, stopWorker, workerStatus } from './memory/reservation.js';
27
- import { identityFor } from './memory/identity.js';
28
- import { backlogSummary, countEntries, discardBacklog, entryById, entryEvents, entryTags, pendingEventCount, quarantineBacklog, receiptTotals, restoreBacklog, resumePaused, timeline, } from './memory/store.js';
29
- import { search } from './memory/search.js';
30
- import { pull, push, syncStatus } from './memory/sync.js';
31
- import { pruneEvidence, queueDepth, summarizerFor, superviseWorker } from './memory/worker.js';
32
- import { replayProject, transcriptDirFor, transcriptsFor } from './memory/replay.js';
33
- import { droppedCount } from './memory/spool.js';
34
- import { savingsFrom, savingsLine } from './memory/tokens.js';
35
- import { inventory, exportPayload, restoreExport, EXPORT_SCHEMA_VERSION, ImportError, IMPORTED_TABLES, verifyImport, } from './memory/import.js';
19
+ import { check, dim, heading, verdict } from './theme.js';
20
+ // Everything heavier is imported inside the command that needs it: the
21
+ // database opener (migrations, seed, packs), install, the dashboard and the
22
+ // whole memory half (`cli-memory.ts`). An ES import is paid whether or not the
23
+ // command runs, and `eklavya statusline` runs on every status-bar refresh —
24
+ // loading all of that there more than doubled its start-up for nothing.
25
+ // `test/hook-isolation.test.ts` pins it.
36
26
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
37
27
  const USAGE = `eklavya — local learning state for agent-assisted development
38
28
 
39
29
  Usage:
40
30
  eklavya serve Run the MCP server on stdio (what Claude Code starts)
41
31
  eklavya install Install Eklavya into Claude Code, runtime included
32
+ --memory eklavya|claude-mem when Claude Mem is installed, pick
33
+ which one records without being asked: eklavya imports its
34
+ history and retires it; claude-mem keeps it and turns Eklavya
35
+ memory off
42
36
  eklavya uninstall [--purge] Remove it (--purge also deletes your learning history)
43
37
  eklavya export-rules [--out <file>] Write the tutor pedagogy as a Cursor rules file
44
38
  eklavya config get Show the effective configuration
@@ -72,11 +66,15 @@ Memory:
72
66
  or set aside, delete or bring back the ones you select with
73
67
  --helpers, --project <key>, --session <id> or --batch <id>
74
68
  eklavya memory prune Delete raw evidence past memory.retention_days
75
- eklavya memory import <source.db> Import a Claude Mem database [--dry-run] [--resume]
69
+ eklavya memory import <source.db> Import a Claude Mem database [--dry-run] [--verify] [--resume]
76
70
  --dry-run reads the source and reports; it writes nothing
71
+ --verify checks every source row by id is here and where each
72
+ project was filed; writes nothing, exits 1 if any are missing
77
73
  --map <source>=<path> file that source project under a checkout
78
74
  --map-here <source> the same, for the checkout you are in
79
- eklavya memory export <file> Versioned JSON of entries, tags, evidence links and receipts
75
+ eklavya memory export <file> [--force]
76
+ Versioned JSON of entries, tags, evidence links and receipts,
77
+ readable only by you. Refuses to replace a file without --force
80
78
  eklavya memory restore <file> Read that file back in. Additive and idempotent — a second
81
79
  restore adds nothing, and no attempt, mastery or gate row is
82
80
  touched. Refuses a schema version it does not understand
@@ -247,6 +245,14 @@ function configCommand(args) {
247
245
  process.stdout.write(`${JSON.stringify(resolved.config, null, 2)}\n`);
248
246
  process.stdout.write(`\nglobal: ${resolved.globalPath}\n`);
249
247
  process.stdout.write(`project: ${resolved.projectPath ?? '(none — not in a git repository)'}\n`);
248
+ if (resolved.ignored.length) {
249
+ process.stdout.write(`ignored in the project file (global-only, set them without --project): ${resolved.ignored.join(', ')}\n`);
250
+ }
251
+ // Defaults are standing in for a file that would not parse; say so, or the
252
+ // output above reads as the developer's settings when it is not.
253
+ const problem = configFileProblem();
254
+ if (problem)
255
+ process.stderr.write(`warning: ${problem}\n`);
250
256
  return;
251
257
  }
252
258
  if (action !== 'set')
@@ -319,7 +325,18 @@ function safely(fn, fallback) {
319
325
  return fallback;
320
326
  }
321
327
  }
322
- function doctor() {
328
+ async function doctor() {
329
+ // Loaded here rather than at the top of the file: see the note on the imports.
330
+ const [{ openDb }, { applyPacks, loadPacks }, { health, commandOnPath, eklavyaGateHook }, { countEntries, pendingEventCount }, { queueDepth }, { syncStatus }, { droppedCount }, { workerLine },] = await Promise.all([
331
+ import('./db.js'),
332
+ import('./packs.js'),
333
+ import('./install.js'),
334
+ import('./memory/store.js'),
335
+ import('./memory/worker.js'),
336
+ import('./memory/sync.js'),
337
+ import('./memory/spool.js'),
338
+ import('./cli-memory.js'),
339
+ ]);
323
340
  const file = dbPath();
324
341
  // `doctor` is where somebody goes when something is not taking effect, so it
325
342
  // is the second place the legacy move runs -- a settings file still sitting
@@ -335,6 +352,9 @@ function doctor() {
335
352
  // install` repairs a broken install and cannot do a thing about a paused
336
353
  // queue. A memory failure still exits non-zero; it just names its own fix.
337
354
  let memoryOk = true;
355
+ // Its own flag for the same reason: `eklavya install` does not put jq or
356
+ // sqlite3 on anybody's PATH, and a config file only its owner can fix.
357
+ let fixByHand = true;
338
358
  add('ok', 'home', eklavyaHome());
339
359
  // The install checks come first because they are what someone is looking for
340
360
  // when Eklavya has gone quiet. Everything below reads fine on an install that
@@ -354,7 +374,11 @@ function doctor() {
354
374
  add('ok', 'surfaces', 'Claude Code CLI and the Code tab in Claude Desktop (checked above)');
355
375
  add('skip', 'cowork', dim('keeps a separate plugin list — install it there from Customize → Plugins;'));
356
376
  add('skip', 'cowork', dim('this command cannot see it. Your learning history is shared either way.'));
357
- add(fs.existsSync(file) ? 'ok' : 'warn', 'database', `${file}${fs.existsSync(file) ? '' : dim(' (not created yet)')}`);
377
+ // `openDb` below creates a missing file, so the row says what happened, not
378
+ // what was true a moment before it happened.
379
+ const dbExisted = fs.existsSync(file);
380
+ const dbRow = rows.length;
381
+ add(dbExisted ? 'ok' : 'warn', 'database', `${file}${dbExisted ? '' : dim(' (does not exist)')}`);
358
382
  let edgesDropped = 0;
359
383
  // Held open past the try so the memory section below can read it, and can
360
384
  // still report when it is null -- the spool drop count is exactly the number
@@ -367,6 +391,8 @@ function doctor() {
367
391
  // recovery for the edit a fingerprint cannot see (a same-size write that
368
392
  // preserves the mtime). `doctor` is where someone goes when a pack is not
369
393
  // taking effect, so `doctor` is what makes it take effect.
394
+ if (!dbExisted)
395
+ rows[dbRow] = ['ok', 'database', `${file} ${dim('(created now — it did not exist)')}`];
370
396
  edgesDropped = applyPacks(db).edgesDropped;
371
397
  const concepts = db.prepare('SELECT count(*) n FROM concepts').get().n;
372
398
  const attempts = db.prepare('SELECT count(*) n FROM attempts').get().n;
@@ -475,6 +501,30 @@ function doctor() {
475
501
  }
476
502
  if (resolved.projectPath)
477
503
  add('ok', 'project', resolved.projectPath);
504
+ // A settings file that stopped parsing is read as absent, so every dial
505
+ // above is a default wearing the developer's name. Only `doctor` says so.
506
+ const configProblem = safely(() => configFileProblem(), null);
507
+ if (configProblem) {
508
+ fixByHand = false;
509
+ add('fail', 'config', `FAILED — ${configProblem}`);
510
+ }
511
+ // The terminal gate (`cli/eklavya-gate`) shells out to jq and sqlite3 and
512
+ // fails OPEN without either: every commit goes through, and the only trace
513
+ // is one stderr line per commit. Installed here, that is a gate that is not
514
+ // gating; enforced with no terminal hook, it is a warning for later.
515
+ const gateHook = safely(() => eklavyaGateHook(), null);
516
+ if (gateHook)
517
+ add('ok', 'git hook', gateHook);
518
+ if (gateHook || q.enforced) {
519
+ const missing = ['jq', 'sqlite3'].filter((tool) => !commandOnPath(tool));
520
+ if (missing.length && gateHook) {
521
+ fixByHand = false;
522
+ add('fail', 'gate', `FAILED — ${missing.join(' and ')} not on PATH; the commit gate lets every commit through`);
523
+ }
524
+ else if (missing.length) {
525
+ add('warn', 'gate', `${missing.join(' and ')} not on PATH ${dim('— the terminal commit gate needs them if you install it')}`);
526
+ }
527
+ }
478
528
  // Packs, and the one place a broken one is visible. `loadPacks` never throws
479
529
  // -- a malformed file in ~/.eklavya/packs/ makes one pack unavailable, not
480
530
  // Eklavya -- so without this line the failure is a domain that quietly never
@@ -520,8 +570,14 @@ function doctor() {
520
570
  const same = prev?.[1] === label;
521
571
  check(same && prev[0] === mark ? null : mark, same ? '' : label, detail);
522
572
  });
523
- verdict(!ok ? 'Something is broken. Run: eklavya install' : !memoryOk ? 'Memory needs attention — see above' : null, 'ALL CLEAR · Eklavya is wired up');
524
- if (!ok || !memoryOk)
573
+ verdict(!ok
574
+ ? 'Something is broken. Run: eklavya install'
575
+ : !memoryOk
576
+ ? 'Memory needs attention — see above'
577
+ : !fixByHand
578
+ ? 'Needs a fix by hand — see above'
579
+ : null, 'ALL CLEAR · Eklavya is wired up');
580
+ if (!ok || !memoryOk || !fixByHand)
525
581
  process.exit(1);
526
582
  }
527
583
  /**
@@ -610,7 +666,7 @@ async function statuslineCommand(argv) {
610
666
  /* A status bar with nothing to say says nothing. */
611
667
  }
612
668
  }
613
- function dashboardCommand(argv) {
669
+ async function dashboardCommand(argv) {
614
670
  const i = argv.indexOf('--port');
615
671
  const port = i === -1 ? undefined : Number(argv[i + 1]);
616
672
  if (port !== undefined && !Number.isInteger(port)) {
@@ -621,6 +677,10 @@ function dashboardCommand(argv) {
621
677
  // dashboard you open once; `--no-open` is for a headless box, or for an agent
622
678
  // that only wants the URL to hand back.
623
679
  const open = !argv.includes('--no-open');
680
+ const [{ openDb }, { startDashboard, openInBrowser }] = await Promise.all([
681
+ import('./db.js'),
682
+ import('./dashboard.js'),
683
+ ]);
624
684
  startDashboard(openDb(), { port }).then(({ url }) => {
625
685
  process.stdout.write(`Eklavya dashboard on ${url}\nReading ${dbPath()} — press Ctrl+C to stop.\n`);
626
686
  if (open) {
@@ -632,749 +692,7 @@ function dashboardCommand(argv) {
632
692
  process.exit(1);
633
693
  });
634
694
  }
635
- /**
636
- * `eklavya memory <subcommand>` — the memory half, outside a session.
637
- *
638
- * Project-scoped by default on every read, like the retrieval layer it sits on:
639
- * another repository's work is noise, and `--all-projects` is the explicit way
640
- * to ask for it.
641
- */
642
- const MEMORY_USAGE = 'Usage: eklavya memory status|search|timeline|show|replay|process|stop|backlog|prune|import|export|restore|sync\n' +
643
- ' run `eklavya --help` for the full list\n';
644
- function flag(argv, name, fallback) {
645
- const i = argv.indexOf(name);
646
- if (i === -1)
647
- return fallback;
648
- const value = argv[i + 1];
649
- if (value === undefined || value.startsWith('--'))
650
- fail(`${name} needs a value.`);
651
- return value;
652
- }
653
- function numberFlag(argv, name, fallback) {
654
- const raw = flag(argv, name);
655
- if (raw === undefined)
656
- return fallback;
657
- const n = Number(raw);
658
- if (!Number.isFinite(n) || n <= 0)
659
- fail(`${name} needs a positive number.`);
660
- return Math.floor(n);
661
- }
662
- /** The project key the memory tables use — the same one the hooks record under. */
663
- function currentProject() {
664
- return identityFor({ cwd: process.cwd(), sessionId: 'cli' }).project;
665
- }
666
- /**
667
- * How a migration is checked: what arrived here, and what arrived under a
668
- * Claude Mem project name no checkout matches -- history that only surfaces
669
- * under --all-projects until it is placed.
670
- */
671
- function importedLines(db, project) {
672
- const rows = db
673
- .prepare(`SELECT project, COUNT(*) AS n FROM memory_entries
674
- WHERE import_source IS NOT NULL AND deleted_at IS NULL GROUP BY project`)
675
- .all();
676
- if (!rows.length)
677
- return [];
678
- const here = rows.find((r) => r.project === project)?.n ?? 0;
679
- const bare = rows.filter((r) => !path.isAbsolute(r.project));
680
- const unplaced = bare.reduce((sum, r) => sum + r.n, 0);
681
- return [
682
- `imported: ${here} here from Claude Mem`,
683
- ...(unplaced
684
- ? [
685
- `unplaced: ${unplaced} under ${bare.length} name(s) no checkout matches — ${bare.map((r) => r.project).slice(0, 5).join(', ')}${bare.length > 5 ? ', …' : ''}`,
686
- ' place them: eklavya memory import ~/.claude-mem.retired/claude-mem.db [--map <name>=<checkout>]',
687
- ]
688
- : []),
689
- ];
690
- }
691
- /** "2m 05s" — elapsed since an ISO stamp. */
692
- function since(iso, now = Date.now()) {
693
- if (!iso)
694
- return '?';
695
- const secs = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
696
- return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`;
697
- }
698
- /** The one background worker, or that there is none — for `memory status` and `doctor`. */
699
- function workerLine(db) {
700
- const w = workerStatus(db);
701
- if (!w)
702
- return 'none running';
703
- return [
704
- w.pid ? `pid ${w.pid}` : 'starting',
705
- w.started ? `up ${since(w.started)}` : null,
706
- w.generation ? `hand-off ${w.generation}` : null,
707
- w.child ? `claude pid ${w.child}` : null,
708
- w.job ? `job #${w.job} for ${since(w.jobStarted)}` : null,
709
- w.stale ? `STALE — no heartbeat for ${since(w.heartbeat)}, being stopped` : `heartbeat ${since(w.heartbeat)} ago`,
710
- ]
711
- .filter(Boolean)
712
- .join(' · ');
713
- }
714
- /**
715
- * Why the queue is paused, by class — never `last_error`, which is the
716
- * provider's own prose and has carried a URL with a token in it.
717
- */
718
- function pauseLine(db) {
719
- const rows = db
720
- .prepare(`SELECT error_class, COUNT(*) AS n, MAX(updated_at) AS at FROM memory_jobs
721
- WHERE status = 'paused' GROUP BY error_class ORDER BY at DESC`)
722
- .all();
723
- if (!rows.length)
724
- return null;
725
- const why = {
726
- auth: 'claude is not logged in',
727
- quota: 'usage limit reached',
728
- missing: 'claude is not on the PATH',
729
- };
730
- return rows
731
- .map((r) => `${r.n} on ${r.error_class ?? 'unclassified'}${why[r.error_class ?? ''] ? ` (${why[r.error_class]})` : ''}, since ${r.at}`)
732
- .join('; ');
733
- }
734
- function memoryStatus() {
735
- const db = openDb();
736
- try {
737
- const { config } = loadConfig();
738
- const project = currentProject();
739
- const queue = queueDepth(db);
740
- const totals = receiptTotals(db);
741
- const savings = savingsFrom({
742
- baseTokens: totals.base,
743
- deliveredTokens: totals.delivered,
744
- delivery: totals.confirmed > 0 ? 'confirmed' : 'unknown',
745
- });
746
- const lines = [
747
- `project: ${project}`,
748
- `capture: ${config.memory.enabled ? config.memory.capture : 'off (memory.enabled is false)'}`,
749
- `entries: ${countEntries(db, project)} here, ${countEntries(db)} in total`,
750
- ...importedLines(db, project),
751
- `pending: ${pendingEventCount(db, project)} evidence events here, ${pendingEventCount(db)} in total`,
752
- `queue: ${queue.pending} pending · ${queue.paused} paused · ${queue.failed} failed${queue.quarantined ? ` · ${queue.quarantined} quarantined` : ''}`,
753
- `oldest job: ${queue.oldest ?? '—'}`,
754
- ...(pauseLine(db) ? [`paused: ${pauseLine(db)} — fix it, then: eklavya memory process`] : []),
755
- `worker: ${workerLine(db)}`,
756
- // Named separately from the summarizer because they answer different
757
- // questions: one is "will anything leave this machine", the other is
758
- // "what is actually writing the observations right now".
759
- `provider: ${config.providers.observer
760
- ? `${config.providers.observer.kind}:${config.providers.observer.model} (via claude -p, on your subscription)`
761
- : 'none — nothing leaves this machine'}`,
762
- `summarizer: ${summarizerFor(config).id}`,
763
- `spool drops: ${droppedCount()}`,
764
- `receipts: ${totals.receipts} (${totals.confirmed} confirmed) · base ${totals.base} → delivered ${totals.delivered} tokens`,
765
- savingsLine(savings),
766
- ];
767
- process.stdout.write(`${lines.join('\n')}\n`);
768
- }
769
- finally {
770
- db.close();
771
- }
772
- }
773
- function memorySearch(argv) {
774
- const query = argv.filter((a, i) => !a.startsWith('--') && !argv[i - 1]?.match(/^--(mode|limit)$/)).join(' ');
775
- if (!query.trim())
776
- fail('Usage: eklavya memory search <query> [--mode keyword|semantic|hybrid] [--limit <n>] [--all-projects]');
777
- const { config } = loadConfig();
778
- const mode = (flag(argv, '--mode', config.retrieval.mode) ?? 'hybrid');
779
- if (mode !== 'keyword' && mode !== 'semantic' && mode !== 'hybrid') {
780
- fail('--mode must be keyword, semantic or hybrid.');
781
- }
782
- const db = openDb();
783
- try {
784
- const hits = search(db, query, mode, {
785
- project: currentProject(),
786
- allProjects: argv.includes('--all-projects'),
787
- limit: numberFlag(argv, '--limit', 10),
788
- });
789
- if (!hits.length) {
790
- process.stdout.write('No matches.\n');
791
- return;
792
- }
793
- for (const hit of hits) {
794
- process.stdout.write(`#${hit.entry.id} ${hit.entry.occurred_at.slice(0, 16).replace('T', ' ')} ${hit.entry.title}\n` +
795
- ` ${hit.entry.type ?? hit.entry.kind} · score ${hit.score.toFixed(3)} · ${hit.via}${hit.entry.import_source ? ` · imported from ${hit.entry.import_source}` : ''}\n`);
796
- }
797
- }
798
- finally {
799
- db.close();
800
- }
801
- }
802
- function memoryTimeline(argv) {
803
- const db = openDb();
804
- try {
805
- const rows = timeline(db, {
806
- project: currentProject(),
807
- limit: numberFlag(argv, '--limit', 20),
808
- since: flag(argv, '--since') ?? null,
809
- });
810
- if (!rows.length) {
811
- process.stdout.write('Nothing recorded for this project yet.\n');
812
- return;
813
- }
814
- for (const row of rows) {
815
- process.stdout.write(`#${row.id} ${row.occurred_at.slice(0, 16).replace('T', ' ')} ${row.kind} ${row.title}\n`);
816
- }
817
- }
818
- finally {
819
- db.close();
820
- }
821
- }
822
- function memoryShow(argv) {
823
- const id = Number(argv[0]);
824
- if (!Number.isInteger(id))
825
- fail('Usage: eklavya memory show <id>');
826
- const db = openDb();
827
- try {
828
- const entry = entryById(db, id);
829
- if (!entry)
830
- fail(`No memory entry #${id}.`);
831
- const tags = entryTags(db, id);
832
- const lines = [
833
- `#${entry.id} ${entry.title}`,
834
- `kind: ${entry.kind}${entry.type ? ` / ${entry.type}` : ''}`,
835
- `project: ${entry.project}`,
836
- `occurred: ${entry.occurred_at}`,
837
- `generator: ${entry.generator}`,
838
- ...(entry.import_source ? [`imported: from ${entry.import_source} (unassessed — no mastery, no attempts)`] : []),
839
- ...(entry.superseded_by ? [`superseded by #${entry.superseded_by}`] : []),
840
- ...(tags.length ? [`tags: ${tags.join(', ')}`] : []),
841
- ...(entry.files ? [`files: ${JSON.parse(entry.files).join(', ')}`] : []),
842
- '',
843
- entry.narrative || '(no narrative)',
844
- ];
845
- const facts = entry.facts ? JSON.parse(entry.facts) : [];
846
- if (facts.length)
847
- lines.push('', 'Facts:', ...facts.map((f) => ` - ${f}`));
848
- const events = entryEvents(db, id);
849
- lines.push('', `Evidence (${events.length}):`);
850
- for (const event of events) {
851
- lines.push(` ${event.occurred_at.slice(0, 16).replace('T', ' ')} ${event.kind}${event.tool ? `/${event.tool}` : ''} ${event.body.slice(0, 120).replace(/\s+/g, ' ')}`);
852
- }
853
- if (!events.length)
854
- lines.push(' (none linked — imported or hand-written entries carry no local evidence)');
855
- process.stdout.write(`${lines.join('\n')}\n`);
856
- }
857
- finally {
858
- db.close();
859
- }
860
- }
861
- function memoryProcess(argv) {
862
- // A summariser's own session must never start a worker: that is the loop
863
- // that took 165 of them to stop (`reservation.ts`).
864
- if (isInternalObserver())
865
- return;
866
- const db = openDb();
867
- const { config } = loadConfig();
868
- // Running this command *is* the "I have fixed the credential" signal: it is
869
- // what `doctor` tells the developer to run, and nothing else takes a job off
870
- // 'paused'. Resuming here rather than in the worker keeps it an explicit act
871
- // — a hook that resumed by itself would spend a rejected key every session.
872
- // Validate before resuming. `numberFlag` exits on a bad value, and resuming
873
- // is not undoable: a refused run that had already emptied the pause would
874
- // tell the developer nothing happened while the queue quietly went back to
875
- // spending a credential that may still be rejected.
876
- const maxJobs = numberFlag(argv, '--max', 10);
877
- const background = argv.includes('--no-resume');
878
- // One worker per installation, manual runs included. A hook that spawned us
879
- // already won the slot and hands over its token; anyone else competes for it.
880
- const handed = flag(argv, '--worker-token');
881
- let token;
882
- try {
883
- token = handed
884
- ? renewWorker(db, handed, { pid: process.pid })
885
- ? handed
886
- : null
887
- : reserveWorker(db, process.pid);
888
- }
889
- catch {
890
- // A database too busy to reserve in is one to leave alone: the slot, if
891
- // this launch held it, lapses on its own.
892
- token = null;
893
- }
894
- if (!token) {
895
- const holder = workerStatus(db);
896
- if (!background) {
897
- process.stdout.write(`another memory worker is running${holder?.pid ? ` (pid ${holder.pid})` : ''} — its queue is this queue, so nothing to do.\n`);
898
- }
899
- db.close();
900
- return;
901
- }
902
- // The hooks' background drain passes --no-resume: a hook that resumed by
903
- // itself is exactly the retry loop the comment above rules out.
904
- const resumed = background ? 0 : resumePaused(db);
905
- // SIGHUP too: a closed terminal is a stop, and the call it left running is
906
- // exactly the orphan this has to prevent.
907
- const stop = new AbortController();
908
- const onSignal = () => stop.abort();
909
- for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP'])
910
- process.once(sig, onSignal);
911
- superviseWorker(db, token, config, {
912
- maxJobs,
913
- signal: stop.signal,
914
- loadConfig: () => loadConfig().config,
915
- }).then((result) => {
916
- if (!background) {
917
- process.stdout.write(`${resumed ? `resumed ${resumed} paused · ` : ''}processed ${result.processed} · entries ${result.entries} · failed ${result.failed} · skipped ${result.skipped}${result.handedOff ? ' · more queued, continuing in the background' : ''}\n`);
918
- }
919
- db.close();
920
- }, (err) => {
921
- db.close();
922
- fail(`eklavya memory process: ${err.message}`);
923
- });
924
- }
925
- /**
926
- * `eklavya memory backlog [quarantine|discard|restore] [selector]`: look at the
927
- * unfinished queue before letting a provider loose on it, and set aside or
928
- * delete what an incident put there. A change needs a selector — there is no
929
- * "all", because the queue also holds real work.
930
- */
931
- function memoryBacklog(argv) {
932
- const action = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'list';
933
- const batch = flag(argv, '--batch');
934
- const sel = {
935
- helpers: argv.includes('--helpers'),
936
- project: flag(argv, '--project'),
937
- session: flag(argv, '--session'),
938
- batch: batch === undefined ? undefined : Number(batch),
939
- };
940
- if (sel.batch !== undefined && !Number.isInteger(sel.batch))
941
- fail('--batch needs a batch id.');
942
- const selected = sel.helpers || sel.project !== undefined || sel.session !== undefined || sel.batch !== undefined;
943
- const db = openDb();
944
- try {
945
- if (action === 'list') {
946
- const groups = backlogSummary(db, sel);
947
- if (!groups.length) {
948
- process.stdout.write('No unfinished jobs.\n');
949
- return;
950
- }
951
- for (const g of groups) {
952
- process.stdout.write(`${String(g.batches).padStart(6)} ${g.status.padEnd(11)} ${g.project}${g.helper ? ' [observer helper sessions]' : ''}\n` +
953
- ` ${g.events} events · ${g.oldest.slice(0, 16).replace('T', ' ')} → ${g.newest.slice(0, 16).replace('T', ' ')}\n`);
954
- }
955
- if (groups.some((g) => g.helper)) {
956
- process.stdout.write('\nHelper sessions are the observer summarising its own runs — noise. Set them aside or delete them:\n' +
957
- ' eklavya memory backlog quarantine --helpers\n eklavya memory backlog discard --helpers\n');
958
- }
959
- return;
960
- }
961
- if (!selected)
962
- fail(`eklavya memory backlog ${action} needs --helpers, --project <key>, --session <id> or --batch <id>.`);
963
- if (action === 'quarantine') {
964
- process.stdout.write(`quarantined ${quarantineBacklog(db, sel)} job(s) — kept, and never processed until restored.\n`);
965
- }
966
- else if (action === 'restore') {
967
- process.stdout.write(`restored ${restoreBacklog(db, sel)} job(s) to the queue.\n`);
968
- }
969
- else if (action === 'discard') {
970
- const gone = discardBacklog(db, sel);
971
- process.stdout.write(`discarded ${gone.batches} batch(es) and ${gone.events} evidence event(s).\n`);
972
- }
973
- else {
974
- fail('Usage: eklavya memory backlog [list|quarantine|discard|restore] [--helpers] [--project <key>] [--session <id>] [--batch <id>]');
975
- }
976
- }
977
- finally {
978
- db.close();
979
- }
980
- }
981
- /**
982
- * `eklavya memory stop`: ends the running worker and its provider call, and
983
- * nothing that is not theirs. The job in flight goes back to the queue.
984
- */
985
- function memoryStop() {
986
- if (isInternalObserver())
987
- return;
988
- const db = openDb();
989
- void stopWorker(db).then((outcome) => {
990
- const { config } = loadConfig();
991
- if (!outcome.stopped) {
992
- process.stdout.write('no memory worker is running.\n');
993
- }
994
- else {
995
- process.stdout.write(`stopped the memory worker${outcome.pid ? ` (pid ${outcome.pid})` : ''}${outcome.child ? ` and its claude call (pid ${outcome.child})` : ''}${outcome.forced ? ' — it had to be killed' : ''}. Unfinished jobs stay queued.\n` +
996
- (outcome.released ? '' : 'something it started would not exit; the slot stays held until it does.\n'));
997
- }
998
- if (config.providers.observer) {
999
- process.stdout.write('the next session seam starts a new one. To keep it stopped: eklavya config set providers.observer null\n');
1000
- }
1001
- db.close();
1002
- });
1003
- }
1004
- /**
1005
- * Backfills from Claude Code's own transcripts.
1006
- *
1007
- * The hooks only see sessions that happened after Eklavya was installed. This
1008
- * is for the ones before it, and for a session where a hook was misconfigured:
1009
- * the transcript is on disk either way, and it goes through the same privacy
1010
- * filter and converges with whatever the hooks already captured.
1011
- */
1012
- function memoryReplay(argv) {
1013
- const db = openDb();
1014
- try {
1015
- const { config } = loadConfig();
1016
- if (!config.memory.enabled) {
1017
- process.stdout.write('memory.enabled is false, so there is nowhere to replay into.\n');
1018
- return;
1019
- }
1020
- const cwd = process.cwd();
1021
- const files = transcriptsFor(cwd);
1022
- if (!files.length) {
1023
- process.stdout.write(`No Claude Code transcripts found for this checkout.\nLooked in: ${transcriptDirFor(cwd)}\n`);
1024
- return;
1025
- }
1026
- const limit = Number(flag(argv, '--limit', '20'));
1027
- const results = replayProject(db, config, cwd, { limit });
1028
- const total = results.reduce((sum, r) => ({
1029
- read: sum.read + r.read,
1030
- captured: sum.captured + r.captured,
1031
- duplicates: sum.duplicates + r.duplicates,
1032
- excluded: sum.excluded + r.excluded,
1033
- }), { read: 0, captured: 0, duplicates: 0, excluded: 0 });
1034
- process.stdout.write([
1035
- `transcripts: ${results.length} of ${files.length}`,
1036
- `lines read: ${total.read}`,
1037
- `captured: ${total.captured}`,
1038
- `already had: ${total.duplicates}`,
1039
- `excluded: ${total.excluded} (privacy filter, or capture set to minimal)`,
1040
- '',
1041
- 'Run `eklavya memory process` to summarise what was captured.',
1042
- '',
1043
- ].join('\n'));
1044
- }
1045
- finally {
1046
- db.close();
1047
- }
1048
- }
1049
- function memoryPrune() {
1050
- const db = openDb();
1051
- try {
1052
- const { config } = loadConfig();
1053
- if (!config.memory.retention_days) {
1054
- process.stdout.write('memory.retention_days is not set, so raw evidence is kept until deleted by hand.\n');
1055
- return;
1056
- }
1057
- const removed = pruneEvidence(db, config);
1058
- process.stdout.write(`Deleted ${removed} raw evidence events older than ${config.memory.retention_days} days.\n`);
1059
- }
1060
- finally {
1061
- db.close();
1062
- }
1063
- }
1064
- /** The field-disposition report, printed before anything is written. */
1065
- function dispositionReport(fields) {
1066
- const lines = [];
1067
- for (const kind of ['mapped', 'dropped', 'unrecognised']) {
1068
- const group = fields.filter((f) => f.kind === kind);
1069
- if (!group.length)
1070
- continue;
1071
- lines.push('', `${kind} (${group.length}):`);
1072
- for (const f of group) {
1073
- lines.push(` ${f.table}.${f.field}${f.to ? ` -> ${f.to}` : ''}${f.reason ? ` — ${f.reason}` : ''}`);
1074
- }
1075
- }
1076
- return lines.join('\n');
1077
- }
1078
- /**
1079
- * Reads `--map source=/path` and `--map-here source` into a project map.
1080
- *
1081
- * Eklavya keys a project by the checkout's absolute realpath; Claude Mem keys
1082
- * it by a bare name. Without a mapping the import is honest and useless at the
1083
- * moment it matters -- every row lands in a scope no session queries, so a
1084
- * search in the very repository the history came from finds nothing. The
1085
- * importer cannot guess which checkout `eklavya` meant, so this is a flag.
1086
- */
1087
- function projectMapFrom(argv) {
1088
- const map = {};
1089
- for (let i = 0; i < argv.length; i++) {
1090
- if (argv[i] === '--map') {
1091
- const pair = argv[i + 1] ?? '';
1092
- const eq = pair.indexOf('=');
1093
- if (eq <= 0)
1094
- fail('Usage: --map <source-project>=<path-to-checkout>');
1095
- map[pair.slice(0, eq)] = projectKey(findRepoConfig(pair.slice(eq + 1)).repoRoot ?? pair.slice(eq + 1));
1096
- i++;
1097
- }
1098
- else if (argv[i] === '--map-here') {
1099
- const name = argv[i + 1];
1100
- if (!name || name.startsWith('--'))
1101
- fail('Usage: --map-here <source-project>');
1102
- const here = findRepoConfig(process.cwd()).repoRoot;
1103
- // "Here" has to be somewhere. Without a checkout `projectKey` answers with
1104
- // the global bucket, so the flag would file every row under a scope no
1105
- // session queries -- silently, permanently, and to say it had mapped them.
1106
- if (!here)
1107
- fail(`--map-here needs a checkout: ${process.cwd()} is not inside a git repository.`);
1108
- map[name] = projectKey(here);
1109
- i++;
1110
- }
1111
- }
1112
- return map;
1113
- }
1114
- /** The `--verify` report: every source row by id, then where each project landed. */
1115
- function verifyLines(r) {
1116
- const missing = r.tables.reduce((n, t) => n + t.missing.length, 0);
1117
- const width = Math.max(0, ...r.projects.map((p) => p.project.length));
1118
- return [
1119
- `verify: ${r.sourcePath}`,
1120
- ...r.tables.map((t) => ` ${t.table.padEnd(18)} ${String(t.source).padStart(6)} in source · ${String(t.present).padStart(6)} in Eklavya · ${t.missing.length} missing${t.missing.length ? ` (ids ${t.missing.slice(0, 10).join(', ')}${t.missing.length > 10 ? ', …' : ''})` : ''}`),
1121
- ` changed since import: ${r.changed} entr${r.changed === 1 ? 'y' : 'ies'}`,
1122
- 'placement:',
1123
- ...r.projects.flatMap((p) => Object.entries(p.filedUnder).map(([to, n]) => {
1124
- const placed = path.isAbsolute(to);
1125
- return ` ${String(n).padStart(6)} ${p.project.padEnd(width)} ${placed ? `→ ${to}` : 'not placed — searchable only with --all-projects; --map it to a checkout'}`;
1126
- })),
1127
- missing
1128
- ? `INCOMPLETE: ${missing} source row(s) are not in Eklavya — re-run without --verify to import them.`
1129
- : 'complete: every source row is in Eklavya.',
1130
- ];
1131
- }
1132
- async function memoryImport(argv) {
1133
- const flagValues = new Set(argv.flatMap((a, i) => (a === '--map' || a === '--map-here' ? [argv[i + 1] ?? ''] : [])));
1134
- const source = argv.find((a) => !a.startsWith('--') && !flagValues.has(a));
1135
- if (!source)
1136
- fail('Usage: eklavya memory import <path-to-claude-mem.db> [--dry-run] [--verify] [--resume] [--map <src>=<path>]');
1137
- const dryRun = argv.includes('--dry-run');
1138
- const explicit = projectMapFrom(argv);
1139
- try {
1140
- if (argv.includes('--verify')) {
1141
- const db = openDb();
1142
- try {
1143
- const report = verifyImport(db, source);
1144
- process.stdout.write(`${verifyLines(report).join('\n')}\n`);
1145
- if (report.tables.some((t) => t.missing.length))
1146
- process.exit(1);
1147
- }
1148
- finally {
1149
- db.close();
1150
- }
1151
- return;
1152
- }
1153
- const found = inventory(source);
1154
- // Placed the way `eklavya install` places them -- off Claude Code's
1155
- // transcripts -- so a re-run by hand files history where install would
1156
- // have. A --map names what the transcripts cannot, and wins.
1157
- const unsure = {};
1158
- const projectMap = { ...guessProjectMap(source, claudeHome(), unsure), ...explicit };
1159
- for (const name of Object.keys(explicit))
1160
- delete unsure[name];
1161
- const unsureLines = Object.entries(unsure).map(([name, paths]) => ` ${name}: ${paths.length} checkouts carry this name — pick one: --map ${name}=<path> (${paths.join(', ')})`);
1162
- const lines = [
1163
- `source: ${found.sourcePath}`,
1164
- `schema: ${found.schemaVersion ?? 'unversioned'} (this importer understands up to ${found.supportedMax})`,
1165
- `range: ${found.dateRange.from?.slice(0, 10) ?? '—'} … ${found.dateRange.to?.slice(0, 10) ?? '—'}`,
1166
- 'tables:',
1167
- ...found.tables.map((t) => ` ${t.rows.toString().padStart(7)} ${t.name}${t.known ? '' : ' (unrecognised)'}`),
1168
- 'projects:',
1169
- ...found.projects.map((p) => ` ${p.entries.toString().padStart(7)} ${p.project}`),
1170
- dispositionReport(found.fields),
1171
- ];
1172
- process.stdout.write(`${lines.join('\n')}\n`);
1173
- if (!found.supported)
1174
- fail(`\n${found.problem ?? 'Unsupported source database.'}`);
1175
- if (dryRun) {
1176
- const planned = Object.entries(projectMap);
1177
- const unmapped = found.projects.map((p) => p.project).filter((p) => !(p in projectMap));
1178
- process.stdout.write([
1179
- '',
1180
- ...planned.map(([from, to]) => `would map: ${from} -> ${to}`),
1181
- ...(unmapped.length ? [`would keep as-is: ${unmapped.join(', ')}`] : []),
1182
- ...unsureLines,
1183
- 'Dry run: nothing was written, and the source was opened read-only.',
1184
- '',
1185
- ].join('\n'));
1186
- return;
1187
- }
1188
- {
1189
- process.stdout.write('\n');
1190
- const { report, verified } = await spin('import', 'importing…', () => importOffThread({ dbFile: dbPath(), source, opts: { resume: argv.includes('--resume'), projectMap } }));
1191
- const rows = IMPORTED_TABLES.map((t) => ` ${t.padEnd(18)} read ${report.read[t]} · imported ${report.imported[t]} · already present ${report.skipped[t]}`);
1192
- process.stdout.write([
1193
- '',
1194
- `snapshot: ${report.snapshot}`,
1195
- ...rows,
1196
- ` concept candidates: ${report.candidates} (all unassessed — no mastery, no attempts, no gate touched)`,
1197
- ` evidence links: ${report.links} (drill-down from an entry to the prompts and tool uses behind it)`,
1198
- ` re-indexed: ${report.reindexed} entries`,
1199
- ...(report.rehomed ? [` re-homed: ${report.rehomed} entries an earlier run left under a bare project name`] : []),
1200
- ` validation: ${report.validation.ok ? 'ok' : `FAILED — ${report.validation.notes.join('; ')}`}`,
1201
- ...report.projectsMapped.map((p) => ` mapped: ${p.from} -> ${p.to}`),
1202
- // The unmapped list is the useful half: those rows only ever surface
1203
- // under --all-projects until somebody maps them.
1204
- ...(report.projectsKept.length
1205
- ? [
1206
- ` kept as-is: ${report.projectsKept.join(', ')}`,
1207
- ' (unmapped projects are searchable only with --all-projects; re-run with --map to file them under a checkout)',
1208
- ]
1209
- : []),
1210
- '',
1211
- ].join('\n'));
1212
- if (unsureLines.length)
1213
- process.stdout.write(`${unsureLines.join('\n')}\n`);
1214
- if (!report.validation.ok)
1215
- process.exit(1);
1216
- // Counts agreeing is what validation proves; this proves every source
1217
- // row by id, and shows where each project's history is filed.
1218
- process.stdout.write(`\n${verifyLines(verified).join('\n')}\n`);
1219
- if (verified.tables.some((t) => t.missing.length))
1220
- process.exit(1);
1221
- }
1222
- }
1223
- catch (err) {
1224
- if (err instanceof ImportError)
1225
- fail(err.message);
1226
- // The source is a hand-typed path, so pointing it at the wrong file is the
1227
- // likeliest mistake there is. The missing-file case was already handled and
1228
- // `restore` says "is not readable JSON" for the same mistake; only this path
1229
- // let a driver error out with a stack through node_modules.
1230
- fail(`eklavya memory import: cannot read ${source} — ${err.message}`);
1231
- }
1232
- }
1233
- function memoryExport(argv) {
1234
- const out = argv.find((a) => !a.startsWith('--'));
1235
- if (!out)
1236
- fail('Usage: eklavya memory export <path>');
1237
- const db = openDb();
1238
- try {
1239
- const payload = exportPayload(db);
1240
- fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
1241
- fs.writeFileSync(out, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
1242
- process.stdout.write(`Wrote ${out} — ${payload.entries.length} entries, schema version ${EXPORT_SCHEMA_VERSION}\n`);
1243
- }
1244
- finally {
1245
- db.close();
1246
- }
1247
- }
1248
- /**
1249
- * `eklavya memory restore <file>` — the other half of the backup pair.
1250
- *
1251
- * Without it `export` writes a file nothing on the machine can read, which
1252
- * makes the rollback drill in the migration guide unrunnable. It is additive
1253
- * and idempotent, so it is also how a second device is brought up to date from
1254
- * a file rather than a shared folder.
1255
- */
1256
- function memoryRestore(argv) {
1257
- const from = argv.find((a) => !a.startsWith('--'));
1258
- if (!from)
1259
- fail('Usage: eklavya memory restore <file>');
1260
- const db = openDb();
1261
- try {
1262
- const r = restoreExport(db, path.resolve(from));
1263
- process.stdout.write([
1264
- `Restored ${from} (export schema version ${r.schemaVersion}):`,
1265
- ` entries: ${r.entries.restored} restored, ${r.entries.skipped} already here`,
1266
- ` evidence: ${r.evidence.restored} restored, ${r.evidence.skipped} already here`,
1267
- ` links: ${r.tags} tag(s), ${r.links} evidence link(s)`,
1268
- ` receipts: ${r.receipts.restored} restored, ${r.receipts.skipped} already here (${r.receiptItems} item(s))`,
1269
- ` reindexed: ${r.reindexed} entries — search index and vectors rebuilt`,
1270
- 'Learning history was not touched: no attempt, mastery or gate row is written by a restore.',
1271
- '',
1272
- ].join('\n'));
1273
- }
1274
- catch (err) {
1275
- if (err instanceof ImportError)
1276
- fail(err.message);
1277
- throw err;
1278
- }
1279
- finally {
1280
- db.close();
1281
- }
1282
- }
1283
- /**
1284
- * `eklavya memory sync push|pull|status [--target <dir>]` (ADR-09).
1285
- *
1286
- * The directory is the whole protocol, so the command has no host, no token and
1287
- * no network error to report — only what it wrote and what it read back.
1288
- * `--target` overrides `sync.target` for one run; it does not override
1289
- * `sync.enabled`, because "point it somewhere for a second" is still a decision
1290
- * to publish this machine's memory.
1291
- */
1292
- function memorySync(argv) {
1293
- const [sub] = argv;
1294
- if (sub !== 'push' && sub !== 'pull' && sub !== 'status') {
1295
- fail('Usage: eklavya memory sync <push|pull|status> [--target <dir>]');
1296
- }
1297
- const target = flag(argv, '--target') ?? null;
1298
- const db = openDb();
1299
- try {
1300
- const { config } = loadConfig();
1301
- if (sub === 'status') {
1302
- const s = syncStatus(db, config, { target });
1303
- const lines = [
1304
- `sync: ${s.enabled ? 'on' : 'off (set sync.enabled)'}`,
1305
- `target: ${s.target ?? '— (set sync.target, or pass --target)'}`,
1306
- `device: ${s.device_id ?? '—'}`,
1307
- `revision: ${s.local_revision}`,
1308
- `pending: ${s.pending} local change${s.pending === 1 ? '' : 's'} to push`,
1309
- `conflicts: ${s.open_conflicts} quarantined`,
1310
- `peers: ${s.peers.length
1311
- ? s.peers.map((p) => `${p.device_id}@${p.last_revision}`).join(', ')
1312
- : 'none seen yet'}`,
1313
- ];
1314
- process.stdout.write(`${lines.join('\n')}\n`);
1315
- return;
1316
- }
1317
- const result = sub === 'push' ? push(db, config, { target }) : pull(db, config, { target });
1318
- if (!result.ok) {
1319
- fail(result.reason === 'disabled'
1320
- ? 'Sync is off. Set sync.enabled to true in ~/.eklavya/config.json.'
1321
- : 'No sync target. Set sync.target to a folder your devices share, or pass --target.');
1322
- }
1323
- if (sub === 'push') {
1324
- const r = result;
1325
- process.stdout.write(`Pushed to ${r.target} as ${r.device_id}: ${r.staged} new revision${r.staged === 1 ? '' : 's'}, ${r.written} record${r.written === 1 ? '' : 's'} written, ${r.already} already there.\n`);
1326
- return;
1327
- }
1328
- const r = result;
1329
- process.stdout.write(`Pulled from ${r.target}: ${r.applied} applied (${r.tombstones} deletion${r.tombstones === 1 ? '' : 's'}), ${r.skipped} already known, ${r.conflicts} quarantined.\n`);
1330
- if (r.conflicts) {
1331
- process.stdout.write('Quarantined versions are kept whole in sync_conflicts — nothing was overwritten.\n');
1332
- }
1333
- if (r.stalled.length) {
1334
- process.stdout.write(`Stopped early on an unreadable record from: ${r.stalled.join(', ')} — likely still being written. Try again.\n`);
1335
- }
1336
- }
1337
- finally {
1338
- db.close();
1339
- }
1340
- }
1341
- function memoryCommand(argv) {
1342
- const [sub, ...rest] = argv;
1343
- switch (sub) {
1344
- case 'status':
1345
- return memoryStatus();
1346
- case 'search':
1347
- return memorySearch(rest);
1348
- case 'timeline':
1349
- return memoryTimeline(rest);
1350
- case 'show':
1351
- return memoryShow(rest);
1352
- case 'replay':
1353
- return memoryReplay(rest);
1354
- case 'process':
1355
- return memoryProcess(rest);
1356
- case 'stop':
1357
- return memoryStop();
1358
- case 'backlog':
1359
- return memoryBacklog(rest);
1360
- case 'prune':
1361
- return memoryPrune();
1362
- case 'import':
1363
- // Async only so the spinner turns: the import itself runs on a worker.
1364
- void memoryImport(rest);
1365
- return;
1366
- case 'export':
1367
- return memoryExport(rest);
1368
- case 'restore':
1369
- return memoryRestore(rest);
1370
- case 'sync':
1371
- return memorySync(rest);
1372
- default:
1373
- process.stderr.write(MEMORY_USAGE);
1374
- process.exit(1);
1375
- }
1376
- }
1377
- function main() {
695
+ async function main() {
1378
696
  const [command, ...rest] = process.argv.slice(2);
1379
697
  switch (command) {
1380
698
  case 'serve':
@@ -1386,15 +704,17 @@ function main() {
1386
704
  process.exit(1);
1387
705
  });
1388
706
  return;
1389
- case 'install':
707
+ case 'install': {
708
+ const { install } = await import('./install.js');
1390
709
  // Async only because the settings walk waits on a terminal.
1391
710
  install(rest).catch((err) => {
1392
711
  process.stderr.write(`eklavya install: ${err instanceof Error ? err.message : String(err)}\n`);
1393
712
  process.exit(1);
1394
713
  });
1395
714
  return;
715
+ }
1396
716
  case 'uninstall':
1397
- return uninstall(rest);
717
+ return (await import('./install.js')).uninstall(rest);
1398
718
  case 'export-rules':
1399
719
  return exportRules(rest);
1400
720
  case 'config':
@@ -1405,7 +725,7 @@ function main() {
1405
725
  case 'dashboard':
1406
726
  return dashboardCommand(rest);
1407
727
  case 'memory':
1408
- return memoryCommand(rest);
728
+ return (await import('./cli-memory.js')).memoryCommand(rest);
1409
729
  case 'doctor':
1410
730
  return doctor();
1411
731
  case 'db-path':
@@ -1421,5 +741,7 @@ function main() {
1421
741
  process.exit(1);
1422
742
  }
1423
743
  }
1424
- main();
744
+ // Awaited, so an error thrown by a command surfaces exactly as it did when
745
+ // `main()` was synchronous: as the module's own failure, stack and exit 1.
746
+ await main();
1425
747
  //# sourceMappingURL=cli.js.map