eklavya 1.18.3 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/dashboard.html +570 -15
  3. package/dist/assets/tutor/SKILL.md +5 -5
  4. package/dist/assets/tutor/references/focus-and-level.md +34 -5
  5. package/dist/assets/tutor/references/grading.md +1 -1
  6. package/dist/cli.js +753 -41
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config-path.js +156 -0
  9. package/dist/config-path.js.map +1 -0
  10. package/dist/config.js +448 -31
  11. package/dist/config.js.map +1 -1
  12. package/dist/dashboard.js +357 -1
  13. package/dist/dashboard.js.map +1 -1
  14. package/dist/eval/retrieval-score.js +77 -0
  15. package/dist/eval/retrieval-score.js.map +1 -0
  16. package/dist/hooks/capture-tool.js +107 -0
  17. package/dist/hooks/capture-tool.js.map +1 -0
  18. package/dist/hooks/checkpoint-quiz.js +2 -2
  19. package/dist/hooks/checkpoint-quiz.js.map +1 -1
  20. package/dist/hooks/lib.js +4 -30
  21. package/dist/hooks/lib.js.map +1 -1
  22. package/dist/hooks/memory-lib.js +181 -0
  23. package/dist/hooks/memory-lib.js.map +1 -0
  24. package/dist/hooks/pre-tool-gate.js +2 -2
  25. package/dist/hooks/pre-tool-gate.js.map +1 -1
  26. package/dist/hooks/prompt-submit-nudge.js +57 -20
  27. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  28. package/dist/hooks/session-start.js +110 -80
  29. package/dist/hooks/session-start.js.map +1 -1
  30. package/dist/hooks/stop-quiz-check.js +58 -38
  31. package/dist/hooks/stop-quiz-check.js.map +1 -1
  32. package/dist/hooks/subagent-start.js +2 -2
  33. package/dist/hooks/subagent-start.js.map +1 -1
  34. package/dist/install.js +37 -0
  35. package/dist/install.js.map +1 -1
  36. package/dist/memory/capture.js +123 -0
  37. package/dist/memory/capture.js.map +1 -0
  38. package/dist/memory/code.js +186 -0
  39. package/dist/memory/code.js.map +1 -0
  40. package/dist/memory/collections.js +87 -0
  41. package/dist/memory/collections.js.map +1 -0
  42. package/dist/memory/embed.js +81 -0
  43. package/dist/memory/embed.js.map +1 -0
  44. package/dist/memory/hosts.js +71 -0
  45. package/dist/memory/hosts.js.map +1 -0
  46. package/dist/memory/identity.js +74 -0
  47. package/dist/memory/identity.js.map +1 -0
  48. package/dist/memory/import.js +916 -0
  49. package/dist/memory/import.js.map +1 -0
  50. package/dist/memory/learning.js +148 -0
  51. package/dist/memory/learning.js.map +1 -0
  52. package/dist/memory/notify.js +220 -0
  53. package/dist/memory/notify.js.map +1 -0
  54. package/dist/memory/privacy.js +117 -0
  55. package/dist/memory/privacy.js.map +1 -0
  56. package/dist/memory/provider.js +155 -0
  57. package/dist/memory/provider.js.map +1 -0
  58. package/dist/memory/recall.js +327 -0
  59. package/dist/memory/recall.js.map +1 -0
  60. package/dist/memory/replay.js +174 -0
  61. package/dist/memory/replay.js.map +1 -0
  62. package/dist/memory/search.js +143 -0
  63. package/dist/memory/search.js.map +1 -0
  64. package/dist/memory/spool.js +93 -0
  65. package/dist/memory/spool.js.map +1 -0
  66. package/dist/memory/store.js +399 -0
  67. package/dist/memory/store.js.map +1 -0
  68. package/dist/memory/summarize.js +178 -0
  69. package/dist/memory/summarize.js.map +1 -0
  70. package/dist/memory/sync.js +595 -0
  71. package/dist/memory/sync.js.map +1 -0
  72. package/dist/memory/tokens.js +55 -0
  73. package/dist/memory/tokens.js.map +1 -0
  74. package/dist/memory/worker.js +207 -0
  75. package/dist/memory/worker.js.map +1 -0
  76. package/dist/migrations/009_memory.sql +226 -0
  77. package/dist/migrations/010_import.sql +26 -0
  78. package/dist/migrations/011_sync.sql +76 -0
  79. package/dist/migrations/012_job_backoff.sql +13 -0
  80. package/dist/migrations/013_batch_provenance.sql +18 -0
  81. package/dist/migrations/014_batch_events_index.sql +18 -0
  82. package/dist/packs.js +26 -12
  83. package/dist/packs.js.map +1 -1
  84. package/dist/paths.js +60 -0
  85. package/dist/paths.js.map +1 -1
  86. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  87. package/dist/plugin/agents/tutor.md +3 -1
  88. package/dist/plugin/cli/CLAUDE.md +32 -8
  89. package/dist/plugin/cli/eklavya-gate +80 -9
  90. package/dist/plugin/hooks/CLAUDE.md +44 -19
  91. package/dist/plugin/hooks/hooks.json +10 -0
  92. package/dist/plugin/scripts/install-git-hook.sh +2 -1
  93. package/dist/plugin/skills/CLAUDE.md +29 -17
  94. package/dist/plugin/skills/gate/SKILL.md +1 -1
  95. package/dist/plugin/skills/level/SKILL.md +3 -3
  96. package/dist/plugin/skills/memory/SKILL.md +88 -0
  97. package/dist/plugin/skills/mode/SKILL.md +24 -16
  98. package/dist/plugin/skills/pack/SKILL.md +8 -4
  99. package/dist/plugin/skills/progress/SKILL.md +1 -1
  100. package/dist/plugin/skills/quiz/SKILL.md +2 -2
  101. package/dist/plugin/skills/setup/SKILL.md +25 -11
  102. package/dist/plugin/skills/tutor/SKILL.md +5 -5
  103. package/dist/plugin/skills/tutor/references/focus-and-level.md +34 -5
  104. package/dist/plugin/skills/tutor/references/grading.md +1 -1
  105. package/dist/session.js +1 -1
  106. package/dist/statusline.js +20 -6
  107. package/dist/statusline.js.map +1 -1
  108. package/dist/store.js +2 -2
  109. package/dist/store.js.map +1 -1
  110. package/dist/surface.js +1 -1
  111. package/dist/time.js +41 -0
  112. package/dist/time.js.map +1 -0
  113. package/dist/tools/code_tools.js +73 -0
  114. package/dist/tools/code_tools.js.map +1 -0
  115. package/dist/tools/collection_tools.js +92 -0
  116. package/dist/tools/collection_tools.js.map +1 -0
  117. package/dist/tools/config_tools.js +165 -32
  118. package/dist/tools/config_tools.js.map +1 -1
  119. package/dist/tools/get_learner_profile.js +2 -1
  120. package/dist/tools/get_learner_profile.js.map +1 -1
  121. package/dist/tools/get_session_quiz_plan.js +15 -9
  122. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  123. package/dist/tools/index.js +15 -0
  124. package/dist/tools/index.js.map +1 -1
  125. package/dist/tools/memory_read_tools.js +277 -0
  126. package/dist/tools/memory_read_tools.js.map +1 -0
  127. package/dist/tools/memory_write_tools.js +115 -0
  128. package/dist/tools/memory_write_tools.js.map +1 -0
  129. package/dist/tools/types.js +1 -1
  130. package/dist/tools/types.js.map +1 -1
  131. package/dist/user-skill/eklavya/SKILL.md +95 -25
  132. 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, findRepoConfig, mainRepoRoot, migrateLegacyRepoConfig, } 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
 
