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.
- package/README.md +1 -1
- package/dist/assets/dashboard.html +570 -15
- package/dist/assets/tutor/SKILL.md +5 -5
- package/dist/assets/tutor/references/focus-and-level.md +34 -5
- package/dist/assets/tutor/references/grading.md +1 -1
- package/dist/cli.js +753 -41
- 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 +448 -31
- package/dist/config.js.map +1 -1
- package/dist/dashboard.js +357 -1
- 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/checkpoint-quiz.js +2 -2
- package/dist/hooks/checkpoint-quiz.js.map +1 -1
- 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/pre-tool-gate.js +2 -2
- package/dist/hooks/pre-tool-gate.js.map +1 -1
- package/dist/hooks/prompt-submit-nudge.js +57 -20
- package/dist/hooks/prompt-submit-nudge.js.map +1 -1
- package/dist/hooks/session-start.js +110 -80
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop-quiz-check.js +58 -38
- package/dist/hooks/stop-quiz-check.js.map +1 -1
- package/dist/hooks/subagent-start.js +2 -2
- package/dist/hooks/subagent-start.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/packs.js +26 -12
- package/dist/packs.js.map +1 -1
- package/dist/paths.js +60 -0
- package/dist/paths.js.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/tutor.md +3 -1
- package/dist/plugin/cli/CLAUDE.md +32 -8
- package/dist/plugin/cli/eklavya-gate +80 -9
- package/dist/plugin/hooks/CLAUDE.md +44 -19
- package/dist/plugin/hooks/hooks.json +10 -0
- package/dist/plugin/scripts/install-git-hook.sh +2 -1
- package/dist/plugin/skills/CLAUDE.md +29 -17
- package/dist/plugin/skills/gate/SKILL.md +1 -1
- package/dist/plugin/skills/level/SKILL.md +3 -3
- package/dist/plugin/skills/memory/SKILL.md +88 -0
- package/dist/plugin/skills/mode/SKILL.md +24 -16
- package/dist/plugin/skills/pack/SKILL.md +8 -4
- package/dist/plugin/skills/progress/SKILL.md +1 -1
- package/dist/plugin/skills/quiz/SKILL.md +2 -2
- package/dist/plugin/skills/setup/SKILL.md +25 -11
- package/dist/plugin/skills/tutor/SKILL.md +5 -5
- package/dist/plugin/skills/tutor/references/focus-and-level.md +34 -5
- package/dist/plugin/skills/tutor/references/grading.md +1 -1
- package/dist/session.js +1 -1
- package/dist/statusline.js +20 -6
- package/dist/statusline.js.map +1 -1
- package/dist/store.js +2 -2
- package/dist/store.js.map +1 -1
- package/dist/surface.js +1 -1
- 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 +165 -32
- package/dist/tools/config_tools.js.map +1 -1
- package/dist/tools/get_learner_profile.js +2 -1
- package/dist/tools/get_learner_profile.js.map +1 -1
- package/dist/tools/get_session_quiz_plan.js +15 -9
- package/dist/tools/get_session_quiz_plan.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/tools/types.js +1 -1
- package/dist/tools/types.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +95 -25
- 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,
|
|
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 (
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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(`
|
|
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
|
|
155
|
-
fail(`Unknown setting "${key}". Known: ${
|
|
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
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
185
|
-
fail('Not inside a git repository, so there is
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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)'}${
|
|
453
|
+
: ' (all questions at the end of the task)'}${from('cadence')}`);
|
|
255
454
|
if (resolved.overrides.length > 0) {
|
|
256
|
-
lines.push(`overridden
|
|
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
|
|
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
|
|
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
|
|
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
|
|
346
|
-
// says, and a bar still reciting the dials of a
|
|
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':
|