eklavya 1.24.2 → 1.25.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 (82) hide show
  1. package/dist/assets/dashboard.html +1012 -255
  2. package/dist/assets/tutor/references/focus-and-level.md +7 -0
  3. package/dist/claude-mem.js +9 -7
  4. package/dist/claude-mem.js.map +1 -1
  5. package/dist/cli-memory.js +786 -0
  6. package/dist/cli-memory.js.map +1 -0
  7. package/dist/cli.js +95 -773
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.js +79 -13
  10. package/dist/config.js.map +1 -1
  11. package/dist/dashboard.js +289 -15
  12. package/dist/dashboard.js.map +1 -1
  13. package/dist/db.js +33 -6
  14. package/dist/db.js.map +1 -1
  15. package/dist/hooks/capture-lib.js +55 -0
  16. package/dist/hooks/capture-lib.js.map +1 -0
  17. package/dist/hooks/capture-tool.js +1 -1
  18. package/dist/hooks/capture-tool.js.map +1 -1
  19. package/dist/hooks/commit-lib.js +255 -0
  20. package/dist/hooks/commit-lib.js.map +1 -0
  21. package/dist/hooks/lib.js +37 -5
  22. package/dist/hooks/lib.js.map +1 -1
  23. package/dist/hooks/memory-lib.js +93 -78
  24. package/dist/hooks/memory-lib.js.map +1 -1
  25. package/dist/hooks/pre-tool-gate.js +5 -10
  26. package/dist/hooks/pre-tool-gate.js.map +1 -1
  27. package/dist/hooks/prompt-submit-nudge.js +26 -1
  28. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  29. package/dist/hooks/session-start.js +13 -4
  30. package/dist/hooks/session-start.js.map +1 -1
  31. package/dist/install.js +280 -67
  32. package/dist/install.js.map +1 -1
  33. package/dist/memory/capture.js +25 -5
  34. package/dist/memory/capture.js.map +1 -1
  35. package/dist/memory/privacy.js +105 -7
  36. package/dist/memory/privacy.js.map +1 -1
  37. package/dist/memory/provider.js +13 -3
  38. package/dist/memory/provider.js.map +1 -1
  39. package/dist/memory/recall.js +18 -6
  40. package/dist/memory/recall.js.map +1 -1
  41. package/dist/memory/spool.js +105 -23
  42. package/dist/memory/spool.js.map +1 -1
  43. package/dist/memory/store.js +32 -2
  44. package/dist/memory/store.js.map +1 -1
  45. package/dist/memory/summarize.js +5 -5
  46. package/dist/memory/summarize.js.map +1 -1
  47. package/dist/memory/worker.js +71 -10
  48. package/dist/memory/worker.js.map +1 -1
  49. package/dist/migrate.js +60 -14
  50. package/dist/migrate.js.map +1 -1
  51. package/dist/paths.js +49 -0
  52. package/dist/paths.js.map +1 -1
  53. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  54. package/dist/plugin/cli/CLAUDE.md +16 -4
  55. package/dist/plugin/hooks/CLAUDE.md +33 -3
  56. package/dist/plugin/hooks/run.mjs +69 -5
  57. package/dist/plugin/scripts/install-git-hook.sh +114 -24
  58. package/dist/plugin/skills/CLAUDE.md +1 -1
  59. package/dist/plugin/skills/setup/SKILL.md +9 -2
  60. package/dist/plugin/skills/tutor/references/focus-and-level.md +7 -0
  61. package/dist/safe-write.js +146 -0
  62. package/dist/safe-write.js.map +1 -0
  63. package/dist/slug.js +4 -1
  64. package/dist/slug.js.map +1 -1
  65. package/dist/srs.js +33 -1
  66. package/dist/srs.js.map +1 -1
  67. package/dist/store.js +29 -19
  68. package/dist/store.js.map +1 -1
  69. package/dist/tools/config_tools.js +23 -1
  70. package/dist/tools/config_tools.js.map +1 -1
  71. package/dist/tools/get_session_quiz_plan.js +51 -30
  72. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  73. package/dist/tools/log_session_concepts.js +30 -23
  74. package/dist/tools/log_session_concepts.js.map +1 -1
  75. package/dist/tools/record_attempt.js +18 -8
  76. package/dist/tools/record_attempt.js.map +1 -1
  77. package/dist/tools/types.js +29 -0
  78. package/dist/tools/types.js.map +1 -1
  79. package/dist/tools/upsert_concepts.js +12 -10
  80. package/dist/tools/upsert_concepts.js.map +1 -1
  81. package/dist/user-skill/eklavya/SKILL.md +46 -21
  82. package/package.json +2 -1
