eklavya 1.18.3 → 1.19.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.
Files changed (96) hide show
  1. package/dist/assets/dashboard.html +559 -14
  2. package/dist/assets/tutor/references/focus-and-level.md +29 -0
  3. package/dist/cli.js +648 -20
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config-path.js +156 -0
  6. package/dist/config-path.js.map +1 -0
  7. package/dist/config.js +231 -2
  8. package/dist/config.js.map +1 -1
  9. package/dist/dashboard.js +355 -0
  10. package/dist/dashboard.js.map +1 -1
  11. package/dist/eval/retrieval-score.js +77 -0
  12. package/dist/eval/retrieval-score.js.map +1 -0
  13. package/dist/hooks/capture-tool.js +107 -0
  14. package/dist/hooks/capture-tool.js.map +1 -0
  15. package/dist/hooks/lib.js +4 -30
  16. package/dist/hooks/lib.js.map +1 -1
  17. package/dist/hooks/memory-lib.js +181 -0
  18. package/dist/hooks/memory-lib.js.map +1 -0
  19. package/dist/hooks/prompt-submit-nudge.js +56 -20
  20. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  21. package/dist/hooks/session-start.js +73 -74
  22. package/dist/hooks/session-start.js.map +1 -1
  23. package/dist/hooks/stop-quiz-check.js +26 -0
  24. package/dist/hooks/stop-quiz-check.js.map +1 -1
  25. package/dist/install.js +37 -0
  26. package/dist/install.js.map +1 -1
  27. package/dist/memory/capture.js +123 -0
  28. package/dist/memory/capture.js.map +1 -0
  29. package/dist/memory/code.js +186 -0
  30. package/dist/memory/code.js.map +1 -0
  31. package/dist/memory/collections.js +87 -0
  32. package/dist/memory/collections.js.map +1 -0
  33. package/dist/memory/embed.js +81 -0
  34. package/dist/memory/embed.js.map +1 -0
  35. package/dist/memory/hosts.js +71 -0
  36. package/dist/memory/hosts.js.map +1 -0
  37. package/dist/memory/identity.js +74 -0
  38. package/dist/memory/identity.js.map +1 -0
  39. package/dist/memory/import.js +916 -0
  40. package/dist/memory/import.js.map +1 -0
  41. package/dist/memory/learning.js +148 -0
  42. package/dist/memory/learning.js.map +1 -0
  43. package/dist/memory/notify.js +220 -0
  44. package/dist/memory/notify.js.map +1 -0
  45. package/dist/memory/privacy.js +117 -0
  46. package/dist/memory/privacy.js.map +1 -0
  47. package/dist/memory/provider.js +155 -0
  48. package/dist/memory/provider.js.map +1 -0
  49. package/dist/memory/recall.js +327 -0
  50. package/dist/memory/recall.js.map +1 -0
  51. package/dist/memory/replay.js +174 -0
  52. package/dist/memory/replay.js.map +1 -0
  53. package/dist/memory/search.js +143 -0
  54. package/dist/memory/search.js.map +1 -0
  55. package/dist/memory/spool.js +93 -0
  56. package/dist/memory/spool.js.map +1 -0
  57. package/dist/memory/store.js +399 -0
  58. package/dist/memory/store.js.map +1 -0
  59. package/dist/memory/summarize.js +178 -0
  60. package/dist/memory/summarize.js.map +1 -0
  61. package/dist/memory/sync.js +595 -0
  62. package/dist/memory/sync.js.map +1 -0
  63. package/dist/memory/tokens.js +55 -0
  64. package/dist/memory/tokens.js.map +1 -0
  65. package/dist/memory/worker.js +207 -0
  66. package/dist/memory/worker.js.map +1 -0
  67. package/dist/migrations/009_memory.sql +226 -0
  68. package/dist/migrations/010_import.sql +26 -0
  69. package/dist/migrations/011_sync.sql +76 -0
  70. package/dist/migrations/012_job_backoff.sql +13 -0
  71. package/dist/migrations/013_batch_provenance.sql +18 -0
  72. package/dist/migrations/014_batch_events_index.sql +18 -0
  73. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  74. package/dist/plugin/agents/tutor.md +3 -1
  75. package/dist/plugin/hooks/CLAUDE.md +25 -3
  76. package/dist/plugin/hooks/hooks.json +10 -0
  77. package/dist/plugin/skills/CLAUDE.md +17 -7
  78. package/dist/plugin/skills/memory/SKILL.md +88 -0
  79. package/dist/plugin/skills/setup/SKILL.md +16 -2
  80. package/dist/plugin/skills/tutor/references/focus-and-level.md +29 -0
  81. package/dist/time.js +41 -0
  82. package/dist/time.js.map +1 -0
  83. package/dist/tools/code_tools.js +73 -0
  84. package/dist/tools/code_tools.js.map +1 -0
  85. package/dist/tools/collection_tools.js +92 -0
  86. package/dist/tools/collection_tools.js.map +1 -0
  87. package/dist/tools/config_tools.js +104 -1
  88. package/dist/tools/config_tools.js.map +1 -1
  89. package/dist/tools/index.js +15 -0
  90. package/dist/tools/index.js.map +1 -1
  91. package/dist/tools/memory_read_tools.js +277 -0
  92. package/dist/tools/memory_read_tools.js.map +1 -0
  93. package/dist/tools/memory_write_tools.js +115 -0
  94. package/dist/tools/memory_write_tools.js.map +1 -0
  95. package/dist/user-skill/eklavya/SKILL.md +64 -8
  96. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -10,15 +10,25 @@ import { fileURLToPath } from 'node:url';
10
10
  import { openDb } from './db.js';
11
11
  import { dbPath, eklavyaHome } from './paths.js';
12
12
  import { readStdinBounded, stripBom, STATUSLINE_STDIN } from './stdin.js';
