kodelyth-ecc 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ };
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: kodelyth-memory
3
+ description: Local self-learning memory for AI coding sessions. Captures what works, recalls it next time, shapes context for prompt-cache savings. Zero dependencies, zero telemetry, model-agnostic.
4
+ ---
5
+
6
+ # Kodelyth Memory — Skill
7
+
8
+ ## When to use
9
+
10
+ - **At session start** when the task touches a domain the user has worked in before (auth, payments, database, deployment, API integration)
11
+ - **When the user says "that worked"** or signals success after struggle — capture the lesson
12
+ - **When the user asks "have I done this before?"** or seems to be repeating past work
13
+ - **When starting a new feature** in a project with existing memory
14
+
15
+ ## How it works
16
+
17
+ ```
18
+ ┌─────────────────┐ capture ┌─────────────────┐ inject ┌─────────────────┐
19
+ │ Past session │ ─────────────→│ ~/.kodelyth/ │─────────────→│ Next session │
20
+ │ (you solved X) │ │ memory/ │ │ (X comes up) │
21
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
22
+
23
+ │ BM25 keyword + tag retrieval
24
+ │ No embeddings, no network
25
+
26
+ Cache-friendly context block
27
+ (stable prefix → cheap re-reads)
28
+ ```
29
+
30
+ ## Storage layout
31
+
32
+ All under `~/.kodelyth/memory/` (override with `KODELYTH_MEMORY_DIR`):
33
+
34
+ | File | Purpose |
35
+ |---|---|
36
+ | `memories.jsonl` | Append-only log — every captured memory |
37
+ | `index.json` | Inverted BM25 index for fast retrieval |
38
+ | `patterns.json` | User-level recurring patterns (auto-derived) |
39
+ | `projects/<hash>.json` | Per-project shortcut indexes |
40
+
41
+ ## Why BM25 instead of embeddings
42
+
43
+ | Embeddings (OpenAI/local) | BM25 (what we use) |
44
+ |---|---|
45
+ | Semantic match — finds related ideas with no shared words | Keyword + tag match |
46
+ | Requires either network calls or 50MB+ local model | Pure JS, ~3KB |
47
+ | Adds 200-2000ms latency per query | Sub-millisecond |
48
+ | Cost per session | Free forever |
49
+ | Privacy: leaks query text to provider | Stays local |
50
+
51
+ For coding memory, the things you want to recall **almost always share vocabulary** with the trigger — file paths, library names, error strings, framework terms. BM25 nails this. We deliberately chose worse semantic match for vastly better latency, privacy, and cost.
52
+
53
+ ## CLI cheatsheet
54
+
55
+ ```bash
56
+ # Add a memory manually
57
+ node scripts/memory/cli.js remember "Stripe webhook signature failed in production" \
58
+ --approach "Switched body parser from json to raw, validated with constructEvent" \
59
+ --tags payments,stripe,webhooks \
60
+ --language typescript
61
+
62
+ # Search
63
+ node scripts/memory/cli.js search "stripe webhook"
64
+
65
+ # Show what would be injected at session start
66
+ node scripts/memory/cli.js inject --query "add stripe payments"
67
+
68
+ # Extract memory candidates from a Claude Code session log
69
+ node scripts/memory/cli.js extract ~/.claude/projects/<project>/<session>.jsonl
70
+
71
+ # List all memories
72
+ node scripts/memory/cli.js list
73
+
74
+ # Storage stats
75
+ node scripts/memory/cli.js stats
76
+
77
+ # Forget one
78
+ node scripts/memory/cli.js forget <id>
79
+ ```
80
+
81
+ ## Slash command
82
+
83
+ In Claude Code:
84
+
85
+ ```
86
+ /memory # Show stats and recent memories
87
+ /memory recall <query> # Search and surface matches
88
+ /memory remember <title> # Capture a new memory (interactive)
89
+ /memory forget <id> # Delete one
90
+ /memory review-session # Extract candidates from current session
91
+ ```
92
+
93
+ ## Cache-friendly injection
94
+
95
+ The injected context block is structured for prompt cache reuse:
96
+
97
+ ```
98
+ [STABLE PREFIX — cached after first call, ~10% cost on subsequent calls]
99
+ ## Your recurring patterns (built from N sessions)
100
+ ## Recent solutions in this project
101
+ ## Detected stack: typescript, next, postgres
102
+
103
+ [VARIABLE SUFFIX — varies per query]
104
+ ## Relevant to your current task: "<query>"
105
+ ```
106
+
107
+ For Anthropic models the cache TTL is 5 minutes — typing back-to-back during a coding session keeps the prefix warm. For OpenAI models the prefix is automatically cached when ≥1024 tokens. Other models (Gemini, Llama, Mistral) do not currently cache, so for them the benefit is purely the recall quality, not cost reduction.
108
+
109
+ ## Honest limits
110
+
111
+ - **Not "the model learns"** — the model is unchanged. We're just feeding it better context.
112
+ - **Per-machine by default** — sync via Dropbox/iCloud/git on `~/.kodelyth/memory/` if needed.
113
+ - **Cloud-AI platforms** (Windsurf, Antigravity, partial Cursor) — session data is server-side. Auto-extract from past sessions doesn't work there. Manual `/memory remember` still does.
114
+ - **Privacy** — every byte stays on your disk. Verify with `ls -la ~/.kodelyth/memory/`.
115
+
116
+ ## Anti-patterns
117
+
118
+ | Don't | Do |
119
+ |---|---|
120
+ | Auto-capture every conversation | Capture only when user signals success |
121
+ | Inject all memories on every session | Inject relevant + recent + patterns only |
122
+ | Capture without showing user the draft | Always confirm before storing |
123
+ | Recall the same memory twice in one session | Track surfaced memories per session |
124
+ | Treat memory as ground truth | Memory is a hint — current task may differ |
125
+
126
+ ## Files in this skill
127
+
128
+ - `agents/kodelyth-memory.md` — the agent persona and protocols
129
+ - `scripts/memory/store.js` — storage + BM25 retrieval
130
+ - `scripts/memory/inject.js` — cache-friendly context block builder
131
+ - `scripts/memory/extract.js` — heuristic learning extractor for session logs
132
+ - `scripts/memory/cli.js` — command-line entry point
133
+ - `hooks/memory/capture-stop.js` — Stop hook that runs extractor on session end
134
+ - `hooks/memory/inject-start.js` — SessionStart hook that runs `inject`
135
+ - `commands/memory.md` — `/memory` slash command
136
+ - `rules/common/memory-protocol.md` — when AI should query memory mid-session
@@ -0,0 +1,121 @@
1
+ // Tests for scripts/memory/store.js — runs against a temp directory
2
+ 'use strict';
3
+
4
+ const test = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'kodelyth-mem-'));
11
+ process.env.KODELYTH_MEMORY_DIR = TMP;
12
+
13
+ // Require AFTER setting env so the store picks up the temp dir
14
+ const store = require('../../scripts/memory/store');
15
+ const { buildContextBlock } = require('../../scripts/memory/inject');
16
+
17
+ test('tokenise removes stopwords and short tokens', () => {
18
+ const tokens = store.tokenise('The Stripe webhook signature failed in production');
19
+ assert.ok(tokens.includes('stripe'));
20
+ assert.ok(tokens.includes('webhook'));
21
+ assert.ok(tokens.includes('signature'));
22
+ assert.ok(tokens.includes('failed'));
23
+ assert.ok(!tokens.includes('the'));
24
+ assert.ok(!tokens.includes('in'));
25
+ });
26
+
27
+ test('capture stores a memory and returns it with id', () => {
28
+ const m = store.capture({
29
+ problem: 'Stripe webhook signature failed in production',
30
+ approach: 'Switched body parser from json to raw, validated with constructEvent',
31
+ tags: ['payments', 'stripe', 'webhooks'],
32
+ project: '/test/project-a',
33
+ language: 'typescript',
34
+ });
35
+ assert.ok(m.id);
36
+ assert.equal(m.problem, 'Stripe webhook signature failed in production');
37
+ assert.equal(m.tags.length, 3);
38
+ assert.ok(m.captured_at);
39
+ });
40
+
41
+ test('capture rejects missing problem or approach', () => {
42
+ assert.throws(() => store.capture({ problem: '', approach: 'x' }));
43
+ assert.throws(() => store.capture({ problem: 'x', approach: '' }));
44
+ });
45
+
46
+ test('recall finds memory by keyword', () => {
47
+ const results = store.recall('stripe webhook');
48
+ assert.ok(results.length >= 1);
49
+ assert.match(results[0].problem, /stripe/i);
50
+ assert.ok(results[0].score > 0);
51
+ });
52
+
53
+ test('recall returns empty for irrelevant query', () => {
54
+ const results = store.recall('completely unrelated quantum mechanics');
55
+ assert.equal(results.length, 0);
56
+ });
57
+
58
+ test('recallForProject prioritises project memories then falls back to global', () => {
59
+ store.capture({
60
+ problem: 'Database connection pool exhausted',
61
+ approach: 'Increased pool size and added connection timeout',
62
+ tags: ['database', 'postgres'],
63
+ project: '/test/project-b',
64
+ language: 'typescript',
65
+ });
66
+ const projectA = store.recallForProject('/test/project-a', 'webhook');
67
+ assert.ok(projectA.length >= 1);
68
+ assert.equal(projectA[0].project_path, '/test/project-a');
69
+ });
70
+
71
+ test('listAll returns all non-deleted memories', () => {
72
+ const all = store.listAll();
73
+ assert.ok(all.length >= 2);
74
+ });
75
+
76
+ test('forget marks memory deleted', () => {
77
+ const all = store.listAll();
78
+ const target = all[0];
79
+ const ok = store.forget(target.id);
80
+ assert.equal(ok, true);
81
+ const after = store.listAll();
82
+ assert.ok(after.length < all.length);
83
+ });
84
+
85
+ test('rebuildIndex restores searchability after manual log edit', () => {
86
+ const r = store.rebuildIndex();
87
+ assert.ok(r.count >= 1);
88
+ });
89
+
90
+ test('stats summarises store contents', () => {
91
+ const s = store.stats();
92
+ assert.ok(s.total >= 1);
93
+ assert.equal(s.storageDir, TMP);
94
+ assert.ok(s.byLanguage.typescript >= 1);
95
+ });
96
+
97
+ test('buildContextBlock returns null when memory is empty', () => {
98
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kodelyth-empty-'));
99
+ process.env.KODELYTH_MEMORY_DIR = emptyDir;
100
+ // Force fresh require by clearing cache
101
+ delete require.cache[require.resolve('../../scripts/memory/store')];
102
+ delete require.cache[require.resolve('../../scripts/memory/inject')];
103
+ const { buildContextBlock: bcb } = require('../../scripts/memory/inject');
104
+ const result = bcb({ projectRoot: '/test/project-x' });
105
+ assert.equal(result, null);
106
+ // Restore
107
+ process.env.KODELYTH_MEMORY_DIR = TMP;
108
+ delete require.cache[require.resolve('../../scripts/memory/store')];
109
+ delete require.cache[require.resolve('../../scripts/memory/inject')];
110
+ });
111
+
112
+ test('buildContextBlock returns structured block when memory exists', () => {
113
+ const fresh = require('../../scripts/memory/inject');
114
+ const result = fresh.buildContextBlock({
115
+ projectRoot: '/test/project-b',
116
+ query: 'database pool',
117
+ });
118
+ assert.ok(result);
119
+ assert.ok(result.text.includes('Kodelyth Memory'));
120
+ assert.ok(result.memoryCount >= 1);
121
+ });