@@ -0,0 +1,786 @@
1
+ /**
2
+ * `eklavya memory …` — the memory half of the CLI, moved out of `cli.ts`.
3
+ *
4
+ * Its own module so that `cli.ts` can load it only when a memory subcommand
5
+ * (or `doctor`, for `workerLine`) runs: this is where the worker, sync, replay,
6
+ * search and the Claude Mem importer come in, and `eklavya statusline` runs on
7
+ * every status-bar refresh without needing any of them. A pure move — the
8
+ * output and exit codes are the ones `cli.ts` had.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { openDb } from './db.js';
13
+ import { dbPath } from './paths.js';
14
+ import { loadConfig, findRepoConfig } from './config.js';
15
+ import { projectKey } from './store.js';
16
+ import { claudeHome } from './install.js';
17
+ import { guessProjectMap } from './claude-mem.js';
18
+ import { spin } from './theme.js';
19
+ import { importOffThread } from './memory/import-worker.js';
20
+ import { isInternalObserver, renewWorker, reserveWorker, stopWorker, workerStatus } from './memory/reservation.js';
21
+ import { identityFor } from './memory/identity.js';
22
+ import { backlogSummary, countEntries, discardBacklog, entryById, entryEvents, entryTags, pendingEventCount, quarantineBacklog, receiptTotals, restoreBacklog, resumePaused, timeline, } from './memory/store.js';
23
+ import { search } from './memory/search.js';
24
+ import { pull, push, syncStatus } from './memory/sync.js';
25
+ import { pruneEvidence, queueDepth, summarizerFor, superviseWorker } from './memory/worker.js';
26
+ import { replayProject, transcriptDirFor, transcriptsFor } from './memory/replay.js';
27
+ import { droppedCount } from './memory/spool.js';
28
+ import { savingsFrom, savingsLine } from './memory/tokens.js';
29
+ import { inventory, exportPayload, restoreExport, EXPORT_SCHEMA_VERSION, ImportError, IMPORTED_TABLES, verifyImport, } from './memory/import.js';
30
+ /** The same as `cli.ts`'s: importing that one would run its `main()`. */
31
+ function fail(message) {
32
+ process.stderr.write(`${message}\n`);
33
+ process.exit(1);
34
+ }
35
+ /**
36
+ * `eklavya memory <subcommand>` — the memory half, outside a session.
37
+ *
38
+ * Project-scoped by default on every read, like the retrieval layer it sits on:
39
+ * another repository's work is noise, and `--all-projects` is the explicit way
40
+ * to ask for it.
41
+ */
42
+ const MEMORY_USAGE = 'Usage: eklavya memory status|search|timeline|show|replay|process|stop|backlog|prune|import|export|restore|sync\n' +
43
+ ' run `eklavya --help` for the full list\n';
44
+ function flag(argv, name, fallback) {
45
+ const i = argv.indexOf(name);
46
+ if (i === -1)
47
+ return fallback;
48
+ const value = argv[i + 1];
49
+ if (value === undefined || value.startsWith('--'))
50
+ fail(`${name} needs a value.`);
51
+ return value;
52
+ }
53
+ function numberFlag(argv, name, fallback) {
54
+ const raw = flag(argv, name);
55
+ if (raw === undefined)
56
+ return fallback;
57
+ const n = Number(raw);
58
+ if (!Number.isFinite(n) || n <= 0)
59
+ fail(`${name} needs a positive number.`);
60
+ return Math.floor(n);
61
+ }
62
+ /** The project key the memory tables use — the same one the hooks record under. */
63
+ function currentProject() {
64
+ return identityFor({ cwd: process.cwd(), sessionId: 'cli' }).project;
65
+ }
66
+ /**
67
+ * How a migration is checked: what arrived here, and what arrived under a
68
+ * Claude Mem project name no checkout matches -- history that only surfaces
69
+ * under --all-projects until it is placed.
70
+ */
71
+ function importedLines(db, project) {
72
+ const rows = db
73
+ .prepare(`SELECT project, COUNT(*) AS n FROM memory_entries
74
+ WHERE import_source IS NOT NULL AND deleted_at IS NULL GROUP BY project`)
75
+ .all();
76
+ if (!rows.length)
77
+ return [];
78
+ const here = rows.find((r) => r.project === project)?.n ?? 0;
79
+ const bare = rows.filter((r) => !path.isAbsolute(r.project));
80
+ const unplaced = bare.reduce((sum, r) => sum + r.n, 0);
81
+ return [
82
+ `imported: ${here} here from Claude Mem`,
83
+ ...(unplaced
84
+ ? [
85
+ `unplaced: ${unplaced} under ${bare.length} name(s) no checkout matches — ${bare.map((r) => r.project).slice(0, 5).join(', ')}${bare.length > 5 ? ', …' : ''}`,
86
+ ' place them: eklavya memory import ~/.claude-mem.retired/claude-mem.db [--map <name>=<checkout>]',
87
+ ]
88
+ : []),
89
+ ];
90
+ }
91
+ /** "2m 05s" — elapsed since an ISO stamp. */
92
+ function since(iso, now = Date.now()) {
93
+ if (!iso)
94
+ return '?';
95
+ const secs = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
96
+ return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`;
97
+ }
98
+ /** The one background worker, or that there is none — for `memory status` and `doctor`. */
99
+ export function workerLine(db) {
100
+ const w = workerStatus(db);
101
+ if (!w)
102
+ return 'none running';
103
+ return [
104
+ w.pid ? `pid ${w.pid}` : 'starting',
105
+ w.started ? `up ${since(w.started)}` : null,
106
+ w.generation ? `hand-off ${w.generation}` : null,
107
+ w.child ? `claude pid ${w.child}` : null,
108
+ w.job ? `job #${w.job} for ${since(w.jobStarted)}` : null,
109
+ w.stale ? `STALE — no heartbeat for ${since(w.heartbeat)}, being stopped` : `heartbeat ${since(w.heartbeat)} ago`,
110
+ ]
111
+ .filter(Boolean)
112
+ .join(' · ');
113
+ }
114
+ /**
115
+ * Why the queue is paused, by class — never `last_error`, which is the
116
+ * provider's own prose and has carried a URL with a token in it.
117
+ */
118
+ function pauseLine(db) {
119
+ const rows = db
120
+ .prepare(`SELECT error_class, COUNT(*) AS n, MAX(updated_at) AS at FROM memory_jobs
121
+ WHERE status = 'paused' GROUP BY error_class ORDER BY at DESC`)
122
+ .all();
123
+ if (!rows.length)
124
+ return null;
125
+ const why = {
126
+ auth: 'claude is not logged in',
127
+ quota: 'usage limit reached',
128
+ missing: 'claude is not on the PATH',
129
+ };
130
+ return rows
131
+ .map((r) => `${r.n} on ${r.error_class ?? 'unclassified'}${why[r.error_class ?? ''] ? ` (${why[r.error_class]})` : ''}, since ${r.at}`)
132
+ .join('; ');
133
+ }
134
+ function memoryStatus() {
135
+ const db = openDb();
136
+ try {
137
+ const { config } = loadConfig();
138
+ const project = currentProject();
139
+ const queue = queueDepth(db);
140
+ const totals = receiptTotals(db);
141
+ const savings = savingsFrom({
142
+ baseTokens: totals.base,
143
+ deliveredTokens: totals.delivered,
144
+ delivery: totals.confirmed > 0 ? 'confirmed' : 'unknown',
145
+ });
146
+ const lines = [
147
+ `project: ${project}`,
148
+ `capture: ${config.memory.enabled ? config.memory.capture : 'off (memory.enabled is false)'}`,
149
+ `entries: ${countEntries(db, project)} here, ${countEntries(db)} in total`,
150
+ ...importedLines(db, project),
151
+ `pending: ${pendingEventCount(db, project)} evidence events here, ${pendingEventCount(db)} in total`,
152
+ `queue: ${queue.pending} pending · ${queue.paused} paused · ${queue.failed} failed${queue.quarantined ? ` · ${queue.quarantined} quarantined` : ''}`,
153
+ `oldest job: ${queue.oldest ?? '—'}`,
154
+ ...(pauseLine(db) ? [`paused: ${pauseLine(db)} — fix it, then: eklavya memory process`] : []),
155
+ `worker: ${workerLine(db)}`,
156
+ // Named separately from the summarizer because they answer different
157
+ // questions: one is "will anything leave this machine", the other is
158
+ // "what is actually writing the observations right now".
159
+ `provider: ${config.providers.observer
160
+ ? `${config.providers.observer.kind}:${config.providers.observer.model} (via claude -p, on your subscription)`
161
+ : 'none — nothing leaves this machine'}`,
162
+ `summarizer: ${summarizerFor(config).id}`,
163
+ `spool drops: ${droppedCount()}`,
164
+ `receipts: ${totals.receipts} (${totals.confirmed} confirmed) · base ${totals.base} → delivered ${totals.delivered} tokens`,
165
+ savingsLine(savings),
166
+ ];
167
+ process.stdout.write(`${lines.join('\n')}\n`);
168
+ }
169
+ finally {
170
+ db.close();
171
+ }
172
+ }
173
+ function memorySearch(argv) {
174
+ const query = argv.filter((a, i) => !a.startsWith('--') && !argv[i - 1]?.match(/^--(mode|limit)$/)).join(' ');
175
+ if (!query.trim())
176
+ fail('Usage: eklavya memory search <query> [--mode keyword|semantic|hybrid] [--limit <n>] [--all-projects]');
177
+ const { config } = loadConfig();
178
+ const mode = (flag(argv, '--mode', config.retrieval.mode) ?? 'hybrid');
179
+ if (mode !== 'keyword' && mode !== 'semantic' && mode !== 'hybrid') {
180
+ fail('--mode must be keyword, semantic or hybrid.');
181
+ }
182
+ const db = openDb();
183
+ try {
184
+ const hits = search(db, query, mode, {
185
+ project: currentProject(),
186
+ allProjects: argv.includes('--all-projects'),
187
+ limit: numberFlag(argv, '--limit', 10),
188
+ });
189
+ if (!hits.length) {
190
+ process.stdout.write('No matches.\n');
191
+ return;
192
+ }
193
+ for (const hit of hits) {
194
+ process.stdout.write(`#${hit.entry.id} ${hit.entry.occurred_at.slice(0, 16).replace('T', ' ')} ${hit.entry.title}\n` +
195
+ ` ${hit.entry.type ?? hit.entry.kind} · score ${hit.score.toFixed(3)} · ${hit.via}${hit.entry.import_source ? ` · imported from ${hit.entry.import_source}` : ''}\n`);
196
+ }
197
+ }
198
+ finally {
199
+ db.close();
200
+ }
201
+ }
202
+ function memoryTimeline(argv) {
203
+ const db = openDb();
204
+ try {
205
+ const rows = timeline(db, {
206
+ project: currentProject(),
207
+ limit: numberFlag(argv, '--limit', 20),
208
+ since: flag(argv, '--since') ?? null,
209
+ });
210
+ if (!rows.length) {
211
+ process.stdout.write('Nothing recorded for this project yet.\n');
212
+ return;
213
+ }
214
+ for (const row of rows) {
215
+ process.stdout.write(`#${row.id} ${row.occurred_at.slice(0, 16).replace('T', ' ')} ${row.kind} ${row.title}\n`);
216
+ }
217
+ }
218
+ finally {
219
+ db.close();
220
+ }
221
+ }
222
+ function memoryShow(argv) {
223
+ const id = Number(argv[0]);
224
+ if (!Number.isInteger(id))
225
+ fail('Usage: eklavya memory show <id>');
226
+ const db = openDb();
227
+ try {
228
+ const entry = entryById(db, id);
229
+ if (!entry)
230
+ fail(`No memory entry #${id}.`);
231
+ const tags = entryTags(db, id);
232
+ const lines = [
233
+ `#${entry.id} ${entry.title}`,
234
+ `kind: ${entry.kind}${entry.type ? ` / ${entry.type}` : ''}`,
235
+ `project: ${entry.project}`,
236
+ `occurred: ${entry.occurred_at}`,
237
+ `generator: ${entry.generator}`,
238
+ ...(entry.import_source ? [`imported: from ${entry.import_source} (unassessed — no mastery, no attempts)`] : []),
239
+ ...(entry.superseded_by ? [`superseded by #${entry.superseded_by}`] : []),
240
+ ...(tags.length ? [`tags: ${tags.join(', ')}`] : []),
241
+ ...(entry.files ? [`files: ${JSON.parse(entry.files).join(', ')}`] : []),
242
+ '',
243
+ entry.narrative || '(no narrative)',
244
+ ];
245
+ const facts = entry.facts ? JSON.parse(entry.facts) : [];
246
+ if (facts.length)
247
+ lines.push('', 'Facts:', ...facts.map((f) => ` - ${f}`));
248
+ const events = entryEvents(db, id);
249
+ lines.push('', `Evidence (${events.length}):`);
250
+ for (const event of events) {
251
+ lines.push(` ${event.occurred_at.slice(0, 16).replace('T', ' ')} ${event.kind}${event.tool ? `/${event.tool}` : ''} ${event.body.slice(0, 120).replace(/\s+/g, ' ')}`);
252
+ }
253
+ if (!events.length)
254
+ lines.push(' (none linked — imported or hand-written entries carry no local evidence)');
255
+ process.stdout.write(`${lines.join('\n')}\n`);
256
+ }
257
+ finally {
258
+ db.close();
259
+ }
260
+ }
261
+ function memoryProcess(argv) {
262
+ // A summariser's own session must never start a worker: that is the loop
263
+ // that took 165 of them to stop (`reservation.ts`).
264
+ if (isInternalObserver())
265
+ return;
266
+ const db = openDb();
267
+ const { config } = loadConfig();
268
+ // Running this command *is* the "I have fixed the credential" signal: it is
269
+ // what `doctor` tells the developer to run, and nothing else takes a job off
270
+ // 'paused'. Resuming here rather than in the worker keeps it an explicit act
271
+ // — a hook that resumed by itself would spend a rejected key every session.
272
+ // Validate before resuming. `numberFlag` exits on a bad value, and resuming
273
+ // is not undoable: a refused run that had already emptied the pause would
274
+ // tell the developer nothing happened while the queue quietly went back to
275
+ // spending a credential that may still be rejected.
276
+ const maxJobs = numberFlag(argv, '--max', 10);
277
+ const background = argv.includes('--no-resume');
278
+ // One worker per installation, manual runs included. A hook that spawned us
279
+ // already won the slot and hands over its token; anyone else competes for it.
280
+ const handed = flag(argv, '--worker-token');
281
+ let token;
282
+ try {
283
+ token = handed
284
+ ? renewWorker(db, handed, { pid: process.pid })
285
+ ? handed
286
+ : null
287
+ : reserveWorker(db, process.pid);
288
+ }
289
+ catch {
290
+ // A database too busy to reserve in is one to leave alone: the slot, if
291
+ // this launch held it, lapses on its own.
292
+ token = null;
293
+ }
294
+ if (!token) {
295
+ const holder = workerStatus(db);
296
+ if (!background) {
297
+ process.stdout.write(`another memory worker is running${holder?.pid ? ` (pid ${holder.pid})` : ''} — its queue is this queue, so nothing to do.\n`);
298
+ }
299
+ db.close();
300
+ return;
301
+ }
302
+ // The hooks' background drain passes --no-resume: a hook that resumed by
303
+ // itself is exactly the retry loop the comment above rules out.
304
+ const resumed = background ? 0 : resumePaused(db);
305
+ // SIGHUP too: a closed terminal is a stop, and the call it left running is
306
+ // exactly the orphan this has to prevent.
307
+ const stop = new AbortController();
308
+ const onSignal = () => stop.abort();
309
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP'])
310
+ process.once(sig, onSignal);
311
+ superviseWorker(db, token, config, {
312
+ maxJobs,
313
+ signal: stop.signal,
314
+ loadConfig: () => loadConfig().config,
315
+ }).then((result) => {
316
+ if (!background) {
317
+ process.stdout.write(`${resumed ? `resumed ${resumed} paused · ` : ''}processed ${result.processed} · entries ${result.entries} · failed ${result.failed} · skipped ${result.skipped}${result.handedOff ? ' · more queued, continuing in the background' : ''}\n`);
318
+ }
319
+ db.close();
320
+ }, (err) => {
321
+ db.close();
322
+ fail(`eklavya memory process: ${err.message}`);
323
+ });
324
+ }
325
+ /**
326
+ * `eklavya memory backlog [quarantine|discard|restore] [selector]`: look at the
327
+ * unfinished queue before letting a provider loose on it, and set aside or
328
+ * delete what an incident put there. A change needs a selector — there is no
329
+ * "all", because the queue also holds real work.
330
+ */
331
+ function memoryBacklog(argv) {
332
+ const action = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'list';
333
+ const batch = flag(argv, '--batch');
334
+ const sel = {
335
+ helpers: argv.includes('--helpers'),
336
+ project: flag(argv, '--project'),
337
+ session: flag(argv, '--session'),
338
+ batch: batch === undefined ? undefined : Number(batch),
339
+ };
340
+ if (sel.batch !== undefined && !Number.isInteger(sel.batch))
341
+ fail('--batch needs a batch id.');
342
+ const selected = sel.helpers || sel.project !== undefined || sel.session !== undefined || sel.batch !== undefined;
343
+ const db = openDb();
344
+ try {
345
+ if (action === 'list') {
346
+ const groups = backlogSummary(db, sel);
347
+ if (!groups.length) {
348
+ process.stdout.write('No unfinished jobs.\n');
349
+ return;
350
+ }
351
+ for (const g of groups) {
352
+ process.stdout.write(`${String(g.batches).padStart(6)} ${g.status.padEnd(11)} ${g.project}${g.helper ? ' [observer helper sessions]' : ''}\n` +
353
+ ` ${g.events} events · ${g.oldest.slice(0, 16).replace('T', ' ')} → ${g.newest.slice(0, 16).replace('T', ' ')}\n`);
354
+ }
355
+ if (groups.some((g) => g.helper)) {
356
+ process.stdout.write('\nHelper sessions are the observer summarising its own runs — noise. Set them aside or delete them:\n' +
357
+ ' eklavya memory backlog quarantine --helpers\n eklavya memory backlog discard --helpers\n');
358
+ }
359
+ return;
360
+ }
361
+ if (!selected)
362
+ fail(`eklavya memory backlog ${action} needs --helpers, --project <key>, --session <id> or --batch <id>.`);
363
+ if (action === 'quarantine') {
364
+ process.stdout.write(`quarantined ${quarantineBacklog(db, sel)} job(s) — kept, and never processed until restored.\n`);
365
+ }
366
+ else if (action === 'restore') {
367
+ process.stdout.write(`restored ${restoreBacklog(db, sel)} job(s) to the queue.\n`);
368
+ }
369
+ else if (action === 'discard') {
370
+ const gone = discardBacklog(db, sel);
371
+ process.stdout.write(`discarded ${gone.batches} batch(es) and ${gone.events} evidence event(s).\n`);
372
+ }
373
+ else {
374
+ fail('Usage: eklavya memory backlog [list|quarantine|discard|restore] [--helpers] [--project <key>] [--session <id>] [--batch <id>]');
375
+ }
376
+ }
377
+ finally {
378
+ db.close();
379
+ }
380
+ }
381
+ /**
382
+ * `eklavya memory stop`: ends the running worker and its provider call, and
383
+ * nothing that is not theirs. The job in flight goes back to the queue.
384
+ */
385
+ function memoryStop() {
386
+ if (isInternalObserver())
387
+ return;
388
+ const db = openDb();
389
+ void stopWorker(db).then((outcome) => {
390
+ const { config } = loadConfig();
391
+ if (!outcome.stopped) {
392
+ process.stdout.write('no memory worker is running.\n');
393
+ }
394
+ else {
395
+ process.stdout.write(`stopped the memory worker${outcome.pid ? ` (pid ${outcome.pid})` : ''}${outcome.child ? ` and its claude call (pid ${outcome.child})` : ''}${outcome.forced ? ' — it had to be killed' : ''}. Unfinished jobs stay queued.\n` +
396
+ (outcome.released ? '' : 'something it started would not exit; the slot stays held until it does.\n'));
397
+ }
398
+ if (config.providers.observer) {
399
+ process.stdout.write('the next session seam starts a new one. To keep it stopped: eklavya config set providers.observer null\n');
400
+ }
401
+ db.close();
402
+ });
403
+ }
404
+ /**
405
+ * Backfills from Claude Code's own transcripts.
406
+ *
407
+ * The hooks only see sessions that happened after Eklavya was installed. This
408
+ * is for the ones before it, and for a session where a hook was misconfigured:
409
+ * the transcript is on disk either way, and it goes through the same privacy
410
+ * filter and converges with whatever the hooks already captured.
411
+ */
412
+ function memoryReplay(argv) {
413
+ const db = openDb();
414
+ try {
415
+ const { config } = loadConfig();
416
+ if (!config.memory.enabled) {
417
+ process.stdout.write('memory.enabled is false, so there is nowhere to replay into.\n');
418
+ return;
419
+ }
420
+ const cwd = process.cwd();
421
+ const files = transcriptsFor(cwd);
422
+ if (!files.length) {
423
+ process.stdout.write(`No Claude Code transcripts found for this checkout.\nLooked in: ${transcriptDirFor(cwd)}\n`);
424
+ return;
425
+ }
426
+ const limit = Number(flag(argv, '--limit', '20'));
427
+ const results = replayProject(db, config, cwd, { limit });
428
+ const total = results.reduce((sum, r) => ({
429
+ read: sum.read + r.read,
430
+ captured: sum.captured + r.captured,
431
+ duplicates: sum.duplicates + r.duplicates,
432
+ excluded: sum.excluded + r.excluded,
433
+ }), { read: 0, captured: 0, duplicates: 0, excluded: 0 });
434
+ process.stdout.write([
435
+ `transcripts: ${results.length} of ${files.length}`,
436
+ `lines read: ${total.read}`,
437
+ `captured: ${total.captured}`,
438
+ `already had: ${total.duplicates}`,
439
+ `excluded: ${total.excluded} (privacy filter, or capture set to minimal)`,
440
+ '',
441
+ 'Run `eklavya memory process` to summarise what was captured.',
442
+ '',
443
+ ].join('\n'));
444
+ }
445
+ finally {
446
+ db.close();
447
+ }
448
+ }
449
+ function memoryPrune() {
450
+ const db = openDb();
451
+ try {
452
+ const { config } = loadConfig();
453
+ if (!config.memory.retention_days) {
454
+ process.stdout.write('memory.retention_days is not set, so raw evidence is kept until deleted by hand.\n');
455
+ return;
456
+ }
457
+ const removed = pruneEvidence(db, config);
458
+ process.stdout.write(`Deleted ${removed} raw evidence events older than ${config.memory.retention_days} days.\n`);
459
+ }
460
+ finally {
461
+ db.close();
462
+ }
463
+ }
464
+ /** The field-disposition report, printed before anything is written. */
465
+ function dispositionReport(fields) {
466
+ const lines = [];
467
+ for (const kind of ['mapped', 'dropped', 'unrecognised']) {
468
+ const group = fields.filter((f) => f.kind === kind);
469
+ if (!group.length)
470
+ continue;
471
+ lines.push('', `${kind} (${group.length}):`);
472
+ for (const f of group) {
473
+ lines.push(` ${f.table}.${f.field}${f.to ? ` -> ${f.to}` : ''}${f.reason ? ` — ${f.reason}` : ''}`);
474
+ }
475
+ }
476
+ return lines.join('\n');
477
+ }
478
+ /**
479
+ * Reads `--map source=/path` and `--map-here source` into a project map.
480
+ *
481
+ * Eklavya keys a project by the checkout's absolute realpath; Claude Mem keys
482
+ * it by a bare name. Without a mapping the import is honest and useless at the
483
+ * moment it matters -- every row lands in a scope no session queries, so a
484
+ * search in the very repository the history came from finds nothing. The
485
+ * importer cannot guess which checkout `eklavya` meant, so this is a flag.
486
+ */
487
+ function projectMapFrom(argv) {
488
+ const map = {};
489
+ for (let i = 0; i < argv.length; i++) {
490
+ if (argv[i] === '--map') {
491
+ const pair = argv[i + 1] ?? '';
492
+ const eq = pair.indexOf('=');
493
+ if (eq <= 0)
494
+ fail('Usage: --map <source-project>=<path-to-checkout>');
495
+ map[pair.slice(0, eq)] = projectKey(findRepoConfig(pair.slice(eq + 1)).repoRoot ?? pair.slice(eq + 1));
496
+ i++;
497
+ }
498
+ else if (argv[i] === '--map-here') {
499
+ const name = argv[i + 1];
500
+ if (!name || name.startsWith('--'))
501
+ fail('Usage: --map-here <source-project>');
502
+ const here = findRepoConfig(process.cwd()).repoRoot;
503
+ // "Here" has to be somewhere. Without a checkout `projectKey` answers with
504
+ // the global bucket, so the flag would file every row under a scope no
505
+ // session queries -- silently, permanently, and to say it had mapped them.
506
+ if (!here)
507
+ fail(`--map-here needs a checkout: ${process.cwd()} is not inside a git repository.`);
508
+ map[name] = projectKey(here);
509
+ i++;
510
+ }
511
+ }
512
+ return map;
513
+ }
514
+ /** The `--verify` report: every source row by id, then where each project landed. */
515
+ function verifyLines(r) {
516
+ const missing = r.tables.reduce((n, t) => n + t.missing.length, 0);
517
+ const width = Math.max(0, ...r.projects.map((p) => p.project.length));
518
+ return [
519
+ `verify: ${r.sourcePath}`,
520
+ ...r.tables.map((t) => ` ${t.table.padEnd(18)} ${String(t.source).padStart(6)} in source · ${String(t.present).padStart(6)} in Eklavya · ${t.missing.length} missing${t.missing.length ? ` (ids ${t.missing.slice(0, 10).join(', ')}${t.missing.length > 10 ? ', …' : ''})` : ''}`),
521
+ ` changed since import: ${r.changed} entr${r.changed === 1 ? 'y' : 'ies'}`,
522
+ 'placement:',
523
+ ...r.projects.flatMap((p) => Object.entries(p.filedUnder).map(([to, n]) => {
524
+ const placed = path.isAbsolute(to);
525
+ return ` ${String(n).padStart(6)} ${p.project.padEnd(width)} ${placed ? `→ ${to}` : 'not placed — searchable only with --all-projects; --map it to a checkout'}`;
526
+ })),
527
+ missing
528
+ ? `INCOMPLETE: ${missing} source row(s) are not in Eklavya — re-run without --verify to import them.`
529
+ : 'complete: every source row is in Eklavya.',
530
+ ];
531
+ }
532
+ async function memoryImport(argv) {
533
+ const flagValues = new Set(argv.flatMap((a, i) => (a === '--map' || a === '--map-here' ? [argv[i + 1] ?? ''] : [])));
534
+ const source = argv.find((a) => !a.startsWith('--') && !flagValues.has(a));
535
+ if (!source)
536
+ fail('Usage: eklavya memory import <path-to-claude-mem.db> [--dry-run] [--verify] [--resume] [--map <src>=<path>]');
537
+ const dryRun = argv.includes('--dry-run');
538
+ const explicit = projectMapFrom(argv);
539
+ try {
540
+ if (argv.includes('--verify')) {
541
+ const db = openDb();
542
+ try {
543
+ const report = verifyImport(db, source);
544
+ process.stdout.write(`${verifyLines(report).join('\n')}\n`);
545
+ if (report.tables.some((t) => t.missing.length))
546
+ process.exit(1);
547
+ }
548
+ finally {
549
+ db.close();
550
+ }
551
+ return;
552
+ }
553
+ const found = inventory(source);
554
+ // Placed the way `eklavya install` places them -- off Claude Code's
555
+ // transcripts -- so a re-run by hand files history where install would
556
+ // have. A --map names what the transcripts cannot, and wins.
557
+ const unsure = {};
558
+ const projectMap = { ...guessProjectMap(source, claudeHome(), unsure), ...explicit };
559
+ for (const name of Object.keys(explicit))
560
+ delete unsure[name];
561
+ const unsureLines = Object.entries(unsure).map(([name, paths]) => ` ${name}: ${paths.length} checkouts carry this name — pick one: --map ${name}=<path> (${paths.join(', ')})`);
562
+ const lines = [
563
+ `source: ${found.sourcePath}`,
564
+ `schema: ${found.schemaVersion ?? 'unversioned'} (this importer understands up to ${found.supportedMax})`,
565
+ `range: ${found.dateRange.from?.slice(0, 10) ?? '—'} … ${found.dateRange.to?.slice(0, 10) ?? '—'}`,
566
+ 'tables:',
567
+ ...found.tables.map((t) => ` ${t.rows.toString().padStart(7)} ${t.name}${t.known ? '' : ' (unrecognised)'}`),
568
+ 'projects:',
569
+ ...found.projects.map((p) => ` ${p.entries.toString().padStart(7)} ${p.project}`),
570
+ dispositionReport(found.fields),
571
+ ];
572
+ process.stdout.write(`${lines.join('\n')}\n`);
573
+ if (!found.supported)
574
+ fail(`\n${found.problem ?? 'Unsupported source database.'}`);
575
+ if (dryRun) {
576
+ const planned = Object.entries(projectMap);
577
+ const unmapped = found.projects.map((p) => p.project).filter((p) => !(p in projectMap));
578
+ process.stdout.write([
579
+ '',
580
+ ...planned.map(([from, to]) => `would map: ${from} -> ${to}`),
581
+ ...(unmapped.length ? [`would keep as-is: ${unmapped.join(', ')}`] : []),
582
+ ...unsureLines,
583
+ 'Dry run: nothing was written, and the source was opened read-only.',
584
+ '',
585
+ ].join('\n'));
586
+ return;
587
+ }
588
+ {
589
+ process.stdout.write('\n');
590
+ const { report, verified } = await spin('import', 'importing…', () => importOffThread({ dbFile: dbPath(), source, opts: { resume: argv.includes('--resume'), projectMap } }));
591
+ const rows = IMPORTED_TABLES.map((t) => ` ${t.padEnd(18)} read ${report.read[t]} · imported ${report.imported[t]} · already present ${report.skipped[t]}`);
592
+ process.stdout.write([
593
+ '',
594
+ `snapshot: ${report.snapshot}`,
595
+ ...rows,
596
+ ` concept candidates: ${report.candidates} (all unassessed — no mastery, no attempts, no gate touched)`,
597
+ ` evidence links: ${report.links} (drill-down from an entry to the prompts and tool uses behind it)`,
598
+ ` re-indexed: ${report.reindexed} entries`,
599
+ ...(report.rehomed ? [` re-homed: ${report.rehomed} entries an earlier run left under a bare project name`] : []),
600
+ ` validation: ${report.validation.ok ? 'ok' : `FAILED — ${report.validation.notes.join('; ')}`}`,
601
+ ...report.projectsMapped.map((p) => ` mapped: ${p.from} -> ${p.to}`),
602
+ // The unmapped list is the useful half: those rows only ever surface
603
+ // under --all-projects until somebody maps them.
604
+ ...(report.projectsKept.length
605
+ ? [
606
+ ` kept as-is: ${report.projectsKept.join(', ')}`,
607
+ ' (unmapped projects are searchable only with --all-projects; re-run with --map to file them under a checkout)',
608
+ ]
609
+ : []),
610
+ '',
611
+ ].join('\n'));
612
+ if (unsureLines.length)
613
+ process.stdout.write(`${unsureLines.join('\n')}\n`);
614
+ if (!report.validation.ok)
615
+ process.exit(1);
616
+ // Counts agreeing is what validation proves; this proves every source
617
+ // row by id, and shows where each project's history is filed.
618
+ process.stdout.write(`\n${verifyLines(verified).join('\n')}\n`);
619
+ if (verified.tables.some((t) => t.missing.length))
620
+ process.exit(1);
621
+ }
622
+ }
623
+ catch (err) {
624
+ if (err instanceof ImportError)
625
+ fail(err.message);
626
+ // The source is a hand-typed path, so pointing it at the wrong file is the
627
+ // likeliest mistake there is. The missing-file case was already handled and
628
+ // `restore` says "is not readable JSON" for the same mistake; only this path
629
+ // let a driver error out with a stack through node_modules.
630
+ fail(`eklavya memory import: cannot read ${source} — ${err.message}`);
631
+ }
632
+ }
633
+ function memoryExport(argv) {
634
+ const out = argv.find((a) => !a.startsWith('--'));
635
+ if (!out)
636
+ fail('Usage: eklavya memory export <path> [--force]');
637
+ const force = argv.includes('--force');
638
+ // Refused before the database is opened: an existing file might be the only
639
+ // copy of an earlier export, and a typo in a path should not cost it.
640
+ if (!force && fs.existsSync(out))
641
+ fail(`eklavya memory export: ${out} already exists. Pass --force to replace it.`);
642
+ const db = openDb();
643
+ try {
644
+ const payload = exportPayload(db);
645
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
646
+ // 0600: this is the developer's whole project history, prompts included.
647
+ // `wx` makes the no-overwrite promise atomic; the chmod covers a file
648
+ // `--force` replaced, whose old mode `mode` would otherwise keep.
649
+ fs.writeFileSync(out, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: force ? 'w' : 'wx' });
650
+ fs.chmodSync(out, 0o600);
651
+ process.stdout.write(`Wrote ${out} — ${payload.entries.length} entries, schema version ${EXPORT_SCHEMA_VERSION}\n`);
652
+ }
653
+ finally {
654
+ db.close();
655
+ }
656
+ }
657
+ /**
658
+ * `eklavya memory restore <file>` — the other half of the backup pair.
659
+ *
660
+ * Without it `export` writes a file nothing on the machine can read, which
661
+ * makes the rollback drill in the migration guide unrunnable. It is additive
662
+ * and idempotent, so it is also how a second device is brought up to date from
663
+ * a file rather than a shared folder.
664
+ */
665
+ function memoryRestore(argv) {
666
+ const from = argv.find((a) => !a.startsWith('--'));
667
+ if (!from)
668
+ fail('Usage: eklavya memory restore <file>');
669
+ const db = openDb();
670
+ try {
671
+ const r = restoreExport(db, path.resolve(from));
672
+ process.stdout.write([
673
+ `Restored ${from} (export schema version ${r.schemaVersion}):`,
674
+ ` entries: ${r.entries.restored} restored, ${r.entries.skipped} already here`,
675
+ ` evidence: ${r.evidence.restored} restored, ${r.evidence.skipped} already here`,
676
+ ` links: ${r.tags} tag(s), ${r.links} evidence link(s)`,
677
+ ` receipts: ${r.receipts.restored} restored, ${r.receipts.skipped} already here (${r.receiptItems} item(s))`,
678
+ ` reindexed: ${r.reindexed} entries — search index and vectors rebuilt`,
679
+ 'Learning history was not touched: no attempt, mastery or gate row is written by a restore.',
680
+ '',
681
+ ].join('\n'));
682
+ }
683
+ catch (err) {
684
+ if (err instanceof ImportError)
685
+ fail(err.message);
686
+ throw err;
687
+ }
688
+ finally {
689
+ db.close();
690
+ }
691
+ }
692
+ /**
693
+ * `eklavya memory sync push|pull|status [--target <dir>]` (ADR-09).
694
+ *
695
+ * The directory is the whole protocol, so the command has no host, no token and
696
+ * no network error to report — only what it wrote and what it read back.
697
+ * `--target` overrides `sync.target` for one run; it does not override
698
+ * `sync.enabled`, because "point it somewhere for a second" is still a decision
699
+ * to publish this machine's memory.
700
+ */
701
+ function memorySync(argv) {
702
+ const [sub] = argv;
703
+ if (sub !== 'push' && sub !== 'pull' && sub !== 'status') {
704
+ fail('Usage: eklavya memory sync <push|pull|status> [--target <dir>]');
705
+ }
706
+ const target = flag(argv, '--target') ?? null;
707
+ const db = openDb();
708
+ try {
709
+ const { config } = loadConfig();
710
+ if (sub === 'status') {
711
+ const s = syncStatus(db, config, { target });
712
+ const lines = [
713
+ `sync: ${s.enabled ? 'on' : 'off (set sync.enabled)'}`,
714
+ `target: ${s.target ?? '— (set sync.target, or pass --target)'}`,
715
+ `device: ${s.device_id ?? '—'}`,
716
+ `revision: ${s.local_revision}`,
717
+ `pending: ${s.pending} local change${s.pending === 1 ? '' : 's'} to push`,
718
+ `conflicts: ${s.open_conflicts} quarantined`,
719
+ `peers: ${s.peers.length
720
+ ? s.peers.map((p) => `${p.device_id}@${p.last_revision}`).join(', ')
721
+ : 'none seen yet'}`,
722
+ ];
723
+ process.stdout.write(`${lines.join('\n')}\n`);
724
+ return;
725
+ }
726
+ const result = sub === 'push' ? push(db, config, { target }) : pull(db, config, { target });
727
+ if (!result.ok) {
728
+ fail(result.reason === 'disabled'
729
+ ? 'Sync is off. Set sync.enabled to true in ~/.eklavya/config.json.'
730
+ : 'No sync target. Set sync.target to a folder your devices share, or pass --target.');
731
+ }
732
+ if (sub === 'push') {
733
+ const r = result;
734
+ 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`);
735
+ return;
736
+ }
737
+ const r = result;
738
+ 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`);
739
+ if (r.conflicts) {
740
+ process.stdout.write('Quarantined versions are kept whole in sync_conflicts — nothing was overwritten.\n');
741
+ }
742
+ if (r.stalled.length) {
743
+ process.stdout.write(`Stopped early on an unreadable record from: ${r.stalled.join(', ')} — likely still being written. Try again.\n`);
744
+ }
745
+ }
746
+ finally {
747
+ db.close();
748
+ }
749
+ }
750
+ export function memoryCommand(argv) {
751
+ const [sub, ...rest] = argv;
752
+ switch (sub) {
753
+ case 'status':
754
+ return memoryStatus();
755
+ case 'search':
756
+ return memorySearch(rest);
757
+ case 'timeline':
758
+ return memoryTimeline(rest);
759
+ case 'show':
760
+ return memoryShow(rest);
761
+ case 'replay':
762
+ return memoryReplay(rest);
763
+ case 'process':
764
+ return memoryProcess(rest);
765
+ case 'stop':
766
+ return memoryStop();
767
+ case 'backlog':
768
+ return memoryBacklog(rest);
769
+ case 'prune':
770
+ return memoryPrune();
771
+ case 'import':
772
+ // Async only so the spinner turns: the import itself runs on a worker.
773
+ void memoryImport(rest);
774
+ return;
775
+ case 'export':
776
+ return memoryExport(rest);
777
+ case 'restore':
778
+ return memoryRestore(rest);
779
+ case 'sync':
780
+ return memorySync(rest);
781
+ default:
782
+ process.stderr.write(MEMORY_USAGE);
783
+ process.exit(1);
784
+ }
785
+ }
786
+ //# sourceMappingURL=cli-memory.js.map