13
- import { loadConfig, writeConfigFile, REPO_CONFIG_FILE, DEFAULT_CONFIG } from './config.js';
13
+ import { loadConfig, writeConfigFile, readConfigFile, REPO_CONFIG_FILE, REPO_FORBIDDEN_KEYS, findRepoConfig, } from './config.js';
14
+ import { isKnownKey, knownKeys, parseValue, patchFor } from './config-path.js';
14
15
  import { loadPacks, applyPacks } from './packs.js';
15
- import { levelStanding } from './store.js';
16
+ import { levelStanding, projectKey } from './store.js';
16
17
  import { statusLine } from './statusline.js';
17
18
  import { isSessionOff } from './session.js';
18
19
  import { START_LEVEL } from './srs.js';
19
20
  import Database from 'better-sqlite3';
20
21
  import { startDashboard, openInBrowser } from './dashboard.js';
21
22
  import { install, uninstall, health } from './install.js';
23
+ import { identityFor } from './memory/identity.js';
24
+ import { countEntries, entryById, entryEvents, entryTags, pendingEventCount, receiptTotals, resumePaused, timeline, } from './memory/store.js';
25
+ import { search } from './memory/search.js';
26
+ import { pull, push, syncStatus } from './memory/sync.js';
27
+ import { processPending, pruneEvidence, queueDepth, summarizerFor } from './memory/worker.js';
28
+ import { replayProject, transcriptDirFor, transcriptsFor } from './memory/replay.js';
29
+ import { droppedCount } from './memory/spool.js';
30
+ import { savingsFrom, savingsLine } from './memory/tokens.js';
31
+ import { importFrom, inventory, exportPayload, restoreExport, EXPORT_SCHEMA_VERSION, ImportError, IMPORTED_TABLES, } from './memory/import.js';
22
32
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
23
33
  const USAGE = `eklavya — local learning state for agent-assisted development
24
34
 
@@ -39,10 +49,45 @@ Usage:
39
49
  eklavya doctor Check the install, apply concept packs, and say what to fix
40
50
  eklavya db-path Print the database location
41
51
 
52
+ Memory:
53
+ eklavya memory status Entries, pending evidence, queue depth, provider and savings
54
+ eklavya memory search <query> Search this project's memory
55
+ [--mode keyword|semantic|hybrid] [--limit <n>] [--all-projects]
56
+ eklavya memory timeline Recent entries, newest first [--limit <n>] [--since <iso date>]
57
+ eklavya memory show <id> One entry, with the evidence it was built from
58
+ eklavya memory replay [--limit <n>] Backfill from this checkout's Claude Code transcripts
59
+ Covers sessions from before the install, and any a hook missed
60
+ eklavya memory process [--max <n>] Drain the observation queue now
61
+ Resumes jobs paused on a credential or quota — run it once you have fixed one
62
+ eklavya memory prune Delete raw evidence past memory.retention_days
63
+ eklavya memory import <source.db> Import a Claude Mem database [--dry-run] [--resume]
64
+ --dry-run reads the source and reports; it writes nothing
65
+ --map <source>=<path> file that source project under a checkout
66
+ --map-here <source> the same, for the checkout you are in
67
+ eklavya memory export <file> Versioned JSON of entries, tags, evidence links and receipts
68
+ eklavya memory restore <file> Read that file back in. Additive and idempotent — a second
69
+ restore adds nothing, and no attempt, mastery or gate row is
70
+ touched. Refuses a schema version it does not understand
71
+ eklavya memory sync <push|pull|status> Exchange memory with your other devices through a shared
72
+ folder [--target <dir>]. Memory entries, tags and tombstones
73
+ only — attempts, mastery, gates and receipts never leave.
74
+ Needs sync.enabled and sync.target; does nothing without both
75
+
42
76
  Config keys: mode, focus, focus_topic, cadence, difficulty, level_up_after,
43
77
  level_up_accuracy, pass_threshold, max_questions_per_task,
44
78
  min_minutes_between_quizzes, min_minutes_between_checkpoints,
45
79
  max_new_concepts_per_session, max_stop_blocks_per_session, quiet
80
+ Config namespaces (nested; edit ~/.eklavya/config.json or .eklavya.json directly):
81
+ memory.{enabled, capture: full|minimal|off, batch_max_events, retention_days}
82
+ privacy.{exclude_paths, exclude_tools, redact_patterns}
83
+ retrieval.{mode: keyword|semantic|hybrid, max_items, max_tokens, cross_project}
84
+ providers.{observer, embeddings} — each null or {kind, model, api_key_env};
85
+ api_key_env names the variable holding the key, never the key itself
86
+ notifications.{enabled, sinks} — sinks are {kind: webhook|command|file, target,
87
+ args, events}; off by default, and a send cannot be recalled
88
+ sync.{enabled, target, device_id} — target is a folder both devices can see
89
+ (Dropbox, iCloud, Syncthing, a share); off and unset by default.
90
+ device_id is normally left null: it is generated once per install
46
91
  `;
