kodelyth-ecc 1.3.0 → 1.4.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,176 @@
1
+ // =============================================================================
2
+ // Kodelyth ECC — Session Learning Extractor
3
+ // Reads a session transcript (JSONL) and extracts capture-worthy memories.
4
+ //
5
+ // Strategy: heuristic scoring, no LLM call. We look for signals that a real
6
+ // problem was solved:
7
+ // 1. Edit/Write tool followed by a passing test or successful build
8
+ // 2. User saying "that worked", "fixed it", "great", "thanks"
9
+ // 3. Same file edited 3+ times in one session (iteration → solution)
10
+ // 4. Long Bash output ending in exit code 0 after several failures
11
+ //
12
+ // Each candidate becomes a draft memory. The user (or the AI) reviews and
13
+ // confirms via /memory review. Nothing is auto-stored without confirmation —
14
+ // silent capture is how memory systems become noisy and useless.
15
+ // =============================================================================
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+
21
+ const SUCCESS_PHRASES = [
22
+ /\bthat worked\b/i,
23
+ /\bfixed it\b/i,
24
+ /\bperfect\b/i,
25
+ /\bnice\b.*\bworks\b/i,
26
+ /\bthanks\b/i,
27
+ /\bgreat\b/i,
28
+ /\bsolved\b/i,
29
+ /\bdone\b/i,
30
+ ];
31
+
32
+ const FAILURE_PHRASES = [
33
+ /\bstill broken\b/i,
34
+ /\bdoesn'?t work\b/i,
35
+ /\bnope\b/i,
36
+ /\bsame error\b/i,
37
+ ];
38
+
39
+ function readTranscript(jsonlPath) {
40
+ if (!fs.existsSync(jsonlPath)) return [];
41
+ const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
42
+ const events = [];
43
+ for (const line of lines) {
44
+ try { events.push(JSON.parse(line)); } catch { /* skip malformed */ }
45
+ }
46
+ return events;
47
+ }
48
+
49
+ function extractText(event) {
50
+ if (typeof event.content === 'string') return event.content;
51
+ if (Array.isArray(event.content)) {
52
+ return event.content
53
+ .filter(c => c && (c.type === 'text' || typeof c.text === 'string'))
54
+ .map(c => c.text || '')
55
+ .join('\n');
56
+ }
57
+ if (event.message?.content) return extractText({ content: event.message.content });
58
+ return '';
59
+ }
60
+
61
+ function detectLanguage(filesTouched) {
62
+ const exts = filesTouched.map(f => (f.match(/\.[a-z0-9]+$/i) || [''])[0].toLowerCase());
63
+ if (exts.includes('.ts') || exts.includes('.tsx')) return 'typescript';
64
+ if (exts.includes('.js') || exts.includes('.jsx')) return 'javascript';
65
+ if (exts.includes('.py')) return 'python';
66
+ if (exts.includes('.go')) return 'golang';
67
+ if (exts.includes('.rs')) return 'rust';
68
+ if (exts.includes('.java')) return 'java';
69
+ if (exts.includes('.kt')) return 'kotlin';
70
+ if (exts.includes('.swift')) return 'swift';
71
+ if (exts.includes('.rb')) return 'ruby';
72
+ if (exts.includes('.php')) return 'php';
73
+ return null;
74
+ }
75
+
76
+ function extractTags(text) {
77
+ const tags = new Set();
78
+ const taxonomy = {
79
+ 'api-integration': /\b(api|endpoint|rest|graphql|grpc|webhook)\b/i,
80
+ 'authentication': /\b(auth|jwt|oauth|sso|login|session|token)\b/i,
81
+ 'database': /\b(sql|postgres|mysql|mongo|redis|orm|migration|query)\b/i,
82
+ 'testing': /\b(test|jest|vitest|pytest|playwright|cypress|coverage)\b/i,
83
+ 'deployment': /\b(deploy|vercel|netlify|aws|docker|kubernetes|ci|cd)\b/i,
84
+ 'performance': /\b(slow|optimize|perf|cache|n\+1|memory leak)\b/i,
85
+ 'security': /\b(secure|xss|csrf|injection|vuln|sanitiz)\b/i,
86
+ 'state-management': /\b(redux|zustand|context|signal|state|store)\b/i,
87
+ 'styling': /\b(css|tailwind|styled|theme|responsive)\b/i,
88
+ 'routing': /\b(router|navigation|route|next-router|react-router)\b/i,
89
+ 'forms': /\b(form|validation|zod|yup|formik|hook-form)\b/i,
90
+ 'streaming': /\b(stream|sse|websocket|realtime)\b/i,
91
+ 'payments': /\b(stripe|paypal|payment|checkout|billing|subscription)\b/i,
92
+ 'ai-llm': /\b(openai|anthropic|llm|gpt|claude|gemini|prompt)\b/i,
93
+ };
94
+ for (const [tag, pattern] of Object.entries(taxonomy)) {
95
+ if (pattern.test(text)) tags.add(tag);
96
+ }
97
+ return Array.from(tags);
98
+ }
99
+
100
+ function scoreCandidate(events, candidateIdx) {
101
+ let score = 0;
102
+ const around = events.slice(Math.max(0, candidateIdx - 5), candidateIdx + 5);
103
+
104
+ for (const ev of around) {
105
+ const text = extractText(ev);
106
+ if (ev.role === 'user' && SUCCESS_PHRASES.some(rx => rx.test(text))) score += 3;
107
+ if (ev.role === 'user' && FAILURE_PHRASES.some(rx => rx.test(text))) score -= 2;
108
+ if (ev.tool_name === 'Bash' && /exit code 0|tests passed|all passed/i.test(text)) score += 2;
109
+ if (ev.tool_name === 'Edit' || ev.tool_name === 'Write') score += 1;
110
+ }
111
+ return score;
112
+ }
113
+
114
+ function extractCandidates(jsonlPath) {
115
+ const events = readTranscript(jsonlPath);
116
+ if (events.length < 4) return [];
117
+
118
+ const editsByFile = {};
119
+ const candidates = [];
120
+
121
+ for (let i = 0; i < events.length; i++) {
122
+ const ev = events[i];
123
+ const filePath = ev.tool_input?.file_path || ev.tool_input?.path;
124
+ if ((ev.tool_name === 'Edit' || ev.tool_name === 'Write') && filePath) {
125
+ editsByFile[filePath] = (editsByFile[filePath] || 0) + 1;
126
+ }
127
+ }
128
+
129
+ // Find user success messages — those mark candidate moments
130
+ for (let i = 0; i < events.length; i++) {
131
+ const ev = events[i];
132
+ if (ev.role !== 'user') continue;
133
+ const text = extractText(ev);
134
+ if (!SUCCESS_PHRASES.some(rx => rx.test(text))) continue;
135
+
136
+ const score = scoreCandidate(events, i);
137
+ if (score < 3) continue;
138
+
139
+ // Look back to find the problem and approach
140
+ const window = events.slice(Math.max(0, i - 20), i);
141
+ const problemEvent = window.find(e => e.role === 'user');
142
+ const problem = problemEvent ? extractText(problemEvent).split('\n')[0].slice(0, 280) : null;
143
+
144
+ const filesTouched = Array.from(new Set(
145
+ window
146
+ .filter(e => e.tool_name === 'Edit' || e.tool_name === 'Write')
147
+ .map(e => e.tool_input?.file_path || e.tool_input?.path)
148
+ .filter(Boolean)
149
+ )).slice(0, 5);
150
+
151
+ const lastAssistant = window.reverse().find(e => e.role === 'assistant');
152
+ const approach = lastAssistant ? extractText(lastAssistant).slice(0, 600) : null;
153
+
154
+ if (!problem || !approach) continue;
155
+
156
+ candidates.push({
157
+ problem,
158
+ approach,
159
+ tags: extractTags(`${problem} ${approach}`),
160
+ files: filesTouched,
161
+ language: detectLanguage(filesTouched),
162
+ score,
163
+ });
164
+ }
165
+
166
+ // Dedupe by problem
167
+ const seen = new Set();
168
+ return candidates.filter(c => {
169
+ const key = c.problem.toLowerCase().slice(0, 60);
170
+ if (seen.has(key)) return false;
171
+ seen.add(key);
172
+ return true;
173
+ });
174
+ }
175
+
176
+ module.exports = { extractCandidates };
@@ -0,0 +1,145 @@
1
+ // =============================================================================
2
+ // Kodelyth ECC — Memory Injection
3
+ // Builds a cache-friendly context block from relevant past memories.
4
+ //
5
+ // Output is structured into a STABLE prefix (first 80% of the block) and a
6
+ // VARIABLE suffix (current-session triggers). The stable prefix is identical
7
+ // across calls in the same project, which lets prompt-cache-aware models
8
+ // (Anthropic, OpenAI) hit cache and charge ~10% on the cached tokens.
9
+ // =============================================================================
10
+
11
+ 'use strict';
12
+
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+ const { recallForProject, listAll, projectHash } = require('./store');
16
+
17
+ const MAX_PATTERNS = 8;
18
+ const MAX_RECENT = 5;
19
+ const MAX_RELEVANT = 5;
20
+
21
+ function loadProjectContextSignals(projectRoot) {
22
+ const signals = [];
23
+
24
+ // package.json — language + framework
25
+ const pkgPath = path.join(projectRoot, 'package.json');
26
+ if (fs.existsSync(pkgPath)) {
27
+ try {
28
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
29
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
30
+ const frameworks = ['next','react','vue','svelte','nuxt','express','fastify','nest'];
31
+ for (const fw of frameworks) {
32
+ if (deps[fw]) signals.push(fw);
33
+ }
34
+ if (deps.typescript) signals.push('typescript');
35
+ } catch {}
36
+ }
37
+
38
+ // pyproject / requirements
39
+ if (fs.existsSync(path.join(projectRoot, 'pyproject.toml'))) signals.push('python');
40
+ if (fs.existsSync(path.join(projectRoot, 'go.mod'))) signals.push('golang');
41
+ if (fs.existsSync(path.join(projectRoot, 'Cargo.toml'))) signals.push('rust');
42
+
43
+ return signals;
44
+ }
45
+
46
+ function summarisePatterns(memories) {
47
+ // Patterns = recurring tags across the user's memory corpus
48
+ const tagCounts = {};
49
+ for (const m of memories) {
50
+ for (const tag of m.tags || []) tagCounts[tag] = (tagCounts[tag] || 0) + 1;
51
+ }
52
+ return Object.entries(tagCounts)
53
+ .filter(([, count]) => count >= 2)
54
+ .sort(([, a], [, b]) => b - a)
55
+ .slice(0, MAX_PATTERNS);
56
+ }
57
+
58
+ function recentMemories(memories, limit = MAX_RECENT) {
59
+ return memories
60
+ .slice()
61
+ .sort((a, b) => new Date(b.captured_at) - new Date(a.captured_at))
62
+ .slice(0, limit);
63
+ }
64
+
65
+ function formatMemory(m) {
66
+ const lines = [`- **${m.problem}**`];
67
+ if (m.approach) lines.push(` Approach: ${m.approach.split('\n')[0].slice(0, 240)}`);
68
+ if (m.gotchas && m.gotchas.length) lines.push(` Gotcha: ${m.gotchas[0].slice(0, 200)}`);
69
+ if (m.tags && m.tags.length) lines.push(` Tags: ${m.tags.slice(0, 5).join(', ')}`);
70
+ return lines.join('\n');
71
+ }
72
+
73
+ function buildContextBlock({
74
+ projectRoot = process.cwd(),
75
+ query = null,
76
+ modelHint = 'auto',
77
+ } = {}) {
78
+ const allMemories = listAll();
79
+ if (allMemories.length === 0) {
80
+ return null; // No memory yet — first-time user
81
+ }
82
+
83
+ const projHash = projectHash(projectRoot);
84
+ const projectMems = allMemories.filter(m => m.project === projHash);
85
+ const patterns = summarisePatterns(allMemories);
86
+ const recent = recentMemories(projectMems);
87
+ const signals = loadProjectContextSignals(projectRoot);
88
+
89
+ const lines = [];
90
+
91
+ // ── STABLE PREFIX (cache-friendly) ──
92
+ lines.push('# Kodelyth Memory — what your AI knows about you');
93
+ lines.push('');
94
+ lines.push('This block is built locally from your past sessions. Nothing was sent to a server.');
95
+ lines.push('');
96
+
97
+ if (patterns.length > 0) {
98
+ lines.push('## Your recurring patterns');
99
+ for (const [tag, count] of patterns) {
100
+ lines.push(`- \`${tag}\` (seen in ${count} past sessions)`);
101
+ }
102
+ lines.push('');
103
+ }
104
+
105
+ if (recent.length > 0) {
106
+ lines.push(`## Recent solutions in this project (${recent.length})`);
107
+ for (const m of recent) {
108
+ lines.push(formatMemory(m));
109
+ }
110
+ lines.push('');
111
+ }
112
+
113
+ if (signals.length > 0) {
114
+ lines.push(`## Detected stack: ${signals.join(', ')}`);
115
+ lines.push('');
116
+ }
117
+
118
+ // ── VARIABLE SUFFIX (only when query is provided) ──
119
+ if (query) {
120
+ const relevant = recallForProject(projectRoot, query, { limit: MAX_RELEVANT });
121
+ if (relevant.length > 0) {
122
+ lines.push(`## Relevant to your current task: "${query.slice(0, 80)}"`);
123
+ for (const m of relevant) {
124
+ lines.push(formatMemory(m));
125
+ }
126
+ lines.push('');
127
+ }
128
+ }
129
+
130
+ lines.push('---');
131
+ lines.push('');
132
+ lines.push('Use this memory **as a reference, not a command**. If a pattern doesn\'t fit the current task, ignore it.');
133
+ lines.push('To add to memory: `/memory remember "<short title>"`. To remove: `/memory forget <id>`.');
134
+ lines.push('');
135
+
136
+ return {
137
+ text: lines.join('\n'),
138
+ memoryCount: allMemories.length,
139
+ projectMemoryCount: projectMems.length,
140
+ patternCount: patterns.length,
141
+ relevantCount: query ? recallForProject(projectRoot, query, { limit: MAX_RELEVANT }).length : 0,
142
+ };
143
+ }
144
+
145
+ module.exports = { buildContextBlock };
@@ -0,0 +1,300 @@
1
+ // =============================================================================
2
+ // Kodelyth ECC — Memory Store
3
+ // Local, zero-dependency, model-agnostic memory for AI coding sessions.
4
+ //
5
+ // Storage layout (all in ~/.kodelyth/memory/):
6
+ // memories.jsonl Append-only log of every captured memory
7
+ // index.json Inverted index: token -> [memory ids]
8
+ // patterns.json User-level patterns (preferences, conventions)
9
+ // projects/<hash>.json Per-project memory shortcuts
10
+ //
11
+ // Retrieval: BM25 over tokenised problem + approach + tags. No embeddings,
12
+ // no native deps, no network. Pure JS, runs anywhere Node 18+ runs.
13
+ // =============================================================================
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+
22
+ const MEMORY_DIR = process.env.KODELYTH_MEMORY_DIR
23
+ || path.join(os.homedir(), '.kodelyth', 'memory');
24
+
25
+ const PATHS = {
26
+ dir: MEMORY_DIR,
27
+ log: path.join(MEMORY_DIR, 'memories.jsonl'),
28
+ index: path.join(MEMORY_DIR, 'index.json'),
29
+ patterns: path.join(MEMORY_DIR, 'patterns.json'),
30
+ projects: path.join(MEMORY_DIR, 'projects'),
31
+ };
32
+
33
+ // ── Stop words (English + common code noise) ─────────────────────────────────
34
+ const STOP_WORDS = new Set([
35
+ 'the','a','an','and','or','but','if','then','else','for','to','of','in','on',
36
+ 'is','are','was','were','be','been','being','have','has','had','do','does',
37
+ 'did','will','would','should','can','could','may','might','must','this','that',
38
+ 'these','those','it','its','as','at','by','from','with','about','i','you','we',
39
+ 'they','he','she','my','your','our','their','use','using','used','set','get',
40
+ 'fix','fixed','make','made','want','need','try','tried','run','running',
41
+ ]);
42
+
43
+ // ── Helpers ──────────────────────────────────────────────────────────────────
44
+ function ensureDir(dir) {
45
+ if (!fs.existsSync(dir)) {
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ }
48
+ }
49
+
50
+ function tokenise(text) {
51
+ if (!text) return [];
52
+ return String(text)
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9_\-/.\s]/g, ' ')
55
+ .split(/\s+/)
56
+ .filter(t => t.length >= 2 && t.length <= 40 && !STOP_WORDS.has(t));
57
+ }
58
+
59
+ function projectHash(projectRoot) {
60
+ return crypto
61
+ .createHash('sha256')
62
+ .update(String(projectRoot))
63
+ .digest('hex')
64
+ .slice(0, 12);
65
+ }
66
+
67
+ function newMemoryId() {
68
+ return crypto.randomBytes(8).toString('hex');
69
+ }
70
+
71
+ // ── Index ────────────────────────────────────────────────────────────────────
72
+ function loadIndex() {
73
+ if (!fs.existsSync(PATHS.index)) {
74
+ return { tokens: {}, docCount: 0, avgDocLength: 0, totalLength: 0 };
75
+ }
76
+ try {
77
+ return JSON.parse(fs.readFileSync(PATHS.index, 'utf8'));
78
+ } catch {
79
+ return { tokens: {}, docCount: 0, avgDocLength: 0, totalLength: 0 };
80
+ }
81
+ }
82
+
83
+ function saveIndex(index) {
84
+ ensureDir(PATHS.dir);
85
+ fs.writeFileSync(PATHS.index, JSON.stringify(index, null, 2));
86
+ }
87
+
88
+ function indexMemory(index, memory) {
89
+ const text = `${memory.problem || ''} ${memory.approach || ''} ${(memory.tags || []).join(' ')}`;
90
+ const tokens = tokenise(text);
91
+ const length = tokens.length;
92
+ if (length === 0) return index;
93
+
94
+ const tokenFreq = {};
95
+ for (const token of tokens) {
96
+ tokenFreq[token] = (tokenFreq[token] || 0) + 1;
97
+ }
98
+
99
+ for (const [token, freq] of Object.entries(tokenFreq)) {
100
+ if (!index.tokens[token]) {
101
+ index.tokens[token] = { docs: [], df: 0 };
102
+ }
103
+ index.tokens[token].docs.push({ id: memory.id, tf: freq, len: length });
104
+ index.tokens[token].df += 1;
105
+ }
106
+
107
+ index.totalLength += length;
108
+ index.docCount += 1;
109
+ index.avgDocLength = index.totalLength / index.docCount;
110
+
111
+ return index;
112
+ }
113
+
114
+ // ── BM25 retrieval (k1=1.5, b=0.75) ──────────────────────────────────────────
115
+ function search(query, options = {}) {
116
+ const { limit = 5, minScore = 0.5, projectFilter = null } = options;
117
+ const index = loadIndex();
118
+ const tokens = tokenise(query);
119
+ if (tokens.length === 0 || index.docCount === 0) return [];
120
+
121
+ const k1 = 1.5;
122
+ const b = 0.75;
123
+ const N = index.docCount;
124
+ const avgDl = index.avgDocLength || 1;
125
+ const scores = {};
126
+
127
+ for (const token of tokens) {
128
+ const entry = index.tokens[token];
129
+ if (!entry) continue;
130
+ const idf = Math.log(1 + (N - entry.df + 0.5) / (entry.df + 0.5));
131
+ for (const doc of entry.docs) {
132
+ const norm = 1 - b + b * (doc.len / avgDl);
133
+ const score = idf * ((doc.tf * (k1 + 1)) / (doc.tf + k1 * norm));
134
+ scores[doc.id] = (scores[doc.id] || 0) + score;
135
+ }
136
+ }
137
+
138
+ const ranked = Object.entries(scores)
139
+ .filter(([, score]) => score >= minScore)
140
+ .sort(([, a], [, b]) => b - a)
141
+ .slice(0, limit * 3);
142
+
143
+ if (ranked.length === 0) return [];
144
+
145
+ const memories = readMemories(ranked.map(([id]) => id));
146
+ let results = ranked
147
+ .map(([id, score]) => {
148
+ const memory = memories[id];
149
+ return memory ? { ...memory, score } : null;
150
+ })
151
+ .filter(Boolean);
152
+
153
+ if (projectFilter) {
154
+ results = results.filter(m => m.project === projectFilter);
155
+ }
156
+
157
+ return results.slice(0, limit);
158
+ }
159
+
160
+ // ── Memory I/O ───────────────────────────────────────────────────────────────
161
+ function readMemories(ids = null) {
162
+ if (!fs.existsSync(PATHS.log)) return ids ? {} : [];
163
+ const wantSet = ids ? new Set(ids) : null;
164
+ const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
165
+ const out = ids ? {} : [];
166
+ for (const line of lines) {
167
+ let memory;
168
+ try { memory = JSON.parse(line); } catch { continue; }
169
+ if (memory.deleted) continue;
170
+ if (wantSet) {
171
+ if (wantSet.has(memory.id)) out[memory.id] = memory;
172
+ } else {
173
+ out.push(memory);
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+
179
+ function appendMemory(memory) {
180
+ ensureDir(PATHS.dir);
181
+ fs.appendFileSync(PATHS.log, JSON.stringify(memory) + '\n');
182
+ }
183
+
184
+ // ── Public API ───────────────────────────────────────────────────────────────
185
+ function capture({
186
+ problem,
187
+ approach,
188
+ tags = [],
189
+ project = null,
190
+ language = null,
191
+ files = [],
192
+ gotchas = [],
193
+ source = 'manual',
194
+ }) {
195
+ if (!problem || !approach) {
196
+ throw new Error('capture requires both `problem` and `approach`');
197
+ }
198
+ const memory = {
199
+ id: newMemoryId(),
200
+ captured_at: new Date().toISOString(),
201
+ problem: String(problem).slice(0, 500),
202
+ approach: String(approach).slice(0, 2000),
203
+ tags: Array.from(new Set(tags.map(String))).slice(0, 20),
204
+ project: project ? projectHash(project) : null,
205
+ project_path: project,
206
+ language,
207
+ files: files.slice(0, 20),
208
+ gotchas: gotchas.slice(0, 10),
209
+ source,
210
+ };
211
+ appendMemory(memory);
212
+ const index = loadIndex();
213
+ saveIndex(indexMemory(index, memory));
214
+ return memory;
215
+ }
216
+
217
+ function recall(query, options = {}) {
218
+ return search(query, options);
219
+ }
220
+
221
+ function recallForProject(projectRoot, query, options = {}) {
222
+ const opts = { ...options, projectFilter: projectHash(projectRoot) };
223
+ const results = search(query, opts);
224
+ if (results.length >= (options.limit || 5)) return results;
225
+ // Fall back to global memories if project-specific are sparse
226
+ const globalResults = search(query, options);
227
+ const seen = new Set(results.map(r => r.id));
228
+ for (const m of globalResults) {
229
+ if (!seen.has(m.id)) results.push(m);
230
+ if (results.length >= (options.limit || 5)) break;
231
+ }
232
+ return results;
233
+ }
234
+
235
+ function listAll() {
236
+ return readMemories();
237
+ }
238
+
239
+ function forget(memoryId) {
240
+ if (!fs.existsSync(PATHS.log)) return false;
241
+ const lines = fs.readFileSync(PATHS.log, 'utf8').split('\n').filter(Boolean);
242
+ let found = false;
243
+ const updated = lines.map(line => {
244
+ try {
245
+ const m = JSON.parse(line);
246
+ if (m.id === memoryId) {
247
+ found = true;
248
+ return JSON.stringify({ ...m, deleted: true, deleted_at: new Date().toISOString() });
249
+ }
250
+ return line;
251
+ } catch {
252
+ return line;
253
+ }
254
+ });
255
+ fs.writeFileSync(PATHS.log, updated.join('\n') + '\n');
256
+ if (found) rebuildIndex();
257
+ return found;
258
+ }
259
+
260
+ function rebuildIndex() {
261
+ const memories = readMemories();
262
+ let index = { tokens: {}, docCount: 0, avgDocLength: 0, totalLength: 0 };
263
+ for (const m of memories) {
264
+ index = indexMemory(index, m);
265
+ }
266
+ saveIndex(index);
267
+ return { count: memories.length };
268
+ }
269
+
270
+ function stats() {
271
+ const memories = readMemories();
272
+ const byProject = {};
273
+ const byLanguage = {};
274
+ const byTag = {};
275
+ for (const m of memories) {
276
+ if (m.project) byProject[m.project] = (byProject[m.project] || 0) + 1;
277
+ if (m.language) byLanguage[m.language] = (byLanguage[m.language] || 0) + 1;
278
+ for (const tag of m.tags || []) byTag[tag] = (byTag[tag] || 0) + 1;
279
+ }
280
+ return {
281
+ total: memories.length,
282
+ storageDir: PATHS.dir,
283
+ projects: Object.keys(byProject).length,
284
+ byLanguage,
285
+ topTags: Object.entries(byTag).sort(([, a], [, b]) => b - a).slice(0, 10),
286
+ };
287
+ }
288
+
289
+ module.exports = {
290
+ PATHS,
291
+ capture,
292
+ recall,
293
+ recallForProject,
294
+ listAll,
295
+ forget,
296
+ rebuildIndex,
297
+ stats,
298
+ tokenise,
299
+ projectHash,
300
+ };