eklavya 1.20.1 → 1.21.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Claude Mem, seen from `eklavya install`.
3
+ *
4
+ * Both tools record every tool call and recall it at session start. Installed
5
+ * together, every session is captured twice and recalled twice, into two
6
+ * databases that never agree. So install asks once which one records, and acts
7
+ * on the answer:
8
+ *
9
+ * eklavya uninstall the Claude Mem plugin, import its history with every
10
+ * project it can place filed under its checkout, and retire
11
+ * `~/.claude-mem` (moved, never deleted — it is the rollback).
12
+ * claude-mem set `memory.enabled: false`. The quiz half is unaffected.
13
+ *
14
+ * The hard part is the project map. Claude Mem names a project with a bare
15
+ * string; Eklavya keys it by checkout path. Every Claude Mem session carries the
16
+ * Claude Code session id, and Claude Code's transcript for that id records the
17
+ * cwd — so the map is read off the transcripts rather than guessed from names.
18
+ */
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import Database from 'better-sqlite3';
24
+ import { findRepoConfig } from './config.js';
25
+ import { projectKey } from './store.js';
26
+ /** `CLAUDE_MEM_DATA_DIR` is Claude Mem's own override, so it is ours too. */
27
+ export function claudeMemDir() {
28
+ return process.env.CLAUDE_MEM_DATA_DIR ?? path.join(os.homedir(), '.claude-mem');
29
+ }
30
+ export function claudeMemDb() {
31
+ return path.join(claudeMemDir(), 'claude-mem.db');
32
+ }
33
+ function readJson(file) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ }
41
+ /** Every registered plugin id that is Claude Mem, whatever marketplace it came from. */
42
+ export function claudeMemPluginIds(claudeHome) {
43
+ const installed = readJson(path.join(claudeHome, 'plugins', 'installed_plugins.json'));
44
+ const settings = readJson(path.join(claudeHome, 'settings.json'));
45
+ const ids = [
46
+ ...Object.keys((installed.plugins ?? {})),
47
+ ...Object.keys((settings.enabledPlugins ?? {})),
48
+ ];
49
+ return [...new Set(ids.filter((id) => id.startsWith('claude-mem@')))];
50
+ }
51
+ /**
52
+ * Removes the plugin through Claude Code's own CLI, and falls back to switching
53
+ * it off in settings.json when `claude` is not on PATH. Off is enough to stop
54
+ * the double recording; the fallback just cannot delete the files.
55
+ */
56
+ export function removeClaudeMemPlugin(claudeHome, ids) {
57
+ let viaCli = true;
58
+ for (const id of ids) {
59
+ const res = spawnSync('claude', ['plugin', 'uninstall', id], {
60
+ stdio: 'ignore',
61
+ shell: process.platform === 'win32',
62
+ env: { ...process.env, CLAUDE_CONFIG_DIR: claudeHome },
63
+ });
64
+ if (res.status !== 0)
65
+ viaCli = false;
66
+ }
67
+ if (viaCli)
68
+ return 'uninstalled';
69
+ const file = path.join(claudeHome, 'settings.json');
70
+ const settings = readJson(file);
71
+ const enabled = (settings.enabledPlugins ?? {});
72
+ for (const id of ids)
73
+ enabled[id] = false;
74
+ settings.enabledPlugins = enabled;
75
+ const tmp = `${file}.tmp-${process.pid}`;
76
+ fs.writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
77
+ fs.renameSync(tmp, file);
78
+ return 'disabled';
79
+ }
80
+ /** The first `cwd` a transcript records, or null. Only the head is read. */
81
+ function transcriptCwd(file) {
82
+ try {
83
+ const fd = fs.openSync(file, 'r');
84
+ const buf = Buffer.alloc(64 * 1024);
85
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
86
+ fs.closeSync(fd);
87
+ const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(buf.toString('utf8', 0, n));
88
+ return m ? JSON.parse(`"${m[1]}"`) : null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function safeMtime(file) {
95
+ try {
96
+ return fs.statSync(file).mtimeMs;
97
+ }
98
+ catch {
99
+ return -1;
100
+ }
101
+ }
102
+ /** The checkout a path belongs to, folded through worktrees — or null if it is gone. */
103
+ function checkoutOf(dir) {
104
+ if (!dir || !fs.existsSync(dir))
105
+ return null;
106
+ const root = findRepoConfig(dir).repoRoot;
107
+ return root ? projectKey(root) : null;
108
+ }
109
+ /**
110
+ * Where a transcript directory's name points today. Claude Code names it after
111
+ * the project path with every non-alphanumeric turned into `-`, and renames it
112
+ * when the checkout moves — while the transcripts inside keep the old `cwd`. The
113
+ * encoding is lossy (`a_b`, `a-b` and `a/b` all read `a-b`), so it is decoded by
114
+ * walking the filesystem, keeping only segments that exist. Case-insensitive,
115
+ * because macOS is. Returns null on anything it cannot walk, Windows included.
116
+ */
117
+ function decodeTranscriptDir(name) {
118
+ const enc = (s) => `-${s.replace(/[^A-Za-z0-9]/g, '-')}`.toLowerCase();
119
+ const walk = (dir, rest) => {
120
+ if (!rest)
121
+ return dir;
122
+ let entries = [];
123
+ try {
124
+ entries = fs.readdirSync(dir);
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ for (const e of entries) {
130
+ const seg = enc(e);
131
+ if (rest !== seg && !rest.startsWith(`${seg}-`))
132
+ continue;
133
+ const hit = walk(path.join(dir, e), rest.slice(seg.length));
134
+ if (hit)
135
+ return hit;
136
+ }
137
+ return null;
138
+ };
139
+ return walk('/', name.toLowerCase());
140
+ }
141
+ /**
142
+ * Claude Mem project name → Eklavya project key, for every project that can be
143
+ * placed. A project is placed by majority vote over its sessions' transcripts —
144
+ * each session counts for the checkout its `cwd` is in, or, where that path is
145
+ * gone, the checkout its transcript directory now points at. Failing that, by a
146
+ * unique live checkout whose folder carries the same name. Anything else is left
147
+ * out, and the importer keeps it as-is — searchable with `--all-projects`,
148
+ * mappable later with `--map`.
149
+ *
150
+ * ponytail: reads at most 20 transcripts per project, which is plenty to vote.
151
+ */
152
+ export function guessProjectMap(sourceDb, claudeHome) {
153
+ const projectsDir = path.join(claudeHome, 'projects');
154
+ let dirs = [];
155
+ try {
156
+ dirs = fs.readdirSync(projectsDir);
157
+ }
158
+ catch {
159
+ return {};
160
+ }
161
+ const byId = new Map();
162
+ const liveByName = new Map();
163
+ for (const d of dirs) {
164
+ let files = [];
165
+ try {
166
+ files = fs.readdirSync(path.join(projectsDir, d)).filter((f) => f.endsWith('.jsonl'));
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ const paths = files.map((f) => path.join(projectsDir, d, f));
172
+ // The newest transcript is likeliest to name where the project lives now.
173
+ let newest;
174
+ let newestAt = -1;
175
+ for (const file of paths) {
176
+ const at = safeMtime(file);
177
+ if (at > newestAt)
178
+ [newest, newestAt] = [file, at];
179
+ }
180
+ const dirRoot = checkoutOf(newest ? transcriptCwd(newest) : null) ?? checkoutOf(decodeTranscriptDir(d));
181
+ for (const file of paths)
182
+ byId.set(path.basename(file, '.jsonl'), { file, dirRoot });
183
+ if (dirRoot) {
184
+ const name = path.basename(dirRoot);
185
+ liveByName.set(name, (liveByName.get(name) ?? new Set()).add(dirRoot));
186
+ }
187
+ }
188
+ const src = new Database(sourceDb, { readonly: true, fileMustExist: true });
189
+ let rows = [];
190
+ try {
191
+ rows = src
192
+ .prepare('SELECT project, group_concat(content_session_id) AS ids FROM sdk_sessions GROUP BY project')
193
+ .all();
194
+ }
195
+ catch {
196
+ /* no sdk_sessions: nothing to vote with */
197
+ }
198
+ finally {
199
+ src.close();
200
+ }
201
+ const map = {};
202
+ for (const { project, ids } of rows) {
203
+ const votes = new Map();
204
+ for (const id of (ids ?? '').split(',').slice(0, 20)) {
205
+ const t = byId.get(id);
206
+ const root = t ? checkoutOf(transcriptCwd(t.file)) ?? t.dirRoot : null;
207
+ if (root)
208
+ votes.set(root, (votes.get(root) ?? 0) + 1);
209
+ }
210
+ const best = [...votes].sort((a, b) => b[1] - a[1])[0];
211
+ const byName = liveByName.get(path.basename(project));
212
+ if (best)
213
+ map[project] = best[0];
214
+ else if (byName?.size === 1)
215
+ map[project] = [...byName][0];
216
+ }
217
+ return map;
218
+ }
219
+ /** `~/.claude-mem` → `~/.claude-mem.retired` (or `.retired-2`, …). Never a delete. */
220
+ export function retireClaudeMemDir() {
221
+ const from = claudeMemDir();
222
+ let to = `${from}.retired`;
223
+ for (let i = 2; fs.existsSync(to); i++)
224
+ to = `${from}.retired-${i}`;
225
+ fs.renameSync(from, to);
226
+ return to;
227
+ }
228
+ //# sourceMappingURL=claude-mem.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-mem.js","sourceRoot":"","sources":["../src/claude-mem.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC/C,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,6EAA6E;AAC7E,MAAM,UAAU,YAAY;IAC1B,OAAO,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,aAAa,CAAC,CAAC;AACnF,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,eAAe,CAAC,CAAC;AACpD,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY;IAC5B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAA4B,CAAC;IAC9E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,kBAAkB,CAAC,UAAkB;IACnD,MAAM,SAAS,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,SAAS,EAAE,wBAAwB,CAAC,CAAC,CAAC;IACvF,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC,CAAC;IAClE,MAAM,GAAG,GAAG;QACV,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,OAAO,IAAI,EAAE,CAAW,CAAC;QACnD,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,cAAc,IAAI,EAAE,CAAW,CAAC;KAC1D,CAAC;IACF,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;AACxE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,UAAkB,EAAE,GAAa;IACrE,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,CAAC,EAAE;YAC3D,KAAK,EAAE,QAAQ;YACf,KAAK,EAAE,OAAO,CAAC,QAAQ,KAAK,OAAO;YACnC,GAAG,EAAE,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,iBAAiB,EAAE,UAAU,EAAE;SACvD,CAAC,CAAC;QACH,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,MAAM,GAAG,KAAK,CAAC;IACvC,CAAC;IACD,IAAI,MAAM;QAAE,OAAO,aAAa,CAAC;IAEjC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC;IACpD,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAChC,MAAM,OAAO,GAAG,CAAC,QAAQ,CAAC,cAAc,IAAI,EAAE,CAA4B,CAAC;IAC3E,KAAK,MAAM,EAAE,IAAI,GAAG;QAAE,OAAO,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC;IAC1C,QAAQ,CAAC,cAAc,GAAG,OAAO,CAAC;IAClC,MAAM,GAAG,GAAG,GAAG,IAAI,QAAQ,OAAO,CAAC,GAAG,EAAE,CAAC;IACzC,EAAE,CAAC,aAAa,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACxE,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACzB,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,4EAA4E;AAC5E,SAAS,aAAa,CAAC,IAAY;IACjC,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAClC,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC;QACpC,MAAM,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACjD,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;QACjB,MAAM,CAAC,GAAG,2BAA2B,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACvE,OAAO,CAAC,CAAC,CAAC,CAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAY,CAAC,CAAC,CAAC,IAAI,CAAC;IACxD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,IAAY;IAC7B,IAAI,CAAC;QACH,OAAO,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,CAAC,CAAC;IACZ,CAAC;AACH,CAAC;AAED,wFAAwF;AACxF,SAAS,UAAU,CAAC,GAAkB;IACpC,IAAI,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC7C,MAAM,IAAI,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC;IAC1C,OAAO,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACxC,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,mBAAmB,CAAC,IAAY;IACvC,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC;IAC/E,MAAM,IAAI,GAAG,CAAC,GAAW,EAAE,IAAY,EAAiB,EAAE;QACxD,IAAI,CAAC,IAAI;YAAE,OAAO,GAAG,CAAC;QACtB,IAAI,OAAO,GAAa,EAAE,CAAC;QAC3B,IAAI,CAAC;YACH,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;QACD,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;YACxB,MAAM,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;YACnB,IAAI,IAAI,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,GAAG,GAAG,CAAC;gBAAE,SAAS;YAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;YAC5D,IAAI,GAAG;gBAAE,OAAO,GAAG,CAAC;QACtB,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC,CAAC;IACF,OAAO,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;AACvC,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,QAAgB,EAAE,UAAkB;IAClE,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;IACtD,IAAI,IAAI,GAAa,EAAE,CAAC;IACxB,IAAI,CAAC;QACH,IAAI,GAAG,EAAE,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;IACrC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,GAAG,EAAoD,CAAC;IACzE,MAAM,UAAU,GAAG,IAAI,GAAG,EAAuB,CAAC;IAClD,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,KAAK,GAAa,EAAE,CAAC;QACzB,IAAI,CAAC;YACH,KAAK,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;QACxF,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC7D,0EAA0E;QAC1E,IAAI,MAA0B,CAAC;QAC/B,IAAI,QAAQ,GAAG,CAAC,CAAC,CAAC;QAClB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,EAAE,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;YAC3B,IAAI,EAAE,GAAG,QAAQ;gBAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACrD,CAAC;QACD,MAAM,OAAO,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC;QACxG,KAAK,MAAM,IAAI,IAAI,KAAK;YAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;QACrF,IAAI,OAAO,EAAE,CAAC;YACZ,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YACpC,UAAU,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;QACzE,CAAC;IACH,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,QAAQ,CAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5E,IAAI,IAAI,GAAmD,EAAE,CAAC;IAC9D,IAAI,CAAC;QACH,IAAI,GAAG,GAAG;aACP,OAAO,CAAC,4FAA4F,CAAC;aACrG,GAAG,EAAiB,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,2CAA2C;IAC7C,CAAC;YAAS,CAAC;QACT,GAAG,CAAC,KAAK,EAAE,CAAC;IACd,CAAC;IAED,MAAM,GAAG,GAA2B,EAAE,CAAC;IACvC,KAAK,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,IAAI,EAAE,CAAC;QACpC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;QACxC,KAAK,MAAM,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC;YACrD,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YACvB,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;YACvE,IAAI,IAAI;gBAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QACxD,CAAC;QACD,MAAM,IAAI,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACvD,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;QACtD,IAAI,IAAI;YAAE,GAAG,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;aAC5B,IAAI,MAAM,EAAE,IAAI,KAAK,CAAC;YAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAE,CAAC;IAC9D,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,kBAAkB;IAChC,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,IAAI,EAAE,GAAG,GAAG,IAAI,UAAU,CAAC;IAC3B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;QAAE,EAAE,GAAG,GAAG,IAAI,YAAY,CAAC,EAAE,CAAC;IACpE,EAAE,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IACxB,OAAO,EAAE,CAAC;AACZ,CAAC"}
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { START_LEVEL } from './srs.js';
20
20
  import Database from 'better-sqlite3';
21
21
  import { startDashboard, openInBrowser } from './dashboard.js';
22
22
  import { install, uninstall, health } from './install.js';
23
+ import { check, dim, heading, verdict } from './theme.js';
23
24
  import { identityFor } from './memory/identity.js';
24
25
  import { countEntries, entryById, entryEvents, entryTags, pendingEventCount, receiptTotals, resumePaused, timeline, } from './memory/store.js';
25
26
  import { search } from './memory/search.js';
@@ -317,13 +318,14 @@ function doctor() {
317
318
  if (repoHere)
318
319
  migrateLegacyRepoConfig(repoHere, mainRepoRoot(repoHere));
319
320
  const resolved = loadConfig();
320
- const lines = [];
321
+ const rows = [];
322
+ const add = (mark, label, detail) => rows.push([mark, label, detail]);
321
323
  let ok = true;
322
324
  // Kept apart from `ok` so the blanket remedy below stays true: `eklavya
323
325
  // install` repairs a broken install and cannot do a thing about a paused
324
326
  // queue. A memory failure still exits non-zero; it just names its own fix.
325
327
  let memoryOk = true;
326
- lines.push(`home: ${eklavyaHome()}`);
328
+ add('ok', 'home', eklavyaHome());
327
329
  // The install checks come first because they are what someone is looking for
328
330
  // when Eklavya has gone quiet. Everything below reads fine on an install that
329
331
  // Claude Code can no longer load at all.
@@ -331,7 +333,7 @@ function doctor() {
331
333
  for (const check of checks) {
332
334
  if (!check.ok)
333
335
  ok = false;
334
- lines.push(`${`${check.name}:`.padEnd(10)}${check.ok ? '' : 'FAILED — '}${check.detail}`);
336
+ add(check.ok ? 'ok' : 'fail', check.name, `${check.ok ? '' : 'FAILED — '}${check.detail}`);
335
337
  }
336
338
  // Which surfaces the checks above actually cover. The `plugin` check reads
337
339
  // Claude Code's registry under `~/.claude`, which the Code tab in Claude
@@ -339,10 +341,10 @@ function doctor() {
339
341
  // inside the app's data directory and is installed from its own UI, so a
340
342
  // green doctor says nothing about it. Someone whose Cowork sessions are
341
343
  // silent needs to be told that here, not left reading a clean report.
342
- lines.push('surfaces: Claude Code CLI and the Code tab in Claude Desktop (checked above).');
343
- lines.push(' Cowork keeps a separate plugin list — install it there from Customize → Plugins;');
344
- lines.push(' this command cannot see it. Your learning history is shared either way.');
345
- lines.push(`database: ${file}${fs.existsSync(file) ? '' : ' (not created yet)'}`);
344
+ add('ok', 'surfaces', 'Claude Code CLI and the Code tab in Claude Desktop (checked above)');
345
+ add('skip', 'cowork', dim('keeps a separate plugin list — install it there from Customize → Plugins;'));
346
+ add('skip', 'cowork', dim('this command cannot see it. Your learning history is shared either way.'));
347
+ add(fs.existsSync(file) ? 'ok' : 'warn', 'database', `${file}${fs.existsSync(file) ? '' : dim(' (not created yet)')}`);
346
348
  let edgesDropped = 0;
347
349
  // Held open past the try so the memory section below can read it, and can
348
350
  // still report when it is null -- the spool drop count is exactly the number
@@ -359,20 +361,20 @@ function doctor() {
359
361
  const concepts = db.prepare('SELECT count(*) n FROM concepts').get().n;
360
362
  const attempts = db.prepare('SELECT count(*) n FROM attempts').get().n;
361
363
  const known = db.prepare('SELECT count(*) n FROM mastery WHERE score >= 0.7 AND reps >= 2').get().n;
362
- lines.push(`concepts: ${concepts}`);
363
- lines.push(`attempts: ${attempts}`);
364
- lines.push(`mastered: ${known}`);
365
- lines.push(`journal: ${String(db.pragma('journal_mode', { simple: true }))}`);
364
+ add('ok', 'concepts', String(concepts));
365
+ add('ok', 'attempts', String(attempts));
366
+ add('ok', 'mastered', String(known));
367
+ add('ok', 'journal', String(db.pragma('journal_mode', { simple: true })));
366
368
  // The project's band, and the runway left in it. Read here because `doctor`
367
369
  // is where someone looks when the questions feel wrong for them.
368
370
  const standing = levelStanding(db, resolved.config, resolved.repoRoot);
369
- lines.push(`level: ${standing.level}${standing.pinned
370
- ? ' (pinned by config — no progression)'
371
- : ` (${standing.counts.passed}/${standing.needed.answers} passing answers in ${standing.repo})`}`);
371
+ add('ok', 'level', `${standing.level} ${dim(standing.pinned
372
+ ? '(pinned by config — no progression)'
373
+ : `(${standing.counts.passed}/${standing.needed.answers} passing answers in ${standing.repo})`)}`);
372
374
  }
373
375
  catch (err) {
374
376
  ok = false;
375
- lines.push(`database error: ${err instanceof Error ? err.message : String(err)}`);
377
+ add('fail', 'database', `FAILED — ${err instanceof Error ? err.message : String(err)}`);
376
378
  }
377
379
  // The memory half. `eklavya memory status` says more, but it is scoped to one
378
380
  // project and nobody runs it when the question is "is anything broken" — so
@@ -388,20 +390,20 @@ function doctor() {
388
390
  const rawQuiz = resolved.raw.quiz;
389
391
  if (rawQuiz?.enabled === false && rawQuiz?.enforced === true) {
390
392
  ok = false;
391
- lines.push('conflict: FAILED — quiz.enabled is false and quiz.enforced is true. A gate needs ' +
393
+ add('fail', 'conflict', 'FAILED — quiz.enabled is false and quiz.enforced is true. A gate needs ' +
392
394
  'passed questions and nothing will ask any, so the enforcement is being ignored ' +
393
395
  'rather than blocking every commit. Set quiz.enabled true to gate commits, or drop ' +
394
396
  'quiz.enforced to accept the silence.');
395
397
  }
396
398
  const memory = resolved.config.memory;
397
- lines.push(`memory: ${memory.enabled ? `on · capture ${memory.capture}` : 'off (memory.enabled is false)'}`);
399
+ add(memory.enabled ? 'ok' : 'skip', 'memory', memory.enabled ? `on · capture ${memory.capture}` : 'off (memory.enabled is false)');
398
400
  if (db) {
399
401
  const entries = safely(() => countEntries(db), 0);
400
402
  const evidence = safely(() => db.prepare('SELECT count(*) n FROM evidence_events').get().n, 0);
401
403
  const waiting = safely(() => pendingEventCount(db), 0);
402
- lines.push(`memory: ${entries} entries, ${evidence} evidence events (${waiting} not yet summarised)`);
404
+ add('ok', 'memory', `${entries} entries, ${evidence} evidence events ${dim(`(${waiting} not yet summarised)`)}`);
403
405
  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`);
406
+ add('ok', 'memory', `queue ${queue.pending} pending · ${queue.paused} paused · ${queue.failed} failed`);
405
407
  // The class, never the message. `last_error` is the provider's own prose
406
408
  // and has carried a URL with a token in it; the class is what tells someone
407
409
  // whether to fix a key or a quota, and it is a fixed vocabulary.
@@ -412,20 +414,20 @@ function doctor() {
412
414
  .join(', '), '') || 'unclassified';
413
415
  if (queue.paused > 0) {
414
416
  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
+ add('fail', 'memory', `FAILED — ${queue.paused} job(s) paused (${classes('paused')}); nothing is being summarised`);
418
+ add('fail', 'memory', dim('fix the credentials or quota behind providers.observer, then: eklavya memory process'));
417
419
  }
418
420
  if (queue.failed > 0) {
419
421
  // Not a failure: a permanently failed job is a batch that will never
420
422
  // summarise, and no command repairs it. Saying so beats a clean report.
421
- lines.push(`memory: ${queue.failed} job(s) failed permanently (${classes('failed')})`);
423
+ add('warn', 'memory', `${queue.failed} job(s) failed permanently (${classes('failed')})`);
422
424
  }
423
425
  // The capture heartbeat: memory that is "on" with nothing arriving is the
424
426
  // failure the entry count alone cannot show.
425
427
  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'}`);
428
+ add('ok', 'memory', `last evidence ${newest ?? '— none captured yet'}`);
427
429
  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'}`);
430
+ add('ok', 'memory', `sync ${sync?.enabled ? `on -> ${sync.target ?? '(no target set — set sync.target)'}` : 'off'}`);
429
431
  }
430
432
  const dropped = safely(() => droppedCount(), 0);
431
433
  if (dropped > 0) {
@@ -433,10 +435,10 @@ function doctor() {
433
435
  // spool file, so nothing replays them on its own. The transcripts are the
434
436
  // only remaining copy.
435
437
  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
+ add('fail', 'memory', `FAILED — ${dropped} event(s) dropped before they reached the database`);
439
+ add('fail', 'memory', dim('recover them from this checkout’s transcripts with: eklavya memory replay'));
438
440
  }
439
- lines.push(`memory: provider ${resolved.config.providers.observer ? 'configured — batches leave this machine' : 'none — nothing leaves this machine'}`);
441
+ add('ok', 'memory', `provider ${resolved.config.providers.observer ? 'configured — batches leave this machine' : 'none — nothing leaves this machine'}`);
440
442
  db?.close();
441
443
  // Per key, not per file. A project config that sets only `quiz` must not make
442
444
  // `focus` and `cadence` claim they came from it -- they came from the
@@ -444,18 +446,18 @@ function doctor() {
444
446
  // as the dial this release renamed. `projectPath` is a path Eklavya *would*
445
447
  // write to, so the file is read rather than assumed to exist.
446
448
  const projectKeys = new Set(resolved.projectPath ? Object.keys(readConfigFile(resolved.projectPath)) : []);
447
- const from = (key) => (projectKeys.has(key) ? ' (set for this project)' : '');
449
+ const from = (key) => (projectKeys.has(key) ? dim(' (set for this project)') : '');
448
450
  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')}`);
451
- lines.push(`cadence: ${resolved.config.cadence}${resolved.config.cadence === 'interleaved'
452
- ? ` (one question mid-task, min ${resolved.config.min_minutes_between_checkpoints}m apart)`
453
- : ' (all questions at the end of the task)'}${from('cadence')}`);
451
+ add(q.enabled ? 'ok' : 'skip', 'quiz', `${q.enabled ? 'on' : 'off — memory is unaffected'}${q.enforced ? ' · enforced (commits gated)' : ''}${from('quiz')}`);
452
+ add('ok', 'focus', `${resolved.config.focus}${resolved.config.focus === 'learn' ? ` (${resolved.config.focus_topic ?? 'no topic set'})` : ''}${from('focus')}`);
453
+ add('ok', 'cadence', `${resolved.config.cadence} ${dim(resolved.config.cadence === 'interleaved'
454
+ ? `(one question mid-task, min ${resolved.config.min_minutes_between_checkpoints}m apart)`
455
+ : '(all questions at the end of the task)')}${from('cadence')}`);
454
456
  if (resolved.overrides.length > 0) {
455
- lines.push(`overridden for this project: ${resolved.overrides.join(', ')}`);
457
+ add('warn', 'overrides', `for this project: ${resolved.overrides.join(', ')}`);
456
458
  }
457
459
  if (resolved.projectPath)
458
- lines.push(`project: ${resolved.projectPath}`);
460
+ add('ok', 'project', resolved.projectPath);
459
461
  // Packs, and the one place a broken one is visible. `loadPacks` never throws
460
462
  // -- a malformed file in ~/.eklavya/packs/ makes one pack unavailable, not
461
463
  // Eklavya -- so without this line the failure is a domain that quietly never
@@ -463,41 +465,45 @@ function doctor() {
463
465
  const packs = loadPacks();
464
466
  if (packs.length > 0) {
465
467
  const good = packs.filter((p) => p.pack);
466
- lines.push(`packs: ${good.length} loaded${good.length > 0
468
+ add('ok', 'packs', `${good.length} loaded${good.length > 0
467
469
  ? ` — ${good.map((p) => `${p.pack.pack}${p.pack.version ? `@${p.pack.version}` : ''} (${p.scope})`).join(', ')}`
468
470
  : ''}`);
469
471
  if (edgesDropped > 0) {
470
472
  // An edge endpoint naming nothing is almost always a typo, and it is
471
473
  // silent everywhere else: the pack loads, the concept appears, and the
472
474
  // prerequisite it was meant to hang off simply is not there.
473
- lines.push(`packs: ${edgesDropped} edge(s) dropped — an endpoint named a slug that does not exist`);
475
+ add('warn', 'packs', `${edgesDropped} edge(s) dropped — an endpoint named a slug that does not exist`);
474
476
  }
475
477
  // The one remaining way an Eklavya file ends up in a checkout, and it only
476
478
  // ever got there before the move. Named rather than fixed: a committed pack
477
479
  // is authored content somebody reviewed, so relocating it is their call.
478
480
  const inRepo = good.filter((p) => p.scope === 'repo');
479
481
  if (inRepo.length > 0) {
480
- lines.push(`packs: ${inRepo.length} still inside the checkout (${inRepo
482
+ add('warn', 'packs', `${inRepo.length} still inside the checkout (${inRepo
481
483
  .map((p) => p.pack.pack)
482
484
  .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`);
485
+ add('warn', 'packs', dim('move them to ~/.eklavya/projects/<checkout>/packs/ to keep the repo clean'));
484
486
  }
485
487
  for (const bad of packs.filter((p) => !p.pack)) {
486
488
  // Deliberately does NOT set `ok`. A bad pack costs that pack and nothing
487
489
  // else, and the blanket remedy below is `eklavya install`, which never
488
490
  // touches ~/.eklavya/packs/ and could not repair this if it wanted to.
489
- lines.push(`packs: FAILED — ${bad.file}: ${bad.error}`);
490
- lines.push(`packs: fix or delete that file; everything else is unaffected`);
491
+ add('fail', 'packs', `FAILED — ${bad.file}: ${bad.error}`);
492
+ add('fail', 'packs', dim('fix or delete that file; everything else is unaffected'));
491
493
  }
492
494
  }
493
495
  // One fix for all of them: `install` is idempotent, so re-running it is the
494
496
  // repair. Naming it here is the whole point of the checks above — a report
495
497
  // nobody can act on is worse than no report.
496
- if (!ok) {
497
- lines.push('');
498
- lines.push('Something is broken. Run: eklavya install');
499
- }
500
- process.stdout.write(`${lines.join('\n')}\n`);
498
+ heading('eklavya doctor');
499
+ // A label is said once, and its glyph again only when the state changes: a run
500
+ // of rows under one label reads as a block, and a failure inside it still shows.
501
+ rows.forEach(([mark, label, detail], i) => {
502
+ const prev = rows[i - 1];
503
+ const same = prev?.[1] === label;
504
+ check(same && prev[0] === mark ? null : mark, same ? '' : label, detail);
505
+ });
506
+ verdict(!ok ? 'Something is broken. Run: eklavya install' : !memoryOk ? 'Memory needs attention — see above' : null, 'ALL CLEAR · Eklavya is wired up');
501
507
  if (!ok || !memoryOk)
502
508
  process.exit(1);
503
509
  }