47
92
  function fail(message) {
48
93
  process.stderr.write(`${message}\n`);
@@ -145,14 +190,22 @@ function configCommand(args) {
145
190
  process.stdout.write(`${JSON.stringify(resolved.config, null, 2)}\n`);
146
191
  process.stdout.write(`\nglobal: ${resolved.globalPath}\n`);
147
192
  process.stdout.write(`repo: ${resolved.repoPath ?? '(none)'}\n`);
193
+ if (resolved.refusedRepoKeys.length) {
194
+ // Loud rather than silent: a checked-in config trying to set one of
195
+ // these is worth somebody looking at.
196
+ process.stdout.write(`\nignored from the repo config: ${resolved.refusedRepoKeys.join(', ')}\n` +
197
+ ' These are read from your global config only — they run a command, write files,\n' +
198
+ ' send work off the machine, or widen what the model can see, and a repository\n' +
199
+ ' config is a file you get by cloning.\n');
200
+ }
148
201
  return;
149
202
  }
150
203
  if (action !== 'set')
151
204
  fail(`Unknown config action "${action}".`);
152
205
  if (!key || value === undefined)
153
206
  fail('Usage: eklavya config set <key> <value>');
154
- if (!(key in DEFAULT_CONFIG)) {
155
- fail(`Unknown setting "${key}". Known: ${Object.keys(DEFAULT_CONFIG).join(', ')}`);
207
+ if (!isKnownKey(key)) {
208
+ fail(`Unknown setting "${key}". Known: ${knownKeys().join(', ')}`);
156
209
  }
157
210
  // `focus learn` is useless without a topic, so let one call say both rather
158
211
  // than leaving a state that the planner will only refuse later.
@@ -166,21 +219,17 @@ function configCommand(args) {
166
219
  if (key === 'focus' && value === 'learn' && topic === undefined) {
167
220
  fail('focus "learn" needs a topic: eklavya config set focus learn --topic <topic>');
168
221
  }
169
- // A topic is always a string. Number-parsing it would turn a topic like "html5"
170
- // -- or worse, "2" -- into something `coerce` then silently drops.
171
- const isTopicKey = key === 'focus_topic';
172
- let parsed = value;
173
- if (isTopicKey)
174
- parsed = value;
175
- else if (value === 'true' || value === 'false')
176
- parsed = value === 'true';
177
- else if (value !== '' && !Number.isNaN(Number(value)))
178
- parsed = Number(value);
179
- const patch = { [key]: parsed };
180
- if (topic !== undefined && key === 'focus')
181
- patch.focus_topic = topic;
222
+ // Typed by the schema at that path rather than guessed from the text. A
223
+ // topic of "2" is a topic; `memory.batch_max_events` of "40" is a number,
224
+ // and only the default sitting there knows which is which.
225
+ const parsed = parseValue(key, value);
182
226
  let target;
183
227
  if (scopeRepo) {
228
+ // Same rule as `loadConfig` applies on read: refuse at the point of writing
229
+ // rather than let a setting land in the file and be ignored for ever.
230
+ if (REPO_FORBIDDEN_KEYS.some((k) => key === k || key.startsWith(`${k}.`))) {
231
+ fail(`"${key}" can only be set globally. A repository config is a file you get by cloning, and this one runs a command, writes files or sends work off the machine.`);
232
+ }
184
233
  if (!resolved.repoRoot)
185
234
  fail('Not inside a git repository, so there is nowhere to write .eklavya.json.');
186
235
  target = resolved.repoPath ?? path.join(resolved.repoRoot, REPO_CONFIG_FILE);
@@ -188,16 +237,36 @@ function configCommand(args) {
188
237
  else {
189
238
  target = resolved.globalPath;
190
239
  }
240
+ // Built against the file being written, not against nothing: the two config
241
+ // files merge with a shallow spread, so a patch that replaced a whole
242
+ // namespace would drop every other key already set in it.
243
+ const existing = readConfigFile(target);
244
+ const patch = patchFor(existing, key, parsed);
245
+ if (topic !== undefined && key === 'focus')
246
+ patch.focus_topic = topic;
191
247
  writeConfigFile(target, patch);
192
248
  for (const [k, v] of Object.entries(patch)) {
193
249
  process.stdout.write(`${k} = ${JSON.stringify(v)} -> ${target}\n`);
194
250
  }
195
251
  }
252
+ /** Never throws: `doctor` is also what someone runs on a half-built database. */
253
+ function safely(fn, fallback) {
254
+ try {
255
+ return fn();
256
+ }
257
+ catch {
258
+ return fallback;
259
+ }
260
+ }
196
261
  function doctor() {
197
262
  const file = dbPath();
198
263
  const resolved = loadConfig();
199
264
  const lines = [];
200
265
  let ok = true;
266
+ // Kept apart from `ok` so the blanket remedy below stays true: `eklavya
267
+ // install` repairs a broken install and cannot do a thing about a paused
268
+ // queue. A memory failure still exits non-zero; it just names its own fix.
269
+ let memoryOk = true;
201
270
  lines.push(`home: ${eklavyaHome()}`);
202
271
  // The install checks come first because they are what someone is looking for
203
272
  // when Eklavya has gone quiet. Everything below reads fine on an install that
@@ -219,8 +288,12 @@ function doctor() {
219
288
  lines.push(' this command cannot see it. Your learning history is shared either way.');
220
289
  lines.push(`database: ${file}${fs.existsSync(file) ? '' : ' (not created yet)'}`);
221
290
  let edgesDropped = 0;
291
+ // Held open past the try so the memory section below can read it, and can
292
+ // still report when it is null -- the spool drop count is exactly the number
293
+ // that matters when the database is the thing that is broken.
294
+ let db = null;
222
295
  try {
223
- const db = openDb(file);
296
+ db = openDb(file);
224
297
  // Applied here, on the connection `doctor` already has, and unconditionally
225
298
  // -- every other path skips when the fingerprint matches, which leaves no
226
299
  // recovery for the edit a fingerprint cannot see (a same-size write that
@@ -240,18 +313,73 @@ function doctor() {
240
313
  lines.push(`level: ${standing.level}${standing.pinned
241
314
  ? ' (pinned by config — no progression)'
242
315
  : ` (${standing.counts.passed}/${standing.needed.answers} passing answers in ${standing.repo})`}`);
243
- db.close();
244
316
  }
245
317
  catch (err) {
246
318
  ok = false;
247
319
  lines.push(`database error: ${err instanceof Error ? err.message : String(err)}`);
248
320
  }
321
+ // The memory half. `eklavya memory status` says more, but it is scoped to one
322
+ // project and nobody runs it when the question is "is anything broken" — so
323
+ // the two failures that are otherwise completely silent, a queue paused on a
324
+ // provider and evidence dropped before it reached the database, are reported
325
+ // here. Every read degrades rather than throws.
326
+ const memory = resolved.config.memory;
327
+ lines.push(`memory: ${memory.enabled ? `on · capture ${memory.capture}` : 'off (memory.enabled is false)'}`);
328
+ if (db) {
329
+ const entries = safely(() => countEntries(db), 0);
330
+ const evidence = safely(() => db.prepare('SELECT count(*) n FROM evidence_events').get().n, 0);
331
+ const waiting = safely(() => pendingEventCount(db), 0);
332
+ lines.push(`memory: ${entries} entries, ${evidence} evidence events (${waiting} not yet summarised)`);
333
+ const queue = safely(() => queueDepth(db), { pending: 0, paused: 0, failed: 0, oldest: null });
334
+ lines.push(`memory: queue ${queue.pending} pending · ${queue.paused} paused · ${queue.failed} failed`);
335
+ // The class, never the message. `last_error` is the provider's own prose
336
+ // and has carried a URL with a token in it; the class is what tells someone
337
+ // whether to fix a key or a quota, and it is a fixed vocabulary.
338
+ const classes = (status) => safely(() => db
339
+ .prepare("SELECT DISTINCT error_class FROM memory_jobs WHERE status = ? AND error_class IS NOT NULL ORDER BY error_class")
340
+ .all(status)
341
+ .map((r) => r.error_class)
342
+ .join(', '), '') || 'unclassified';
343
+ if (queue.paused > 0) {
344
+ memoryOk = false;
345
+ lines.push(`memory: FAILED — ${queue.paused} job(s) paused (${classes('paused')}); nothing is being summarised`);
346
+ lines.push('memory: fix the credentials or quota behind providers.observer, then: eklavya memory process');
347
+ }
348
+ if (queue.failed > 0) {
349
+ // Not a failure: a permanently failed job is a batch that will never
350
+ // summarise, and no command repairs it. Saying so beats a clean report.
351
+ lines.push(`memory: ${queue.failed} job(s) failed permanently (${classes('failed')})`);
352
+ }
353
+ // The capture heartbeat: memory that is "on" with nothing arriving is the
354
+ // failure the entry count alone cannot show.
355
+ const newest = safely(() => db.prepare('SELECT MAX(occurred_at) AS at FROM evidence_events').get().at, null);
356
+ lines.push(`memory: last evidence ${newest ?? '— none captured yet'}`);
357
+ const sync = safely(() => syncStatus(db, resolved.config), null);
358
+ lines.push(`memory: sync ${sync?.enabled ? `on -> ${sync.target ?? '(no target set — set sync.target)'}` : 'off'}`);
359
+ }
360
+ const dropped = safely(() => droppedCount(), 0);
361
+ if (dropped > 0) {
362
+ // Dropped, not spooled: these events never reached the database *or* the
363
+ // spool file, so nothing replays them on its own. The transcripts are the
364
+ // only remaining copy.
365
+ memoryOk = false;
366
+ lines.push(`memory: FAILED — ${dropped} event(s) dropped before they reached the database`);
367
+ lines.push('memory: recover them from this checkout’s transcripts with: eklavya memory replay');
368
+ }
369
+ lines.push(`memory: provider ${resolved.config.providers.observer ? 'configured — batches leave this machine' : 'none — nothing leaves this machine'}`);
370
+ db?.close();
249
371
  const fromRepo = resolved.repoPath ? ' (from this repo)' : '';
250
372
  lines.push(`mode: ${resolved.config.mode}${fromRepo}`);
251
373
  lines.push(`focus: ${resolved.config.focus}${resolved.config.focus === 'learn' ? ` (${resolved.config.focus_topic ?? 'no topic set'})` : ''}${fromRepo}`);
252
374
  lines.push(`cadence: ${resolved.config.cadence}${resolved.config.cadence === 'interleaved'
253
375
  ? ` (one question mid-task, min ${resolved.config.min_minutes_between_checkpoints}m apart)`
254
376
  : ' (all questions at the end of the task)'}${fromRepo}`);
377
+ if (resolved.refusedRepoKeys.length > 0) {
378
+ // Not a failure — the setting was correctly ignored — but the loudest
379
+ // thing `doctor` can say short of one, because a repository trying to
380
+ // install a notification sink is worth a look.
381
+ lines.push(`IGNORED: the repo config sets ${resolved.refusedRepoKeys.join(', ')}, which only your global config may set`);
382
+ }
255
383
  if (resolved.overrides.length > 0) {
256
384
  lines.push(`overridden by repo: ${resolved.overrides.join(', ')}`);
257
385
  }
@@ -287,7 +415,7 @@ function doctor() {
287
415
  lines.push('Something is broken. Run: eklavya install');
288
416
  }
289
417
  process.stdout.write(`${lines.join('\n')}\n`);
290
- if (!ok)
418
+ if (!ok || !memoryOk)
291
419
  process.exit(1);
292
420
  }
293
421
  /**
@@ -397,6 +525,504 @@ function dashboardCommand(argv) {
397
525
  process.exit(1);
398
526
  });
399
527
  }
528
+ /**
529
+ * `eklavya memory <subcommand>` — the memory half, outside a session.
530
+ *
531
+ * Project-scoped by default on every read, like the retrieval layer it sits on:
532
+ * another repository's work is noise, and `--all-projects` is the explicit way
533
+ * to ask for it.
534
+ */
535
+ const MEMORY_USAGE = 'Usage: eklavya memory status|search|timeline|show|replay|process|prune|import|export|restore|sync\n' +
536
+ ' run `eklavya --help` for the full list\n';
537
+ function flag(argv, name, fallback) {
538
+ const i = argv.indexOf(name);
539
+ if (i === -1)
540
+ return fallback;
541
+ const value = argv[i + 1];
542
+ if (value === undefined || value.startsWith('--'))
543
+ fail(`${name} needs a value.`);
544
+ return value;
545
+ }
546
+ function numberFlag(argv, name, fallback) {
547
+ const raw = flag(argv, name);
548
+ if (raw === undefined)
549
+ return fallback;
550
+ const n = Number(raw);
551
+ if (!Number.isFinite(n) || n <= 0)
552
+ fail(`${name} needs a positive number.`);
553
+ return Math.floor(n);
554
+ }
555
+ /** The project key the memory tables use — the same one the hooks record under. */
556
+ function currentProject() {
557
+ return identityFor({ cwd: process.cwd(), sessionId: 'cli' }).project;
558
+ }
559
+ function memoryStatus() {
560
+ const db = openDb();
561
+ try {
562
+ const { config } = loadConfig();
563
+ const project = currentProject();
564
+ const queue = queueDepth(db);
565
+ const totals = receiptTotals(db);
566
+ const savings = savingsFrom({
567
+ baseTokens: totals.base,
568
+ deliveredTokens: totals.delivered,
569
+ delivery: totals.confirmed > 0 ? 'confirmed' : 'unknown',
570
+ });
571
+ const lines = [
572
+ `project: ${project}`,
573
+ `capture: ${config.memory.enabled ? config.memory.capture : 'off (memory.enabled is false)'}`,
574
+ `entries: ${countEntries(db, project)} here, ${countEntries(db)} in total`,
575
+ `pending: ${pendingEventCount(db, project)} evidence events here, ${pendingEventCount(db)} in total`,
576
+ `queue: ${queue.pending} pending · ${queue.paused} paused · ${queue.failed} failed`,
577
+ `oldest job: ${queue.oldest ?? '—'}`,
578
+ // Named separately from the summarizer because they answer different
579
+ // questions: one is "will anything leave this machine", the other is
580
+ // "what is actually writing the observations right now".
581
+ `provider: ${config.providers.observer
582
+ ? `${config.providers.observer.kind}:${config.providers.observer.model} (key from $${config.providers.observer.api_key_env})`
583
+ : 'none — nothing leaves this machine'}`,
584
+ `summarizer: ${summarizerFor(config).id}`,
585
+ `spool drops: ${droppedCount()}`,
586
+ `receipts: ${totals.receipts} (${totals.confirmed} confirmed) · base ${totals.base} → delivered ${totals.delivered} tokens`,
587
+ savingsLine(savings),
588
+ ];
589
+ process.stdout.write(`${lines.join('\n')}\n`);
590
+ }
591
+ finally {
592
+ db.close();
593
+ }
594
+ }
595
+ function memorySearch(argv) {
596
+ const query = argv.filter((a, i) => !a.startsWith('--') && !argv[i - 1]?.match(/^--(mode|limit)$/)).join(' ');
597
+ if (!query.trim())
598
+ fail('Usage: eklavya memory search <query> [--mode keyword|semantic|hybrid] [--limit <n>] [--all-projects]');
599
+ const { config } = loadConfig();
600
+ const mode = (flag(argv, '--mode', config.retrieval.mode) ?? 'hybrid');
601
+ if (mode !== 'keyword' && mode !== 'semantic' && mode !== 'hybrid') {
602
+ fail('--mode must be keyword, semantic or hybrid.');
603
+ }
604
+ const db = openDb();
605
+ try {
606
+ const hits = search(db, query, mode, {
607
+ project: currentProject(),
608
+ allProjects: argv.includes('--all-projects'),
609
+ limit: numberFlag(argv, '--limit', 10),
610
+ });
611
+ if (!hits.length) {
612
+ process.stdout.write('No matches.\n');
613
+ return;
614
+ }
615
+ for (const hit of hits) {
616
+ process.stdout.write(`#${hit.entry.id} ${hit.entry.occurred_at.slice(0, 16).replace('T', ' ')} ${hit.entry.title}\n` +
617
+ ` ${hit.entry.type ?? hit.entry.kind} · score ${hit.score.toFixed(3)} · ${hit.via}${hit.entry.import_source ? ` · imported from ${hit.entry.import_source}` : ''}\n`);
618
+ }
619
+ }
620
+ finally {
621
+ db.close();
622
+ }
623
+ }
624
+ function memoryTimeline(argv) {
625
+ const db = openDb();
626
+ try {
627
+ const rows = timeline(db, {
628
+ project: currentProject(),
629
+ limit: numberFlag(argv, '--limit', 20),
630
+ since: flag(argv, '--since') ?? null,
631
+ });
632
+ if (!rows.length) {
633
+ process.stdout.write('Nothing recorded for this project yet.\n');
634
+ return;
635
+ }
636
+ for (const row of rows) {
637
+ process.stdout.write(`#${row.id} ${row.occurred_at.slice(0, 16).replace('T', ' ')} ${row.kind} ${row.title}\n`);
638
+ }
639
+ }
640
+ finally {
641
+ db.close();
642
+ }
643
+ }
644
+ function memoryShow(argv) {
645
+ const id = Number(argv[0]);
646
+ if (!Number.isInteger(id))
647
+ fail('Usage: eklavya memory show <id>');
648
+ const db = openDb();
649
+ try {
650
+ const entry = entryById(db, id);
651
+ if (!entry)
652
+ fail(`No memory entry #${id}.`);
653
+ const tags = entryTags(db, id);
654
+ const lines = [
655
+ `#${entry.id} ${entry.title}`,
656
+ `kind: ${entry.kind}${entry.type ? ` / ${entry.type}` : ''}`,
657
+ `project: ${entry.project}`,
658
+ `occurred: ${entry.occurred_at}`,
659
+ `generator: ${entry.generator}`,
660
+ ...(entry.import_source ? [`imported: from ${entry.import_source} (unassessed — no mastery, no attempts)`] : []),
661
+ ...(entry.superseded_by ? [`superseded by #${entry.superseded_by}`] : []),
662
+ ...(tags.length ? [`tags: ${tags.join(', ')}`] : []),
663
+ ...(entry.files ? [`files: ${JSON.parse(entry.files).join(', ')}`] : []),
664
+ '',
665
+ entry.narrative || '(no narrative)',
666
+ ];
667
+ const facts = entry.facts ? JSON.parse(entry.facts) : [];
668
+ if (facts.length)
669
+ lines.push('', 'Facts:', ...facts.map((f) => ` - ${f}`));
670
+ const events = entryEvents(db, id);
671
+ lines.push('', `Evidence (${events.length}):`);
672
+ for (const event of events) {
673
+ lines.push(` ${event.occurred_at.slice(0, 16).replace('T', ' ')} ${event.kind}${event.tool ? `/${event.tool}` : ''} ${event.body.slice(0, 120).replace(/\s+/g, ' ')}`);
674
+ }
675
+ if (!events.length)
676
+ lines.push(' (none linked — imported or hand-written entries carry no local evidence)');
677
+ process.stdout.write(`${lines.join('\n')}\n`);
678
+ }
679
+ finally {
680
+ db.close();
681
+ }
682
+ }
683
+ function memoryProcess(argv) {
684
+ const db = openDb();
685
+ const { config } = loadConfig();
686
+ // Running this command *is* the "I have fixed the credential" signal: it is
687
+ // what `doctor` tells the developer to run, and nothing else takes a job off
688
+ // 'paused'. Resuming here rather than in the worker keeps it an explicit act
689
+ // — a hook that resumed by itself would spend a rejected key every session.
690
+ // Validate before resuming. `numberFlag` exits on a bad value, and resuming
691
+ // is not undoable: a refused run that had already emptied the pause would
692
+ // tell the developer nothing happened while the queue quietly went back to
693
+ // spending a credential that may still be rejected.
694
+ const maxJobs = numberFlag(argv, '--max', 10);
695
+ const resumed = resumePaused(db);
696
+ processPending(db, config, { maxJobs }).then((result) => {
697
+ process.stdout.write(`${resumed ? `resumed ${resumed} paused · ` : ''}processed ${result.processed} · entries ${result.entries} · failed ${result.failed} · skipped ${result.skipped}\n`);
698
+ db.close();
699
+ }, (err) => {
700
+ db.close();
701
+ fail(`eklavya memory process: ${err.message}`);
702
+ });
703
+ }
704
+ /**
705
+ * Backfills from Claude Code's own transcripts.
706
+ *
707
+ * The hooks only see sessions that happened after Eklavya was installed. This
708
+ * is for the ones before it, and for a session where a hook was misconfigured:
709
+ * the transcript is on disk either way, and it goes through the same privacy
710
+ * filter and converges with whatever the hooks already captured.
711
+ */
712
+ function memoryReplay(argv) {
713
+ const db = openDb();
714
+ try {
715
+ const { config } = loadConfig();
716
+ if (!config.memory.enabled) {
717
+ process.stdout.write('memory.enabled is false, so there is nowhere to replay into.\n');
718
+ return;
719
+ }
720
+ const cwd = process.cwd();
721
+ const files = transcriptsFor(cwd);
722
+ if (!files.length) {
723
+ process.stdout.write(`No Claude Code transcripts found for this checkout.\nLooked in: ${transcriptDirFor(cwd)}\n`);
724
+ return;
725
+ }
726
+ const limit = Number(flag(argv, '--limit', '20'));
727
+ const results = replayProject(db, config, cwd, { limit });
728
+ const total = results.reduce((sum, r) => ({
729
+ read: sum.read + r.read,
730
+ captured: sum.captured + r.captured,
731
+ duplicates: sum.duplicates + r.duplicates,
732
+ excluded: sum.excluded + r.excluded,
733
+ }), { read: 0, captured: 0, duplicates: 0, excluded: 0 });
734
+ process.stdout.write([
735
+ `transcripts: ${results.length} of ${files.length}`,
736
+ `lines read: ${total.read}`,
737
+ `captured: ${total.captured}`,
738
+ `already had: ${total.duplicates}`,
739
+ `excluded: ${total.excluded} (privacy filter, or capture set to minimal)`,
740
+ '',
741
+ 'Run `eklavya memory process` to summarise what was captured.',
742
+ '',
743
+ ].join('\n'));
744
+ }
745
+ finally {
746
+ db.close();
747
+ }
748
+ }
749
+ function memoryPrune() {
750
+ const db = openDb();
751
+ try {
752
+ const { config } = loadConfig();
753
+ if (!config.memory.retention_days) {
754
+ process.stdout.write('memory.retention_days is not set, so raw evidence is kept until deleted by hand.\n');
755
+ return;
756
+ }
757
+ const removed = pruneEvidence(db, config);
758
+ process.stdout.write(`Deleted ${removed} raw evidence events older than ${config.memory.retention_days} days.\n`);
759
+ }
760
+ finally {
761
+ db.close();
762
+ }
763
+ }
764
+ /** The field-disposition report, printed before anything is written. */
765
+ function dispositionReport(fields) {
766
+ const lines = [];
767
+ for (const kind of ['mapped', 'dropped', 'unrecognised']) {
768
+ const group = fields.filter((f) => f.kind === kind);
769
+ if (!group.length)
770
+ continue;
771
+ lines.push('', `${kind} (${group.length}):`);
772
+ for (const f of group) {
773
+ lines.push(` ${f.table}.${f.field}${f.to ? ` -> ${f.to}` : ''}${f.reason ? ` — ${f.reason}` : ''}`);
774
+ }
775
+ }
776
+ return lines.join('\n');
777
+ }
778
+ /**
779
+ * Reads `--map source=/path` and `--map-here source` into a project map.
780
+ *
781
+ * Eklavya keys a project by the checkout's absolute realpath; Claude Mem keys
782
+ * it by a bare name. Without a mapping the import is honest and useless at the
783
+ * moment it matters -- every row lands in a scope no session queries, so a
784
+ * search in the very repository the history came from finds nothing. The
785
+ * importer cannot guess which checkout `eklavya` meant, so this is a flag.
786
+ */
787
+ function projectMapFrom(argv) {
788
+ const map = {};
789
+ for (let i = 0; i < argv.length; i++) {
790
+ if (argv[i] === '--map') {
791
+ const pair = argv[i + 1] ?? '';
792
+ const eq = pair.indexOf('=');
793
+ if (eq <= 0)
794
+ fail('Usage: --map <source-project>=<path-to-checkout>');
795
+ map[pair.slice(0, eq)] = projectKey(findRepoConfig(pair.slice(eq + 1)).repoRoot ?? pair.slice(eq + 1));
796
+ i++;
797
+ }
798
+ else if (argv[i] === '--map-here') {
799
+ const name = argv[i + 1];
800
+ if (!name || name.startsWith('--'))
801
+ fail('Usage: --map-here <source-project>');
802
+ const here = findRepoConfig(process.cwd()).repoRoot;
803
+ // "Here" has to be somewhere. Without a checkout `projectKey` answers with
804
+ // the global bucket, so the flag would file every row under a scope no
805
+ // session queries -- silently, permanently, and to say it had mapped them.
806
+ if (!here)
807
+ fail(`--map-here needs a checkout: ${process.cwd()} is not inside a git repository.`);
808
+ map[name] = projectKey(here);
809
+ i++;
810
+ }
811
+ }
812
+ return map;
813
+ }
814
+ function memoryImport(argv) {
815
+ const flagValues = new Set(argv.flatMap((a, i) => (a === '--map' || a === '--map-here' ? [argv[i + 1] ?? ''] : [])));
816
+ const source = argv.find((a) => !a.startsWith('--') && !flagValues.has(a));
817
+ if (!source)
818
+ fail('Usage: eklavya memory import <path-to-claude-mem.db> [--dry-run] [--resume] [--map <src>=<path>]');
819
+ const dryRun = argv.includes('--dry-run');
820
+ const projectMap = projectMapFrom(argv);
821
+ try {
822
+ const found = inventory(source);
823
+ const lines = [
824
+ `source: ${found.sourcePath}`,
825
+ `schema: ${found.schemaVersion ?? 'unversioned'} (this importer understands up to ${found.supportedMax})`,
826
+ `range: ${found.dateRange.from?.slice(0, 10) ?? '—'} … ${found.dateRange.to?.slice(0, 10) ?? '—'}`,
827
+ 'tables:',
828
+ ...found.tables.map((t) => ` ${t.rows.toString().padStart(7)} ${t.name}${t.known ? '' : ' (unrecognised)'}`),
829
+ 'projects:',
830
+ ...found.projects.map((p) => ` ${p.entries.toString().padStart(7)} ${p.project}`),
831
+ dispositionReport(found.fields),
832
+ ];
833
+ process.stdout.write(`${lines.join('\n')}\n`);
834
+ if (!found.supported)
835
+ fail(`\n${found.problem ?? 'Unsupported source database.'}`);
836
+ if (dryRun) {
837
+ const planned = Object.entries(projectMap);
838
+ const unmapped = found.projects.map((p) => p.project).filter((p) => !(p in projectMap));
839
+ process.stdout.write([
840
+ '',
841
+ ...planned.map(([from, to]) => `would map: ${from} -> ${to}`),
842
+ ...(unmapped.length ? [`would keep as-is: ${unmapped.join(', ')}`] : []),
843
+ 'Dry run: nothing was written, and the source was opened read-only.',
844
+ '',
845
+ ].join('\n'));
846
+ return;
847
+ }
848
+ const db = openDb();
849
+ try {
850
+ const report = importFrom(db, source, { resume: argv.includes('--resume'), projectMap });
851
+ const rows = IMPORTED_TABLES.map((t) => ` ${t.padEnd(18)} read ${report.read[t]} · imported ${report.imported[t]} · already present ${report.skipped[t]}`);
852
+ process.stdout.write([
853
+ '',
854
+ `snapshot: ${report.snapshot}`,
855
+ ...rows,
856
+ ` concept candidates: ${report.candidates} (all unassessed — no mastery, no attempts, no gate touched)`,
857
+ ` evidence links: ${report.links} (drill-down from an entry to the prompts and tool uses behind it)`,
858
+ ` re-indexed: ${report.reindexed} entries`,
859
+ ` validation: ${report.validation.ok ? 'ok' : `FAILED — ${report.validation.notes.join('; ')}`}`,
860
+ ...report.projectsMapped.map((p) => ` mapped: ${p.from} -> ${p.to}`),
861
+ // The unmapped list is the useful half: those rows only ever surface
862
+ // under --all-projects until somebody maps them.
863
+ ...(report.projectsKept.length
864
+ ? [
865
+ ` kept as-is: ${report.projectsKept.join(', ')}`,
866
+ ' (unmapped projects are searchable only with --all-projects; re-run with --map to file them under a checkout)',
867
+ ]
868
+ : []),
869
+ '',
870
+ ].join('\n'));
871
+ if (!report.validation.ok)
872
+ process.exit(1);
873
+ }
874
+ finally {
875
+ db.close();
876
+ }
877
+ }
878
+ catch (err) {
879
+ if (err instanceof ImportError)
880
+ fail(err.message);
881
+ // The source is a hand-typed path, so pointing it at the wrong file is the
882
+ // likeliest mistake there is. The missing-file case was already handled and
883
+ // `restore` says "is not readable JSON" for the same mistake; only this path
884
+ // let a driver error out with a stack through node_modules.
885
+ fail(`eklavya memory import: cannot read ${source} — ${err.message}`);
886
+ }
887
+ }
888
+ function memoryExport(argv) {
889
+ const out = argv.find((a) => !a.startsWith('--'));
890
+ if (!out)
891
+ fail('Usage: eklavya memory export <path>');
892
+ const db = openDb();
893
+ try {
894
+ const payload = exportPayload(db);
895
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
896
+ fs.writeFileSync(out, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
897
+ process.stdout.write(`Wrote ${out} — ${payload.entries.length} entries, schema version ${EXPORT_SCHEMA_VERSION}\n`);
898
+ }
899
+ finally {
900
+ db.close();
901
+ }
902
+ }
903
+ /**
904
+ * `eklavya memory restore <file>` — the other half of the backup pair.
905
+ *
906
+ * Without it `export` writes a file nothing on the machine can read, which
907
+ * makes the rollback drill in the migration guide unrunnable. It is additive
908
+ * and idempotent, so it is also how a second device is brought up to date from
909
+ * a file rather than a shared folder.
910
+ */
911
+ function memoryRestore(argv) {
912
+ const from = argv.find((a) => !a.startsWith('--'));
913
+ if (!from)
914
+ fail('Usage: eklavya memory restore <file>');
915
+ const db = openDb();
916
+ try {
917
+ const r = restoreExport(db, path.resolve(from));
918
+ process.stdout.write([
919
+ `Restored ${from} (export schema version ${r.schemaVersion}):`,
920
+ ` entries: ${r.entries.restored} restored, ${r.entries.skipped} already here`,
921
+ ` evidence: ${r.evidence.restored} restored, ${r.evidence.skipped} already here`,
922
+ ` links: ${r.tags} tag(s), ${r.links} evidence link(s)`,
923
+ ` receipts: ${r.receipts.restored} restored, ${r.receipts.skipped} already here (${r.receiptItems} item(s))`,
924
+ ` reindexed: ${r.reindexed} entries — search index and vectors rebuilt`,
925
+ 'Learning history was not touched: no attempt, mastery or gate row is written by a restore.',
926
+ '',
927
+ ].join('\n'));
928
+ }
929
+ catch (err) {
930
+ if (err instanceof ImportError)
931
+ fail(err.message);
932
+ throw err;
933
+ }
934
+ finally {
935
+ db.close();
936
+ }
937
+ }
938
+ /**
939
+ * `eklavya memory sync push|pull|status [--target <dir>]` (ADR-09).
940
+ *
941
+ * The directory is the whole protocol, so the command has no host, no token and
942
+ * no network error to report — only what it wrote and what it read back.
943
+ * `--target` overrides `sync.target` for one run; it does not override
944
+ * `sync.enabled`, because "point it somewhere for a second" is still a decision
945
+ * to publish this machine's memory.
946
+ */
947
+ function memorySync(argv) {
948
+ const [sub] = argv;
949
+ if (sub !== 'push' && sub !== 'pull' && sub !== 'status') {
950
+ fail('Usage: eklavya memory sync <push|pull|status> [--target <dir>]');
951
+ }
952
+ const target = flag(argv, '--target') ?? null;
953
+ const db = openDb();
954
+ try {
955
+ const { config } = loadConfig();
956
+ if (sub === 'status') {
957
+ const s = syncStatus(db, config, { target });
958
+ const lines = [
959
+ `sync: ${s.enabled ? 'on' : 'off (set sync.enabled)'}`,
960
+ `target: ${s.target ?? '— (set sync.target, or pass --target)'}`,
961
+ `device: ${s.device_id ?? '—'}`,
962
+ `revision: ${s.local_revision}`,
963
+ `pending: ${s.pending} local change${s.pending === 1 ? '' : 's'} to push`,
964
+ `conflicts: ${s.open_conflicts} quarantined`,
965
+ `peers: ${s.peers.length
966
+ ? s.peers.map((p) => `${p.device_id}@${p.last_revision}`).join(', ')
967
+ : 'none seen yet'}`,
968
+ ];
969
+ process.stdout.write(`${lines.join('\n')}\n`);
970
+ return;
971
+ }
972
+ const result = sub === 'push' ? push(db, config, { target }) : pull(db, config, { target });
973
+ if (!result.ok) {
974
+ fail(result.reason === 'disabled'
975
+ ? 'Sync is off. Set sync.enabled to true in ~/.eklavya/config.json.'
976
+ : 'No sync target. Set sync.target to a folder your devices share, or pass --target.');
977
+ }
978
+ if (sub === 'push') {
979
+ const r = result;
980
+ 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`);
981
+ return;
982
+ }
983
+ const r = result;
984
+ 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`);
985
+ if (r.conflicts) {
986
+ process.stdout.write('Quarantined versions are kept whole in sync_conflicts — nothing was overwritten.\n');
987
+ }
988
+ if (r.stalled.length) {
989
+ process.stdout.write(`Stopped early on an unreadable record from: ${r.stalled.join(', ')} — likely still being written. Try again.\n`);
990
+ }
991
+ }
992
+ finally {
993
+ db.close();
994
+ }
995
+ }
996
+ function memoryCommand(argv) {
997
+ const [sub, ...rest] = argv;
998
+ switch (sub) {
999
+ case 'status':
1000
+ return memoryStatus();
1001
+ case 'search':
1002
+ return memorySearch(rest);
1003
+ case 'timeline':
1004
+ return memoryTimeline(rest);
1005
+ case 'show':
1006
+ return memoryShow(rest);
1007
+ case 'replay':
1008
+ return memoryReplay(rest);
1009
+ case 'process':
1010
+ return memoryProcess(rest);
1011
+ case 'prune':
1012
+ return memoryPrune();
1013
+ case 'import':
1014
+ return memoryImport(rest);
1015
+ case 'export':
1016
+ return memoryExport(rest);
1017
+ case 'restore':
1018
+ return memoryRestore(rest);
1019
+ case 'sync':
1020
+ return memorySync(rest);
1021
+ default:
1022
+ process.stderr.write(MEMORY_USAGE);
1023
+ process.exit(1);
1024
+ }
1025
+ }
400
1026
  function main() {
401
1027
  const [command, ...rest] = process.argv.slice(2);
402
1028
  switch (command) {
@@ -422,6 +1048,8 @@ function main() {
422
1048
  return;
423
1049
  case 'dashboard':
424
1050
  return dashboardCommand(rest);
1051
+ case 'memory':
1052
+ return memoryCommand(rest);
425
1053
  case 'doctor':
426
1054
  return doctor();
427
1055
  case 'db-path':