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