@@ -28,9 +38,10 @@ Usage:
28
38
  eklavya uninstall [--purge] Remove it (--purge also deletes your learning history)
29
39
  eklavya export-rules [--out <file>] Write the tutor pedagogy as a Cursor rules file
30
40
  eklavya config get Show the effective configuration
31
- eklavya config set <key> <value> Change a setting (add --repo to scope it to this repo)
32
- e.g. mode ambient|enforced|off, focus project|concept|learn,
33
- cadence interleaved|end,
41
+ eklavya config set <key> <value> Change a setting (--project scopes it to this codebase,
42
+ stored under ~/.eklavya/projects/, never in the repo)
43
+ e.g. quiz.enabled true|false, quiz.enforced true|false,
44
+ focus project|concept|learn, cadence interleaved|end,
34
45
  difficulty auto|easy|medium|hard
35
46
  add --topic <topic> when setting focus to "learn"
36
47
  eklavya dashboard [--port <n>] Serve the learning dashboard and open it in your browser
@@ -39,10 +50,49 @@ Usage:
39
50
  eklavya doctor Check the install, apply concept packs, and say what to fix
40
51
  eklavya db-path Print the database location
41
52
 
42
- Config keys: mode, focus, focus_topic, cadence, difficulty, level_up_after,
53
+ Memory:
54
+ eklavya memory status Entries, pending evidence, queue depth, provider and savings
55
+ eklavya memory search <query> Search this project's memory
56
+ [--mode keyword|semantic|hybrid] [--limit <n>] [--all-projects]
57
+ eklavya memory timeline Recent entries, newest first [--limit <n>] [--since <iso date>]
58
+ eklavya memory show <id> One entry, with the evidence it was built from
59
+ eklavya memory replay [--limit <n>] Backfill from this checkout's Claude Code transcripts
60
+ Covers sessions from before the install, and any a hook missed
61
+ eklavya memory process [--max <n>] Drain the observation queue now
62
+ Resumes jobs paused on a credential or quota — run it once you have fixed one
63
+ eklavya memory prune Delete raw evidence past memory.retention_days
64
+ eklavya memory import <source.db> Import a Claude Mem database [--dry-run] [--resume]
65
+ --dry-run reads the source and reports; it writes nothing
66
+ --map <source>=<path> file that source project under a checkout
67
+ --map-here <source> the same, for the checkout you are in
68
+ eklavya memory export <file> Versioned JSON of entries, tags, evidence links and receipts
69
+ eklavya memory restore <file> Read that file back in. Additive and idempotent — a second
70
+ restore adds nothing, and no attempt, mastery or gate row is
71
+ touched. Refuses a schema version it does not understand
72
+ eklavya memory sync <push|pull|status> Exchange memory with your other devices through a shared
73
+ folder [--target <dir>]. Memory entries, tags and tombstones
74
+ only — attempts, mastery, gates and receipts never leave.
75
+ Needs sync.enabled and sync.target; does nothing without both
76
+
77
+ Config keys: focus, focus_topic, cadence, difficulty, level_up_after,
43
78
  level_up_accuracy, pass_threshold, max_questions_per_task,
44
79
  min_minutes_between_quizzes, min_minutes_between_checkpoints,
45
80
  max_new_concepts_per_session, max_stop_blocks_per_session, quiet
81
+ Config namespaces (nested; edit ~/.eklavya/config.json or this project's file directly):
82
+ quiz.{enabled, enforced} — whether questions happen, and whether they gate
83
+ commits. Separate from memory: silencing questions never stops
84
+ recording. (mode: ambient|enforced|off is the retired spelling,
85
+ still read so older configs keep working)
86
+ memory.{enabled, capture: full|minimal|off, batch_max_events, retention_days}
87
+ privacy.{exclude_paths, exclude_tools, redact_patterns}
88
+ retrieval.{mode: keyword|semantic|hybrid, max_items, max_tokens, cross_project}
89
+ providers.{observer, embeddings} — each null or {kind, model, api_key_env};
90
+ api_key_env names the variable holding the key, never the key itself
91
+ notifications.{enabled, sinks} — sinks are {kind: webhook|command|file, target,
92
+ args, events}; off by default, and a send cannot be recalled
93
+ sync.{enabled, target, device_id} — target is a folder both devices can see
94
+ (Dropbox, iCloud, Syncthing, a share); off and unset by default.
95
+ device_id is normally left null: it is generated once per install
46
96
  `;
47
97
  function fail(message) {
48
98
  process.stderr.write(`${message}\n`);
@@ -138,21 +188,62 @@ ${tutorSections().join('\n\n')}
138
188
  process.stdout.write(rules);
139
189
  }
140
190
  function configCommand(args) {
141
- const [action, key, value] = args;
142
- const scopeRepo = args.includes('--repo');
191
+ // Before reading or writing anything: a leftover `.eklavya.json` in the
192
+ // checkout is moved out first, so `config get` reports one source of truth
193
+ // and `config set --project` cannot leave settings split across two files.
194
+ const here = findRepoConfig().repoRoot;
195
+ if (here)
196
+ migrateLegacyRepoConfig(here, mainRepoRoot(here));
197
+ const [action, rawKey, rawValue] = args;
198
+ // `mode` is read from config files forever, but it is no longer written to
199
+ // one. A `config set` is somebody typing today, so it is the moment to hand
200
+ // them the names that replaced it -- translating and saying so beats both a
201
+ // dead-end "unknown setting" and silently writing a key that nothing lists.
202
+ let key = rawKey;
203
+ let value = rawValue;
204
+ let modeNote = null;
205
+ // Both flags, never one. `mode` named a *pair* of states, so translating it
206
+ // to a single dotted key left the other flag standing: `mode ambient` wrote
207
+ // `quiz.enabled true` over an existing `quiz.enforced: true` and reported
208
+ // success while commits stayed gated -- and `mode enforced` against an
209
+ // existing `quiz.enabled: false` was a silent no-op that `coerceNamespaces`
210
+ // undid and `doctor` then reported as a contradiction. `set_config` always
211
+ // wrote the pair; this is the CLI catching up.
212
+ let modeQuiz = null;
213
+ if (rawKey === 'mode') {
214
+ if (rawValue !== 'ambient' && rawValue !== 'enforced' && rawValue !== 'off') {
215
+ fail('`mode` was replaced by `quiz.enabled` and `quiz.enforced`. Set those directly: eklavya config set quiz.enabled false');
216
+ }
217
+ key = 'quiz';
218
+ modeQuiz = {
219
+ enabled: rawValue !== 'off',
220
+ enforced: rawValue === 'enforced',
221
+ };
222
+ value = JSON.stringify(modeQuiz);
223
+ modeNote =
224
+ `note: \`mode ${rawValue}\` is now \`quiz.enabled ${modeQuiz.enabled}, quiz.enforced ${modeQuiz.enforced}\`. ` +
225
+ (rawValue === 'off'
226
+ ? 'This stops the questions only — memory keeps recording; `memory.enabled false` is that switch.'
227
+ : 'Memory is governed separately by `memory.enabled`.');
228
+ }
229
+ // `--project` is the name; `--repo` is what it was called when the file lived
230
+ // in the repository, kept because it is in every doc and shell history written
231
+ // before the move. Accepting only one of them silently wrote to the global
232
+ // config instead, which is a setting landing somewhere nobody asked for.
233
+ const scopeRepo = args.includes('--project') || args.includes('--repo');
143
234
  const resolved = loadConfig();
144
235
  if (!action || action === 'get') {
145
236
  process.stdout.write(`${JSON.stringify(resolved.config, null, 2)}\n`);
146
237
  process.stdout.write(`\nglobal: ${resolved.globalPath}\n`);
147
- process.stdout.write(`repo: ${resolved.repoPath ?? '(none)'}\n`);
238
+ process.stdout.write(`project: ${resolved.projectPath ?? '(none — not in a git repository)'}\n`);
148
239
  return;
149
240
  }
150
241
  if (action !== 'set')
151
242
  fail(`Unknown config action "${action}".`);
152
243
  if (!key || value === undefined)
153
244
  fail('Usage: eklavya config set <key> <value>');
154
- if (!(key in DEFAULT_CONFIG)) {
155
- fail(`Unknown setting "${key}". Known: ${Object.keys(DEFAULT_CONFIG).join(', ')}`);
245
+ if (!isKnownKey(key)) {
246
+ fail(`Unknown setting "${key}". Known: ${knownKeys().join(', ')}`);
156
247
  }
157
248
  // `focus learn` is useless without a topic, so let one call say both rather
158
249
  // than leaving a state that the planner will only refuse later.
@@ -166,38 +257,72 @@ function configCommand(args) {
166
257
  if (key === 'focus' && value === 'learn' && topic === undefined) {
167
258
  fail('focus "learn" needs a topic: eklavya config set focus learn --topic <topic>');
168
259
  }
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;
260
+ // Typed by the schema at that path rather than guessed from the text. A
261
+ // topic of "2" is a topic; `memory.batch_max_events` of "40" is a number,
262
+ // and only the default sitting there knows which is which.
263
+ const parsed = modeQuiz ?? parseValue(key, value);
264
+ // Nothing here writes into the checkout. `--project` (and its older spelling
265
+ // `--repo`) means "this project", not "this repository's working tree": the
266
+ // file lands under ~/.eklavya/projects/, keyed by the checkout's path. There
267
+ // is no forbidden-key list any more, because there is no longer such a thing
268
+ // as a config file that arrived from somebody else.
182
269
  let target;
183
270
  if (scopeRepo) {
184
- if (!resolved.repoRoot)
185
- fail('Not inside a git repository, so there is nowhere to write .eklavya.json.');
186
- target = resolved.repoPath ?? path.join(resolved.repoRoot, REPO_CONFIG_FILE);
271
+ if (!resolved.projectPath) {
272
+ fail('Not inside a git repository, so there is no project to scope this to.');
273
+ }
274
+ target = resolved.projectPath;
187
275
  }
188
276
  else {
189
277
  target = resolved.globalPath;
190
278
  }
191
- writeConfigFile(target, patch);
279
+ // Built against the file being written, not against nothing: the two config
280
+ // files merge with a shallow spread, so a patch that replaced a whole
281
+ // namespace would drop every other key already set in it.
282
+ const existing = readConfigFile(target);
283
+ const patch = patchFor(existing, key, parsed);
284
+ if (topic !== undefined && key === 'focus')
285
+ patch.focus_topic = topic;
286
+ // Which checkout this file is about, so a slug collision is detected rather
287
+ // than applied to the wrong repository. See `belongsTo` in config.ts.
288
+ if (scopeRepo && resolved.repoRoot)
289
+ patch.project = mainRepoRoot(resolved.repoRoot);
290
+ try {
291
+ writeConfigFile(target, patch);
292
+ }
293
+ catch (err) {
294
+ fail(err instanceof Error ? err.message : String(err));
295
+ }
192
296
  for (const [k, v] of Object.entries(patch)) {
193
297
  process.stdout.write(`${k} = ${JSON.stringify(v)} -> ${target}\n`);
194
298
  }
299
+ if (modeNote)
300
+ process.stdout.write(`${modeNote}\n`);
301
+ }
302
+ /** Never throws: `doctor` is also what someone runs on a half-built database. */
303
+ function safely(fn, fallback) {
304
+ try {
305
+ return fn();
306
+ }
307
+ catch {
308
+ return fallback;
309
+ }
195
310
  }
196
311
  function doctor() {
197
312
  const file = dbPath();
313
+ // `doctor` is where somebody goes when something is not taking effect, so it
314
+ // is the second place the legacy move runs -- a settings file still sitting
315
+ // in the checkout is exactly that complaint.
316
+ const repoHere = findRepoConfig().repoRoot;
317
+ if (repoHere)
318
+ migrateLegacyRepoConfig(repoHere, mainRepoRoot(repoHere));
198
319
  const resolved = loadConfig();
199
320
  const lines = [];
200
321
  let ok = true;
322
+ // Kept apart from `ok` so the blanket remedy below stays true: `eklavya
323
+ // install` repairs a broken install and cannot do a thing about a paused
324
+ // queue. A memory failure still exits non-zero; it just names its own fix.
325
+ let memoryOk = true;
201
326
  lines.push(`home: ${eklavyaHome()}`);
202
327
  // The install checks come first because they are what someone is looking for
203
328
  // when Eklavya has gone quiet. Everything below reads fine on an install that
@@ -219,8 +344,12 @@ function doctor() {
219
344
  lines.push(' this command cannot see it. Your learning history is shared either way.');
220
345
  lines.push(`database: ${file}${fs.existsSync(file) ? '' : ' (not created yet)'}`);
221
346
  let edgesDropped = 0;
347
+ // Held open past the try so the memory section below can read it, and can
348
+ // still report when it is null -- the spool drop count is exactly the number
349
+ // that matters when the database is the thing that is broken.
350
+ let db = null;
222
351
  try {
223
- const db = openDb(file);
352
+ db = openDb(file);
224
353
  // Applied here, on the connection `doctor` already has, and unconditionally
225
354
  // -- every other path skips when the fingerprint matches, which leaves no
226
355
  // recovery for the edit a fingerprint cannot see (a same-size write that
@@ -240,21 +369,93 @@ function doctor() {
240
369
  lines.push(`level: ${standing.level}${standing.pinned
241
370
  ? ' (pinned by config — no progression)'
242
371
  : ` (${standing.counts.passed}/${standing.needed.answers} passing answers in ${standing.repo})`}`);
243
- db.close();
244
372
  }
245
373
  catch (err) {
246
374
  ok = false;
247
375
  lines.push(`database error: ${err instanceof Error ? err.message : String(err)}`);
248
376
  }
249
- const fromRepo = resolved.repoPath ? ' (from this repo)' : '';
250
- lines.push(`mode: ${resolved.config.mode}${fromRepo}`);
251
- lines.push(`focus: ${resolved.config.focus}${resolved.config.focus === 'learn' ? ` (${resolved.config.focus_topic ?? 'no topic set'})` : ''}${fromRepo}`);
377
+ // The memory half. `eklavya memory status` says more, but it is scoped to one
378
+ // project and nobody runs it when the question is "is anything broken" — so
379
+ // the two failures that are otherwise completely silent, a queue paused on a
380
+ // provider and evidence dropped before it reached the database, are reported
381
+ // here. Every read degrades rather than throws.
382
+ // The contradiction `coerce()` silently resolves, said out loud exactly once,
383
+ // here. `quiz.enabled: false` with `quiz.enforced: true` is a gate whose
384
+ // questions never get asked; `coerce` drops the enforcement so nobody is
385
+ // locked out of their own repository, but a lead who wrote that line believes
386
+ // commits are being held and they are not. `doctor` is where somebody goes to
387
+ // find out why -- so it has to be findable here rather than only in a comment.
388
+ const rawQuiz = resolved.raw.quiz;
389
+ if (rawQuiz?.enabled === false && rawQuiz?.enforced === true) {
390
+ ok = false;
391
+ lines.push('conflict: FAILED — quiz.enabled is false and quiz.enforced is true. A gate needs ' +
392
+ 'passed questions and nothing will ask any, so the enforcement is being ignored ' +
393
+ 'rather than blocking every commit. Set quiz.enabled true to gate commits, or drop ' +
394
+ 'quiz.enforced to accept the silence.');
395
+ }
396
+ const memory = resolved.config.memory;
397
+ lines.push(`memory: ${memory.enabled ? `on · capture ${memory.capture}` : 'off (memory.enabled is false)'}`);
398
+ if (db) {
399
+ const entries = safely(() => countEntries(db), 0);
400
+ const evidence = safely(() => db.prepare('SELECT count(*) n FROM evidence_events').get().n, 0);
401
+ const waiting = safely(() => pendingEventCount(db), 0);
402
+ lines.push(`memory: ${entries} entries, ${evidence} evidence events (${waiting} not yet summarised)`);
403
+ const queue = safely(() => queueDepth(db), { pending: 0, paused: 0, failed: 0, oldest: null });
404
+ lines.push(`memory: queue ${queue.pending} pending · ${queue.paused} paused · ${queue.failed} failed`);
405
+ // The class, never the message. `last_error` is the provider's own prose
406
+ // and has carried a URL with a token in it; the class is what tells someone
407
+ // whether to fix a key or a quota, and it is a fixed vocabulary.
408
+ const classes = (status) => safely(() => db
409
+ .prepare("SELECT DISTINCT error_class FROM memory_jobs WHERE status = ? AND error_class IS NOT NULL ORDER BY error_class")
410
+ .all(status)
411
+ .map((r) => r.error_class)
412
+ .join(', '), '') || 'unclassified';
413
+ if (queue.paused > 0) {
414
+ memoryOk = false;
415
+ lines.push(`memory: FAILED — ${queue.paused} job(s) paused (${classes('paused')}); nothing is being summarised`);
416
+ lines.push('memory: fix the credentials or quota behind providers.observer, then: eklavya memory process');
417
+ }
418
+ if (queue.failed > 0) {
419
+ // Not a failure: a permanently failed job is a batch that will never
420
+ // summarise, and no command repairs it. Saying so beats a clean report.
421
+ lines.push(`memory: ${queue.failed} job(s) failed permanently (${classes('failed')})`);
422
+ }
423
+ // The capture heartbeat: memory that is "on" with nothing arriving is the
424
+ // failure the entry count alone cannot show.
425
+ const newest = safely(() => db.prepare('SELECT MAX(occurred_at) AS at FROM evidence_events').get().at, null);
426
+ lines.push(`memory: last evidence ${newest ?? '— none captured yet'}`);
427
+ const sync = safely(() => syncStatus(db, resolved.config), null);
428
+ lines.push(`memory: sync ${sync?.enabled ? `on -> ${sync.target ?? '(no target set — set sync.target)'}` : 'off'}`);
429
+ }
430
+ const dropped = safely(() => droppedCount(), 0);
431
+ if (dropped > 0) {
432
+ // Dropped, not spooled: these events never reached the database *or* the
433
+ // spool file, so nothing replays them on its own. The transcripts are the
434
+ // only remaining copy.
435
+ memoryOk = false;
436
+ lines.push(`memory: FAILED — ${dropped} event(s) dropped before they reached the database`);
437
+ lines.push('memory: recover them from this checkout’s transcripts with: eklavya memory replay');
438
+ }
439
+ lines.push(`memory: provider ${resolved.config.providers.observer ? 'configured — batches leave this machine' : 'none — nothing leaves this machine'}`);
440
+ db?.close();
441
+ // Per key, not per file. A project config that sets only `quiz` must not make
442
+ // `focus` and `cadence` claim they came from it -- they came from the
443
+ // defaults, and a line that names the wrong source is the same class of bug
444
+ // as the dial this release renamed. `projectPath` is a path Eklavya *would*
445
+ // write to, so the file is read rather than assumed to exist.
446
+ const projectKeys = new Set(resolved.projectPath ? Object.keys(readConfigFile(resolved.projectPath)) : []);
447
+ const from = (key) => (projectKeys.has(key) ? ' (set for this project)' : '');
448
+ const q = resolved.config.quiz;
449
+ lines.push(`quiz: ${q.enabled ? 'on' : 'off — memory is unaffected'}${q.enforced ? ' · enforced (commits gated)' : ''}${from('quiz')}`);
450
+ lines.push(`focus: ${resolved.config.focus}${resolved.config.focus === 'learn' ? ` (${resolved.config.focus_topic ?? 'no topic set'})` : ''}${from('focus')}`);
252
451
  lines.push(`cadence: ${resolved.config.cadence}${resolved.config.cadence === 'interleaved'
253
452
  ? ` (one question mid-task, min ${resolved.config.min_minutes_between_checkpoints}m apart)`
254
- : ' (all questions at the end of the task)'}${fromRepo}`);
453
+ : ' (all questions at the end of the task)'}${from('cadence')}`);
255
454
  if (resolved.overrides.length > 0) {
256
- lines.push(`overridden by repo: ${resolved.overrides.join(', ')}`);
455
+ lines.push(`overridden for this project: ${resolved.overrides.join(', ')}`);
257
456
  }
457
+ if (resolved.projectPath)
458
+ lines.push(`project: ${resolved.projectPath}`);
258
459
  // Packs, and the one place a broken one is visible. `loadPacks` never throws
259
460
  // -- a malformed file in ~/.eklavya/packs/ makes one pack unavailable, not
260
461
  // Eklavya -- so without this line the failure is a domain that quietly never
@@ -271,6 +472,16 @@ function doctor() {
271
472
  // prerequisite it was meant to hang off simply is not there.
272
473
  lines.push(`packs: ${edgesDropped} edge(s) dropped — an endpoint named a slug that does not exist`);
273
474
  }
475
+ // The one remaining way an Eklavya file ends up in a checkout, and it only
476
+ // ever got there before the move. Named rather than fixed: a committed pack
477
+ // is authored content somebody reviewed, so relocating it is their call.
478
+ const inRepo = good.filter((p) => p.scope === 'repo');
479
+ if (inRepo.length > 0) {
480
+ lines.push(`packs: ${inRepo.length} still inside the checkout (${inRepo
481
+ .map((p) => p.pack.pack)
482
+ .join(', ')}) — they load, but Eklavya no longer writes there;`);
483
+ lines.push(`packs: move them to ~/.eklavya/projects/<checkout>/packs/ to keep the repo clean`);
484
+ }
274
485
  for (const bad of packs.filter((p) => !p.pack)) {
275
486
  // Deliberately does NOT set `ok`. A bad pack costs that pack and nothing
276
487
  // else, and the blanket remedy below is `eklavya install`, which never
@@ -287,7 +498,7 @@ function doctor() {
287
498
  lines.push('Something is broken. Run: eklavya install');
288
499
  }
289
500
  process.stdout.write(`${lines.join('\n')}\n`);
290
- if (!ok)
501
+ if (!ok || !memoryOk)
291
502
  process.exit(1);
292
503
  }
293
504
  /**
@@ -299,7 +510,7 @@ function doctor() {
299
510
  * not a place to report that Eklavya is unwell.
300
511
  *
301
512
  * Only the earned level and the per-session off switch need the database. The
302
- * dials themselves come from `.eklavya.json`, so an install with no database
513
+ * dials themselves come from the config files, so an install with no database
303
514
  * yet — or one that cannot be opened — still shows its dials rather than
304
515
  * nothing. The database is consulted in its own try/catch for exactly that
305
516
  * reason: a locked or corrupt file costs the level, never the bar.
@@ -307,7 +518,7 @@ function doctor() {
307
518
  async function statuslineCommand(argv) {
308
519
  try {
309
520
  // Claude Code writes a JSON blob to stdin (cwd, model, session). We want
310
- // the cwd, so the repo-scoped .eklavya.json is the one that answers.
521
+ // the cwd, so this project's config is the one that answers.
311
522
  //
312
523
  // Bounded deliberately. On Windows the host may wrap a command in a
313
524
  // PowerShell block that swallows the pipe, so `end` never fires and a naive
@@ -318,7 +529,7 @@ async function statuslineCommand(argv) {
318
529
  let sid = null;
319
530
  // Parsed in its own try: input we cannot read is a reason to fall back to
320
531
  // the working directory, not a reason to show the developer nothing. The
321
- // dials are still true; only the choice of .eklavya.json was in doubt.
532
+ // dials are still true; only which project's config applies was in doubt.
322
533
  try {
323
534
  if (raw.trim()) {
324
535
  // Strip a BOM: some Windows shells prepend one, and JSON.parse throws
@@ -342,8 +553,9 @@ async function statuslineCommand(argv) {
342
553
  let db = null;
343
554
  try {
344
555
  db = new Database(dbPath(), { readonly: true });
345
- // A silenced session shows no bar. It says the same thing `mode: off`
346
- // says, and a bar still reciting the dials of a session that will not
556
+ // A silenced session shows no bar. It says the same thing
557
+ // `quiz.enabled: false` says, and a bar still reciting the dials of a
558
+ // session that will not
347
559
  // ask anything is the kind of small lie that costs a bug report.
348
560
  //
349
561
  // Only when the host named the session. The fallback would be the
@@ -397,6 +609,504 @@ function dashboardCommand(argv) {
397
609
  process.exit(1);
398
610
  });
399
611
  }
612
+ /**
613
+ * `eklavya memory <subcommand>` — the memory half, outside a session.
614
+ *
615
+ * Project-scoped by default on every read, like the retrieval layer it sits on:
616
+ * another repository's work is noise, and `--all-projects` is the explicit way
617
+ * to ask for it.
618
+ */
619
+ const MEMORY_USAGE = 'Usage: eklavya memory status|search|timeline|show|replay|process|prune|import|export|restore|sync\n' +
620
+ ' run `eklavya --help` for the full list\n';
621
+ function flag(argv, name, fallback) {
622
+ const i = argv.indexOf(name);
623
+ if (i === -1)
624
+ return fallback;
625
+ const value = argv[i + 1];
626
+ if (value === undefined || value.startsWith('--'))
627
+ fail(`${name} needs a value.`);
628
+ return value;
629
+ }
630
+ function numberFlag(argv, name, fallback) {
631
+ const raw = flag(argv, name);
632
+ if (raw === undefined)
633
+ return fallback;
634
+ const n = Number(raw);
635
+ if (!Number.isFinite(n) || n <= 0)
636
+ fail(`${name} needs a positive number.`);
637
+ return Math.floor(n);
638
+ }
639
+ /** The project key the memory tables use — the same one the hooks record under. */
640
+ function currentProject() {
641
+ return identityFor({ cwd: process.cwd(), sessionId: 'cli' }).project;
642
+ }
643
+ function memoryStatus() {
644
+ const db = openDb();
645
+ try {
646
+ const { config } = loadConfig();
647
+ const project = currentProject();
648
+ const queue = queueDepth(db);
649
+ const totals = receiptTotals(db);
650
+ const savings = savingsFrom({
651
+ baseTokens: totals.base,
652
+ deliveredTokens: totals.delivered,
653
+ delivery: totals.confirmed > 0 ? 'confirmed' : 'unknown',
654
+ });
655
+ const lines = [
656
+ `project: ${project}`,
657
+ `capture: ${config.memory.enabled ? config.memory.capture : 'off (memory.enabled is false)'}`,
658
+ `entries: ${countEntries(db, project)} here, ${countEntries(db)} in total`,
659
+ `pending: ${pendingEventCount(db, project)} evidence events here, ${pendingEventCount(db)} in total`,
660
+ `queue: ${queue.pending} pending · ${queue.paused} paused · ${queue.failed} failed`,
661
+ `oldest job: ${queue.oldest ?? '—'}`,
662
+ // Named separately from the summarizer because they answer different
663
+ // questions: one is "will anything leave this machine", the other is
664
+ // "what is actually writing the observations right now".
665
+ `provider: ${config.providers.observer
666
+ ? `${config.providers.observer.kind}:${config.providers.observer.model} (key from $${config.providers.observer.api_key_env})`
667
+ : 'none — nothing leaves this machine'}`,
668
+ `summarizer: ${summarizerFor(config).id}`,
669
+ `spool drops: ${droppedCount()}`,
670
+ `receipts: ${totals.receipts} (${totals.confirmed} confirmed) · base ${totals.base} → delivered ${totals.delivered} tokens`,
671
+ savingsLine(savings),
672
+ ];
673
+ process.stdout.write(`${lines.join('\n')}\n`);
674
+ }
675
+ finally {
676
+ db.close();
677
+ }
678
+ }
679
+ function memorySearch(argv) {
680
+ const query = argv.filter((a, i) => !a.startsWith('--') && !argv[i - 1]?.match(/^--(mode|limit)$/)).join(' ');
681
+ if (!query.trim())
682
+ fail('Usage: eklavya memory search <query> [--mode keyword|semantic|hybrid] [--limit <n>] [--all-projects]');
683
+ const { config } = loadConfig();
684
+ const mode = (flag(argv, '--mode', config.retrieval.mode) ?? 'hybrid');
685
+ if (mode !== 'keyword' && mode !== 'semantic' && mode !== 'hybrid') {
686
+ fail('--mode must be keyword, semantic or hybrid.');
687
+ }
688
+ const db = openDb();
689
+ try {
690
+ const hits = search(db, query, mode, {
691
+ project: currentProject(),
692
+ allProjects: argv.includes('--all-projects'),
693
+ limit: numberFlag(argv, '--limit', 10),
694
+ });
695
+ if (!hits.length) {
696
+ process.stdout.write('No matches.\n');
697
+ return;
698
+ }
699
+ for (const hit of hits) {
700
+ process.stdout.write(`#${hit.entry.id} ${hit.entry.occurred_at.slice(0, 16).replace('T', ' ')} ${hit.entry.title}\n` +
701
+ ` ${hit.entry.type ?? hit.entry.kind} · score ${hit.score.toFixed(3)} · ${hit.via}${hit.entry.import_source ? ` · imported from ${hit.entry.import_source}` : ''}\n`);
702
+ }
703
+ }
704
+ finally {
705
+ db.close();
706
+ }
707
+ }
708
+ function memoryTimeline(argv) {
709
+ const db = openDb();
710
+ try {
711
+ const rows = timeline(db, {
712
+ project: currentProject(),
713
+ limit: numberFlag(argv, '--limit', 20),
714
+ since: flag(argv, '--since') ?? null,
715
+ });
716
+ if (!rows.length) {
717
+ process.stdout.write('Nothing recorded for this project yet.\n');
718
+ return;
719
+ }
720
+ for (const row of rows) {
721
+ process.stdout.write(`#${row.id} ${row.occurred_at.slice(0, 16).replace('T', ' ')} ${row.kind} ${row.title}\n`);
722
+ }
723
+ }
724
+ finally {
725
+ db.close();
726
+ }
727
+ }
728
+ function memoryShow(argv) {
729
+ const id = Number(argv[0]);
730
+ if (!Number.isInteger(id))
731
+ fail('Usage: eklavya memory show <id>');
732
+ const db = openDb();
733
+ try {
734
+ const entry = entryById(db, id);
735
+ if (!entry)
736
+ fail(`No memory entry #${id}.`);
737
+ const tags = entryTags(db, id);
738
+ const lines = [
739
+ `#${entry.id} ${entry.title}`,
740
+ `kind: ${entry.kind}${entry.type ? ` / ${entry.type}` : ''}`,
741
+ `project: ${entry.project}`,
742
+ `occurred: ${entry.occurred_at}`,
743
+ `generator: ${entry.generator}`,
744
+ ...(entry.import_source ? [`imported: from ${entry.import_source} (unassessed — no mastery, no attempts)`] : []),
745
+ ...(entry.superseded_by ? [`superseded by #${entry.superseded_by}`] : []),
746
+ ...(tags.length ? [`tags: ${tags.join(', ')}`] : []),
747
+ ...(entry.files ? [`files: ${JSON.parse(entry.files).join(', ')}`] : []),
748
+ '',
749
+ entry.narrative || '(no narrative)',
750
+ ];
751
+ const facts = entry.facts ? JSON.parse(entry.facts) : [];
752
+ if (facts.length)
753
+ lines.push('', 'Facts:', ...facts.map((f) => ` - ${f}`));
754
+ const events = entryEvents(db, id);
755
+ lines.push('', `Evidence (${events.length}):`);
756
+ for (const event of events) {
757
+ lines.push(` ${event.occurred_at.slice(0, 16).replace('T', ' ')} ${event.kind}${event.tool ? `/${event.tool}` : ''} ${event.body.slice(0, 120).replace(/\s+/g, ' ')}`);
758
+ }
759
+ if (!events.length)
760
+ lines.push(' (none linked — imported or hand-written entries carry no local evidence)');
761
+ process.stdout.write(`${lines.join('\n')}\n`);
762
+ }
763
+ finally {
764
+ db.close();
765
+ }
766
+ }
767
+ function memoryProcess(argv) {
768
+ const db = openDb();
769
+ const { config } = loadConfig();
770
+ // Running this command *is* the "I have fixed the credential" signal: it is
771
+ // what `doctor` tells the developer to run, and nothing else takes a job off
772
+ // 'paused'. Resuming here rather than in the worker keeps it an explicit act
773
+ // — a hook that resumed by itself would spend a rejected key every session.
774
+ // Validate before resuming. `numberFlag` exits on a bad value, and resuming
775
+ // is not undoable: a refused run that had already emptied the pause would
776
+ // tell the developer nothing happened while the queue quietly went back to
777
+ // spending a credential that may still be rejected.
778
+ const maxJobs = numberFlag(argv, '--max', 10);
779
+ const resumed = resumePaused(db);
780
+ processPending(db, config, { maxJobs }).then((result) => {
781
+ process.stdout.write(`${resumed ? `resumed ${resumed} paused · ` : ''}processed ${result.processed} · entries ${result.entries} · failed ${result.failed} · skipped ${result.skipped}\n`);
782
+ db.close();
783
+ }, (err) => {
784
+ db.close();
785
+ fail(`eklavya memory process: ${err.message}`);
786
+ });
787
+ }
788
+ /**
789
+ * Backfills from Claude Code's own transcripts.
790
+ *
791
+ * The hooks only see sessions that happened after Eklavya was installed. This
792
+ * is for the ones before it, and for a session where a hook was misconfigured:
793
+ * the transcript is on disk either way, and it goes through the same privacy
794
+ * filter and converges with whatever the hooks already captured.
795
+ */
796
+ function memoryReplay(argv) {
797
+ const db = openDb();
798
+ try {
799
+ const { config } = loadConfig();
800
+ if (!config.memory.enabled) {
801
+ process.stdout.write('memory.enabled is false, so there is nowhere to replay into.\n');
802
+ return;
803
+ }
804
+ const cwd = process.cwd();
805
+ const files = transcriptsFor(cwd);
806
+ if (!files.length) {
807
+ process.stdout.write(`No Claude Code transcripts found for this checkout.\nLooked in: ${transcriptDirFor(cwd)}\n`);
808
+ return;
809
+ }
810
+ const limit = Number(flag(argv, '--limit', '20'));
811
+ const results = replayProject(db, config, cwd, { limit });
812
+ const total = results.reduce((sum, r) => ({
813
+ read: sum.read + r.read,
814
+ captured: sum.captured + r.captured,
815
+ duplicates: sum.duplicates + r.duplicates,
816
+ excluded: sum.excluded + r.excluded,
817
+ }), { read: 0, captured: 0, duplicates: 0, excluded: 0 });
818
+ process.stdout.write([
819
+ `transcripts: ${results.length} of ${files.length}`,
820
+ `lines read: ${total.read}`,
821
+ `captured: ${total.captured}`,
822
+ `already had: ${total.duplicates}`,
823
+ `excluded: ${total.excluded} (privacy filter, or capture set to minimal)`,
824
+ '',
825
+ 'Run `eklavya memory process` to summarise what was captured.',
826
+ '',
827
+ ].join('\n'));
828
+ }
829
+ finally {
830
+ db.close();
831
+ }
832
+ }
833
+ function memoryPrune() {
834
+ const db = openDb();
835
+ try {
836
+ const { config } = loadConfig();
837
+ if (!config.memory.retention_days) {
838
+ process.stdout.write('memory.retention_days is not set, so raw evidence is kept until deleted by hand.\n');
839
+ return;
840
+ }
841
+ const removed = pruneEvidence(db, config);
842
+ process.stdout.write(`Deleted ${removed} raw evidence events older than ${config.memory.retention_days} days.\n`);
843
+ }
844
+ finally {
845
+ db.close();
846
+ }
847
+ }
848
+ /** The field-disposition report, printed before anything is written. */
849
+ function dispositionReport(fields) {
850
+ const lines = [];
851
+ for (const kind of ['mapped', 'dropped', 'unrecognised']) {
852
+ const group = fields.filter((f) => f.kind === kind);
853
+ if (!group.length)
854
+ continue;
855
+ lines.push('', `${kind} (${group.length}):`);
856
+ for (const f of group) {
857
+ lines.push(` ${f.table}.${f.field}${f.to ? ` -> ${f.to}` : ''}${f.reason ? ` — ${f.reason}` : ''}`);
858
+ }
859
+ }
860
+ return lines.join('\n');
861
+ }
862
+ /**
863
+ * Reads `--map source=/path` and `--map-here source` into a project map.
864
+ *
865
+ * Eklavya keys a project by the checkout's absolute realpath; Claude Mem keys
866
+ * it by a bare name. Without a mapping the import is honest and useless at the
867
+ * moment it matters -- every row lands in a scope no session queries, so a
868
+ * search in the very repository the history came from finds nothing. The
869
+ * importer cannot guess which checkout `eklavya` meant, so this is a flag.
870
+ */
871
+ function projectMapFrom(argv) {
872
+ const map = {};
873
+ for (let i = 0; i < argv.length; i++) {
874
+ if (argv[i] === '--map') {
875
+ const pair = argv[i + 1] ?? '';
876
+ const eq = pair.indexOf('=');
877
+ if (eq <= 0)
878
+ fail('Usage: --map <source-project>=<path-to-checkout>');
879
+ map[pair.slice(0, eq)] = projectKey(findRepoConfig(pair.slice(eq + 1)).repoRoot ?? pair.slice(eq + 1));
880
+ i++;
881
+ }
882
+ else if (argv[i] === '--map-here') {
883
+ const name = argv[i + 1];
884
+ if (!name || name.startsWith('--'))
885
+ fail('Usage: --map-here <source-project>');
886
+ const here = findRepoConfig(process.cwd()).repoRoot;
887
+ // "Here" has to be somewhere. Without a checkout `projectKey` answers with
888
+ // the global bucket, so the flag would file every row under a scope no
889
+ // session queries -- silently, permanently, and to say it had mapped them.
890
+ if (!here)
891
+ fail(`--map-here needs a checkout: ${process.cwd()} is not inside a git repository.`);
892
+ map[name] = projectKey(here);
893
+ i++;
894
+ }
895
+ }
896
+ return map;
897
+ }
898
+ function memoryImport(argv) {
899
+ const flagValues = new Set(argv.flatMap((a, i) => (a === '--map' || a === '--map-here' ? [argv[i + 1] ?? ''] : [])));
900
+ const source = argv.find((a) => !a.startsWith('--') && !flagValues.has(a));
901
+ if (!source)
902
+ fail('Usage: eklavya memory import <path-to-claude-mem.db> [--dry-run] [--resume] [--map <src>=<path>]');
903
+ const dryRun = argv.includes('--dry-run');
904
+ const projectMap = projectMapFrom(argv);
905
+ try {
906
+ const found = inventory(source);
907
+ const lines = [
908
+ `source: ${found.sourcePath}`,
909
+ `schema: ${found.schemaVersion ?? 'unversioned'} (this importer understands up to ${found.supportedMax})`,
910
+ `range: ${found.dateRange.from?.slice(0, 10) ?? '—'} … ${found.dateRange.to?.slice(0, 10) ?? '—'}`,
911
+ 'tables:',
912
+ ...found.tables.map((t) => ` ${t.rows.toString().padStart(7)} ${t.name}${t.known ? '' : ' (unrecognised)'}`),
913
+ 'projects:',
914
+ ...found.projects.map((p) => ` ${p.entries.toString().padStart(7)} ${p.project}`),
915
+ dispositionReport(found.fields),
916
+ ];
917
+ process.stdout.write(`${lines.join('\n')}\n`);
918
+ if (!found.supported)
919
+ fail(`\n${found.problem ?? 'Unsupported source database.'}`);
920
+ if (dryRun) {
921
+ const planned = Object.entries(projectMap);
922
+ const unmapped = found.projects.map((p) => p.project).filter((p) => !(p in projectMap));
923
+ process.stdout.write([
924
+ '',
925
+ ...planned.map(([from, to]) => `would map: ${from} -> ${to}`),
926
+ ...(unmapped.length ? [`would keep as-is: ${unmapped.join(', ')}`] : []),
927
+ 'Dry run: nothing was written, and the source was opened read-only.',
928
+ '',
929
+ ].join('\n'));
930
+ return;
931
+ }
932
+ const db = openDb();
933
+ try {
934
+ const report = importFrom(db, source, { resume: argv.includes('--resume'), projectMap });
935
+ const rows = IMPORTED_TABLES.map((t) => ` ${t.padEnd(18)} read ${report.read[t]} · imported ${report.imported[t]} · already present ${report.skipped[t]}`);
936
+ process.stdout.write([
937
+ '',
938
+ `snapshot: ${report.snapshot}`,
939
+ ...rows,
940
+ ` concept candidates: ${report.candidates} (all unassessed — no mastery, no attempts, no gate touched)`,
941
+ ` evidence links: ${report.links} (drill-down from an entry to the prompts and tool uses behind it)`,
942
+ ` re-indexed: ${report.reindexed} entries`,
943
+ ` validation: ${report.validation.ok ? 'ok' : `FAILED — ${report.validation.notes.join('; ')}`}`,
944
+ ...report.projectsMapped.map((p) => ` mapped: ${p.from} -> ${p.to}`),
945
+ // The unmapped list is the useful half: those rows only ever surface
946
+ // under --all-projects until somebody maps them.
947
+ ...(report.projectsKept.length
948
+ ? [
949
+ ` kept as-is: ${report.projectsKept.join(', ')}`,
950
+ ' (unmapped projects are searchable only with --all-projects; re-run with --map to file them under a checkout)',
951
+ ]
952
+ : []),
953
+ '',
954
+ ].join('\n'));
955
+ if (!report.validation.ok)
956
+ process.exit(1);
957
+ }
958
+ finally {
959
+ db.close();
960
+ }
961
+ }
962
+ catch (err) {
963
+ if (err instanceof ImportError)
964
+ fail(err.message);
965
+ // The source is a hand-typed path, so pointing it at the wrong file is the
966
+ // likeliest mistake there is. The missing-file case was already handled and
967
+ // `restore` says "is not readable JSON" for the same mistake; only this path
968
+ // let a driver error out with a stack through node_modules.
969
+ fail(`eklavya memory import: cannot read ${source} — ${err.message}`);
970
+ }
971
+ }
972
+ function memoryExport(argv) {
973
+ const out = argv.find((a) => !a.startsWith('--'));
974
+ if (!out)
975
+ fail('Usage: eklavya memory export <path>');
976
+ const db = openDb();
977
+ try {
978
+ const payload = exportPayload(db);
979
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
980
+ fs.writeFileSync(out, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
981
+ process.stdout.write(`Wrote ${out} — ${payload.entries.length} entries, schema version ${EXPORT_SCHEMA_VERSION}\n`);
982
+ }
983
+ finally {
984
+ db.close();
985
+ }
986
+ }
987
+ /**
988
+ * `eklavya memory restore <file>` — the other half of the backup pair.
989
+ *
990
+ * Without it `export` writes a file nothing on the machine can read, which
991
+ * makes the rollback drill in the migration guide unrunnable. It is additive
992
+ * and idempotent, so it is also how a second device is brought up to date from
993
+ * a file rather than a shared folder.
994
+ */
995
+ function memoryRestore(argv) {
996
+ const from = argv.find((a) => !a.startsWith('--'));
997
+ if (!from)
998
+ fail('Usage: eklavya memory restore <file>');
999
+ const db = openDb();
1000
+ try {
1001
+ const r = restoreExport(db, path.resolve(from));
1002
+ process.stdout.write([
1003
+ `Restored ${from} (export schema version ${r.schemaVersion}):`,
1004
+ ` entries: ${r.entries.restored} restored, ${r.entries.skipped} already here`,
1005
+ ` evidence: ${r.evidence.restored} restored, ${r.evidence.skipped} already here`,
1006
+ ` links: ${r.tags} tag(s), ${r.links} evidence link(s)`,
1007
+ ` receipts: ${r.receipts.restored} restored, ${r.receipts.skipped} already here (${r.receiptItems} item(s))`,
1008
+ ` reindexed: ${r.reindexed} entries — search index and vectors rebuilt`,
1009
+ 'Learning history was not touched: no attempt, mastery or gate row is written by a restore.',
1010
+ '',
1011
+ ].join('\n'));
1012
+ }
1013
+ catch (err) {
1014
+ if (err instanceof ImportError)
1015
+ fail(err.message);
1016
+ throw err;
1017
+ }
1018
+ finally {
1019
+ db.close();
1020
+ }
1021
+ }
1022
+ /**
1023
+ * `eklavya memory sync push|pull|status [--target <dir>]` (ADR-09).
1024
+ *
1025
+ * The directory is the whole protocol, so the command has no host, no token and
1026
+ * no network error to report — only what it wrote and what it read back.
1027
+ * `--target` overrides `sync.target` for one run; it does not override
1028
+ * `sync.enabled`, because "point it somewhere for a second" is still a decision
1029
+ * to publish this machine's memory.
1030
+ */
1031
+ function memorySync(argv) {
1032
+ const [sub] = argv;
1033
+ if (sub !== 'push' && sub !== 'pull' && sub !== 'status') {
1034
+ fail('Usage: eklavya memory sync <push|pull|status> [--target <dir>]');
1035
+ }
1036
+ const target = flag(argv, '--target') ?? null;
1037
+ const db = openDb();
1038
+ try {
1039
+ const { config } = loadConfig();
1040
+ if (sub === 'status') {
1041
+ const s = syncStatus(db, config, { target });
1042
+ const lines = [
1043
+ `sync: ${s.enabled ? 'on' : 'off (set sync.enabled)'}`,
1044
+ `target: ${s.target ?? '— (set sync.target, or pass --target)'}`,
1045
+ `device: ${s.device_id ?? '—'}`,
1046
+ `revision: ${s.local_revision}`,
1047
+ `pending: ${s.pending} local change${s.pending === 1 ? '' : 's'} to push`,
1048
+ `conflicts: ${s.open_conflicts} quarantined`,
1049
+ `peers: ${s.peers.length
1050
+ ? s.peers.map((p) => `${p.device_id}@${p.last_revision}`).join(', ')
1051
+ : 'none seen yet'}`,
1052
+ ];
1053
+ process.stdout.write(`${lines.join('\n')}\n`);
1054
+ return;
1055
+ }
1056
+ const result = sub === 'push' ? push(db, config, { target }) : pull(db, config, { target });
1057
+ if (!result.ok) {
1058
+ fail(result.reason === 'disabled'
1059
+ ? 'Sync is off. Set sync.enabled to true in ~/.eklavya/config.json.'
1060
+ : 'No sync target. Set sync.target to a folder your devices share, or pass --target.');
1061
+ }
1062
+ if (sub === 'push') {
1063
+ const r = result;
1064
+ 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`);
1065
+ return;
1066
+ }
1067
+ const r = result;
1068
+ 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`);
1069
+ if (r.conflicts) {
1070
+ process.stdout.write('Quarantined versions are kept whole in sync_conflicts — nothing was overwritten.\n');
1071
+ }
1072
+ if (r.stalled.length) {
1073
+ process.stdout.write(`Stopped early on an unreadable record from: ${r.stalled.join(', ')} — likely still being written. Try again.\n`);
1074
+ }
1075
+ }
1076
+ finally {
1077
+ db.close();
1078
+ }
1079
+ }
1080
+ function memoryCommand(argv) {
1081
+ const [sub, ...rest] = argv;
1082
+ switch (sub) {
1083
+ case 'status':
1084
+ return memoryStatus();
1085
+ case 'search':
1086
+ return memorySearch(rest);
1087
+ case 'timeline':
1088
+ return memoryTimeline(rest);
1089
+ case 'show':
1090
+ return memoryShow(rest);
1091
+ case 'replay':
1092
+ return memoryReplay(rest);
1093
+ case 'process':
1094
+ return memoryProcess(rest);
1095
+ case 'prune':
1096
+ return memoryPrune();
1097
+ case 'import':
1098
+ return memoryImport(rest);
1099
+ case 'export':
1100
+ return memoryExport(rest);
1101
+ case 'restore':
1102
+ return memoryRestore(rest);
1103
+ case 'sync':
1104
+ return memorySync(rest);
1105
+ default:
1106
+ process.stderr.write(MEMORY_USAGE);
1107
+ process.exit(1);
1108
+ }
1109
+ }
400
1110
  function main() {
401
1111
  const [command, ...rest] = process.argv.slice(2);
402
1112
  switch (command) {
@@ -422,6 +1132,8 @@ function main() {
422
1132
  return;
423
1133
  case 'dashboard':
424
1134
  return dashboardCommand(rest);
1135
+ case 'memory':
1136
+ return memoryCommand(rest);
425
1137
  case 'doctor':
426
1138
  return doctor();
427
1139
  case 'db-path':