@monoes/monomindcli 1.12.0 → 1.13.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,461 @@
1
+ 'use strict';
2
+ /**
3
+ * memory-palace.cjs — MemPalace-inspired cross-session memory for Monomind
4
+ *
5
+ * Architecture (Wing → Room → Hall namespace hierarchy):
6
+ * Drawers verbatim 800-char chunks with 100-char overlap → drawers.jsonl
7
+ * Closets regex topic-pointer index (no AI calls) → closets.jsonl
8
+ * KG temporal knowledge graph (SQLite-free triples) → kg.json
9
+ *
10
+ * Memory stack:
11
+ * L0 identity identity.md — static, user-maintained
12
+ * L1 essential top-scored recent drawers → injected on session-restore
13
+ * L2 on-demand recall(wing, room) — explicit namespace pull
14
+ * L3 deep search search(query) — Okapi BM25 across all drawers
15
+ *
16
+ * Wire points in hook-handler.cjs:
17
+ * session-restore → wakeUp(CWD) injects L0 + L1 into session context
18
+ * post-task → storeVerbatim(...) files the verbatim task chunks
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ // ── constants ─────────────────────────────────────────────────────────────────
25
+ const DRAWER_SIZE = 800; // chars per verbatim chunk
26
+ const OVERLAP = 100; // overlap between consecutive chunks
27
+ const L1_LIMIT = 5; // max drawers surfaced in essential story
28
+ const L1_DAYS = 30; // look-back window for L1 drawers (days)
29
+ const BM25_K1 = 1.5; // term-frequency saturation
30
+ const BM25_B = 0.75; // length normalisation
31
+
32
+ // ── filesystem helpers ────────────────────────────────────────────────────────
33
+ function palaceDir(cwd) {
34
+ return path.join(cwd, '.monomind', 'palace');
35
+ }
36
+
37
+ function ensureDir(dir) {
38
+ if (!fs.existsSync(dir)) {
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ }
41
+ }
42
+
43
+ function readJsonl(filePath) {
44
+ if (!fs.existsSync(filePath)) return [];
45
+ try {
46
+ return fs.readFileSync(filePath, 'utf-8')
47
+ .split('\n')
48
+ .filter(Boolean)
49
+ .map(function(line) { return JSON.parse(line); });
50
+ } catch (e) {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ function appendJsonl(filePath, record) {
56
+ fs.appendFileSync(filePath, JSON.stringify(record) + '\n', 'utf-8');
57
+ }
58
+
59
+ function readJson(filePath, fallback) {
60
+ if (!fs.existsSync(filePath)) return fallback;
61
+ try {
62
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
63
+ } catch (e) {
64
+ return fallback;
65
+ }
66
+ }
67
+
68
+ function writeJson(filePath, data) {
69
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
70
+ }
71
+
72
+ function uid() {
73
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
74
+ }
75
+
76
+ // ── Okapi BM25 ────────────────────────────────────────────────────────────────
77
+ function tokenize(text) {
78
+ return (text || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(Boolean);
79
+ }
80
+
81
+ /**
82
+ * bm25(query, docs) → [{ id, score }, …] sorted descending
83
+ * docs: array of { id, text }
84
+ */
85
+ function bm25(query, docs) {
86
+ if (!docs || docs.length === 0) return [];
87
+ var qTerms = tokenize(query);
88
+ if (qTerms.length === 0) return docs.map(function(d) { return { id: d.id, score: 0 }; });
89
+
90
+ // Pre-tokenize every document
91
+ var tokenized = docs.map(function(d) {
92
+ return { id: d.id, tokens: tokenize(d.text || '') };
93
+ });
94
+ var N = tokenized.length;
95
+ var avgdl = tokenized.reduce(function(s, d) { return s + d.tokens.length; }, 0) / (N || 1);
96
+
97
+ // Document frequency per term
98
+ var df = {};
99
+ tokenized.forEach(function(d) {
100
+ var seen = {};
101
+ d.tokens.forEach(function(t) { seen[t] = true; });
102
+ Object.keys(seen).forEach(function(t) { df[t] = (df[t] || 0) + 1; });
103
+ });
104
+
105
+ // Score each document
106
+ return tokenized.map(function(d) {
107
+ var tf = {};
108
+ d.tokens.forEach(function(t) { tf[t] = (tf[t] || 0) + 1; });
109
+ var dl = d.tokens.length;
110
+ var score = 0;
111
+ qTerms.forEach(function(term) {
112
+ var f = tf[term] || 0;
113
+ if (f === 0) return;
114
+ var dfTerm = df[term] || 0;
115
+ var idf = Math.log((N - dfTerm + 0.5) / (dfTerm + 0.5) + 1);
116
+ score += idf * (f * (BM25_K1 + 1)) / (f + BM25_K1 * (1 - BM25_B + BM25_B * dl / avgdl));
117
+ });
118
+ return { id: d.id, score: score };
119
+ }).sort(function(a, b) { return b.score - a.score; });
120
+ }
121
+
122
+ // ── closet extraction (no AI, regex only) ─────────────────────────────────────
123
+ /**
124
+ * buildClosets(content, drawerId) → [closet record, …]
125
+ * Extracts: section headers, action phrases, proper nouns, quoted passages.
126
+ */
127
+ function buildClosets(content, drawerId) {
128
+ var results = [];
129
+ var ts = new Date().toISOString();
130
+ var m;
131
+
132
+ // Section headers (Markdown)
133
+ var headerRe = /^#{1,3}\s+(.+)$/gm;
134
+ while ((m = headerRe.exec(content)) !== null) {
135
+ results.push({ drawerId: drawerId, term: m[1].trim(), type: 'header', ts: ts });
136
+ }
137
+
138
+ // Action phrases: "verb Object"
139
+ var actionRe = /\b(built|fixed|added|implemented|created|removed|updated|refactored|deployed|configured|enabled|disabled|migrated|merged|published|released|optimized|rewrote|designed|analyzed|reviewed)\s+([A-Za-z][A-Za-z0-9_/.-]{1,50})/g;
140
+ while ((m = actionRe.exec(content)) !== null) {
141
+ results.push({ drawerId: drawerId, term: m[1] + ' ' + m[2], type: 'action', ts: ts });
142
+ }
143
+
144
+ // Proper nouns: consecutive Title Case words
145
+ var properRe = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g;
146
+ var properSeen = {};
147
+ while ((m = properRe.exec(content)) !== null) {
148
+ if (!properSeen[m[1]]) {
149
+ properSeen[m[1]] = true;
150
+ results.push({ drawerId: drawerId, term: m[1], type: 'proper', ts: ts });
151
+ }
152
+ }
153
+
154
+ // Quoted passages (3–60 chars)
155
+ var quotedRe = /"([^"]{3,60})"|`([^`]{3,60})`/g;
156
+ while ((m = quotedRe.exec(content)) !== null) {
157
+ var phrase = (m[1] || m[2]).trim();
158
+ results.push({ drawerId: drawerId, term: phrase, type: 'quoted', ts: ts });
159
+ }
160
+
161
+ return results;
162
+ }
163
+
164
+ // ── storeVerbatim ─────────────────────────────────────────────────────────────
165
+ /**
166
+ * storeVerbatim(cwd, content, { wing, room, hall })
167
+ * Chunks content into DRAWER_SIZE-char segments (with OVERLAP), stores each
168
+ * as a drawer and extracts closet topics from each chunk.
169
+ */
170
+ function storeVerbatim(cwd, content, meta) {
171
+ if (!content || typeof content !== 'string' || content.trim().length < 20) return;
172
+ var pDir = palaceDir(cwd);
173
+ ensureDir(pDir);
174
+
175
+ var wing = (meta && meta.wing) || 'general';
176
+ var room = (meta && meta.room) || 'default';
177
+ var hall = (meta && meta.hall) || '';
178
+ var ts = new Date().toISOString();
179
+
180
+ var drawersFile = path.join(pDir, 'drawers.jsonl');
181
+ var closetsFile = path.join(pDir, 'closets.jsonl');
182
+
183
+ var step = DRAWER_SIZE - OVERLAP;
184
+ var i = 0;
185
+ while (i < content.length) {
186
+ var chunk = content.slice(i, i + DRAWER_SIZE);
187
+ if (chunk.trim().length >= 20) {
188
+ var id = uid();
189
+ var drawer = { id: id, content: chunk, wing: wing, room: room, hall: hall, score: 1.0, ts: ts };
190
+ appendJsonl(drawersFile, drawer);
191
+ var closets = buildClosets(chunk, id);
192
+ closets.forEach(function(c) { appendJsonl(closetsFile, c); });
193
+ }
194
+ if (i + DRAWER_SIZE >= content.length) break;
195
+ i += step;
196
+ }
197
+ }
198
+
199
+ // ── score persistence ─────────────────────────────────────────────────────────
200
+ /**
201
+ * _bumpScores(drawersFile, ids)
202
+ * Appends score bumps to a sidecar .score-diffs.jsonl instead of rewriting
203
+ * the full drawers.jsonl on every search. Compacts when sidecar reaches 100 entries.
204
+ * Called after search/recall so frequently-retrieved drawers rise to L1.
205
+ */
206
+ function _bumpScores(drawersFile, ids) {
207
+ if (!ids || ids.length === 0) return;
208
+ var diffPath = drawersFile.replace('.jsonl', '-score-diffs.jsonl');
209
+ try {
210
+ var entry = JSON.stringify({ ts: Date.now(), bumpIds: ids, delta: 1 });
211
+ fs.appendFileSync(diffPath, entry + '\n', 'utf-8');
212
+ _maybeCompactScoreDiffs(drawersFile, diffPath);
213
+ } catch (e) { /* non-fatal */ }
214
+ }
215
+
216
+ /**
217
+ * _maybeCompactScoreDiffs(drawersFile, diffPath)
218
+ * When the sidecar accumulates >= 100 entries, apply all diffs to the drawers
219
+ * file atomically and delete the sidecar.
220
+ */
221
+ function _maybeCompactScoreDiffs(drawersFile, diffPath) {
222
+ var lines;
223
+ try {
224
+ lines = fs.readFileSync(diffPath, 'utf-8').split('\n').filter(Boolean);
225
+ } catch { return; }
226
+ if (lines.length < 100) return;
227
+ _applyScoreDiffs(drawersFile, diffPath, lines);
228
+ }
229
+
230
+ /**
231
+ * _applyScoreDiffs(drawersFile, diffPath, lines)
232
+ * Apply accumulated score diffs to drawers.jsonl atomically, then remove sidecar.
233
+ */
234
+ function _applyScoreDiffs(drawersFile, diffPath, lines) {
235
+ try {
236
+ var drawers = readJsonl(drawersFile);
237
+ var diffs = (lines || fs.readFileSync(diffPath, 'utf-8').split('\n').filter(Boolean))
238
+ .map(function(l) { try { return JSON.parse(l); } catch { return null; } })
239
+ .filter(Boolean);
240
+ for (var i = 0; i < diffs.length; i++) {
241
+ var diff = diffs[i];
242
+ var delta = diff.delta || 1;
243
+ (diff.bumpIds || []).forEach(function(id) {
244
+ var d = drawers.find(function(x) { return x.id === id; });
245
+ if (d) d.score = (d.score || 1.0) + delta;
246
+ });
247
+ }
248
+ var tmpPath = drawersFile + '.tmp';
249
+ fs.writeFileSync(tmpPath, drawers.map(function(d) { return JSON.stringify(d); }).join('\n') + '\n', 'utf-8');
250
+ fs.renameSync(tmpPath, drawersFile);
251
+ try { fs.unlinkSync(diffPath); } catch {}
252
+ } catch (e) { /* non-fatal — leave sidecar intact for next attempt */ }
253
+ }
254
+
255
+ /**
256
+ * _getEffectiveDrawers(drawersFile)
257
+ * Read drawers.jsonl and apply any pending score diffs from the sidecar,
258
+ * returning the merged result without compacting. Used by wakeUp/recall/search
259
+ * to get accurate scores without triggering a full compact.
260
+ */
261
+ function _getEffectiveDrawers(drawersFile) {
262
+ var drawers = readJsonl(drawersFile);
263
+ var diffPath = drawersFile.replace('.jsonl', '-score-diffs.jsonl');
264
+ try {
265
+ var lines = fs.readFileSync(diffPath, 'utf-8').split('\n').filter(Boolean);
266
+ var diffs = lines.map(function(l) { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
267
+ // Build score bump totals per id
268
+ var bumps = {};
269
+ diffs.forEach(function(diff) {
270
+ (diff.bumpIds || []).forEach(function(id) {
271
+ bumps[id] = (bumps[id] || 0) + (diff.delta || 1);
272
+ });
273
+ });
274
+ drawers.forEach(function(d) {
275
+ if (bumps[d.id]) d.score = (d.score || 1.0) + bumps[d.id];
276
+ });
277
+ } catch { /* sidecar absent — use raw drawers */ }
278
+ return drawers;
279
+ }
280
+
281
+ // ── search (L3 deep) ──────────────────────────────────────────────────────────
282
+ /**
283
+ * search(cwd, query, { wing, room, limit }) → drawer[]
284
+ * BM25 across all drawers + closet-topic boost, filtered by wing/room.
285
+ * Bumps score on retrieved drawers so L1 surfaces actually-used content.
286
+ */
287
+ function search(cwd, query, opts) {
288
+ opts = opts || {};
289
+ var limit = opts.limit || 5;
290
+ var pDir = palaceDir(cwd);
291
+ var drawersFile = path.join(pDir, 'drawers.jsonl');
292
+ var drawers = _getEffectiveDrawers(drawersFile);
293
+
294
+ if (opts.wing) drawers = drawers.filter(function(d) { return d.wing === opts.wing; });
295
+ if (opts.room) drawers = drawers.filter(function(d) { return d.room === opts.room; });
296
+ if (drawers.length === 0) return [];
297
+
298
+ // BM25 baseline
299
+ var docs = drawers.map(function(d) { return { id: d.id, text: d.content }; });
300
+ var ranked = bm25(query, docs);
301
+
302
+ // Closet boost: drawers whose topic terms match query words get +0.5
303
+ try {
304
+ var closets = readJsonl(path.join(pDir, 'closets.jsonl'));
305
+ var qWords = tokenize(query);
306
+ var closetBoost = {};
307
+ closets.forEach(function(c) {
308
+ var termWords = tokenize(c.term);
309
+ var hit = termWords.some(function(w) { return qWords.indexOf(w) !== -1; });
310
+ if (hit) closetBoost[c.drawerId] = (closetBoost[c.drawerId] || 0) + 0.5;
311
+ });
312
+ if (Object.keys(closetBoost).length > 0) {
313
+ ranked.forEach(function(r) { r.score += (closetBoost[r.id] || 0); });
314
+ ranked.sort(function(a, b) { return b.score - a.score; });
315
+ }
316
+ } catch (e) { /* non-fatal */ }
317
+
318
+ var topIds = ranked.slice(0, limit).map(function(r) { return r.id; });
319
+ _bumpScores(drawersFile, topIds);
320
+
321
+ var drawerMap = {};
322
+ drawers.forEach(function(d) { drawerMap[d.id] = d; });
323
+ return topIds.map(function(id) { return drawerMap[id]; }).filter(Boolean);
324
+ }
325
+
326
+ // ── recall (L2 on-demand) ─────────────────────────────────────────────────────
327
+ /**
328
+ * recall(cwd, { wing, room, limit }) → drawer[]
329
+ * Returns drawers matching the namespace, sorted by score desc.
330
+ * Bumps score on returned drawers.
331
+ */
332
+ function recall(cwd, opts) {
333
+ opts = opts || {};
334
+ var limit = opts.limit || 10;
335
+ var drawersFile = path.join(palaceDir(cwd), 'drawers.jsonl');
336
+ var drawers = _getEffectiveDrawers(drawersFile);
337
+ if (opts.wing) drawers = drawers.filter(function(d) { return d.wing === opts.wing; });
338
+ if (opts.room) drawers = drawers.filter(function(d) { return d.room === opts.room; });
339
+ var top = drawers
340
+ .sort(function(a, b) { return (b.score || 0) - (a.score || 0); })
341
+ .slice(0, limit);
342
+ _bumpScores(drawersFile, top.map(function(d) { return d.id; }));
343
+ return top;
344
+ }
345
+
346
+ // ── knowledge graph ───────────────────────────────────────────────────────────
347
+ /**
348
+ * kgAdd(cwd, subject, predicate, object, validFrom, confidence, sourceId)
349
+ * Appends a temporal triple. validFrom defaults to now; valid_to = null (open).
350
+ */
351
+ function kgAdd(cwd, subject, predicate, object, validFrom, confidence, sourceId) {
352
+ var pDir = palaceDir(cwd);
353
+ ensureDir(pDir);
354
+ var kgFile = path.join(pDir, 'kg.json');
355
+ var kg = readJson(kgFile, []);
356
+ kg.push({
357
+ id: uid(),
358
+ subject: String(subject),
359
+ predicate: String(predicate),
360
+ object: String(object),
361
+ valid_from: validFrom || new Date().toISOString(),
362
+ valid_to: null,
363
+ confidence: typeof confidence === 'number' ? confidence : 1.0,
364
+ source_id: sourceId || null,
365
+ created_at: new Date().toISOString(),
366
+ });
367
+ writeJson(kgFile, kg);
368
+ }
369
+
370
+ /**
371
+ * kgQuery(cwd, entity, asOf) → triple[]
372
+ * Triples where subject = entity, valid at the given time (defaults to now).
373
+ */
374
+ function kgQuery(cwd, entity, asOf) {
375
+ var kgFile = path.join(palaceDir(cwd), 'kg.json');
376
+ var kg = readJson(kgFile, []);
377
+ var t = asOf ? new Date(asOf).getTime() : Date.now();
378
+ return kg.filter(function(triple) {
379
+ if (triple.subject !== entity) return false;
380
+ var from = triple.valid_from ? new Date(triple.valid_from).getTime() : 0;
381
+ var to = triple.valid_to ? new Date(triple.valid_to).getTime() : Infinity;
382
+ return t >= from && t <= to;
383
+ });
384
+ }
385
+
386
+ /**
387
+ * kgTimeline(cwd, entity) → triple[] (chronological)
388
+ * Full history of facts about entity, sorted by valid_from.
389
+ */
390
+ function kgTimeline(cwd, entity) {
391
+ var kgFile = path.join(palaceDir(cwd), 'kg.json');
392
+ var kg = readJson(kgFile, []);
393
+ return kg
394
+ .filter(function(t) { return t.subject === entity; })
395
+ .sort(function(a, b) {
396
+ return new Date(a.valid_from).getTime() - new Date(b.valid_from).getTime();
397
+ });
398
+ }
399
+
400
+ // ── wakeUp (L0 + L1 injection) ───────────────────────────────────────────────
401
+ /**
402
+ * wakeUp(cwd) → string
403
+ * Loads L0 identity context + generates L1 essential story from top-scored
404
+ * recent drawers. Returns a string for console.log injection into session.
405
+ */
406
+ function wakeUp(cwd) {
407
+ var pDir = palaceDir(cwd);
408
+ var lines = [];
409
+
410
+ // L0 — identity.md (user-maintained, static)
411
+ var identityFile = path.join(pDir, 'identity.md');
412
+ if (fs.existsSync(identityFile)) {
413
+ try {
414
+ var identity = fs.readFileSync(identityFile, 'utf-8').trim();
415
+ if (identity) {
416
+ lines.push('[MEMORY_PALACE_L0] Identity:');
417
+ lines.push(identity);
418
+ }
419
+ } catch (e) { /* non-fatal */ }
420
+ }
421
+
422
+ // L1 — essential story: top-scored drawers from last L1_DAYS days
423
+ var drawersFile = path.join(pDir, 'drawers.jsonl');
424
+ if (fs.existsSync(drawersFile)) {
425
+ try {
426
+ var drawers = _getEffectiveDrawers(drawersFile);
427
+ var cutoff = Date.now() - L1_DAYS * 24 * 60 * 60 * 1000;
428
+ var recent = drawers.filter(function(d) {
429
+ return d.ts && new Date(d.ts).getTime() > cutoff;
430
+ });
431
+ var top = recent
432
+ .sort(function(a, b) { return (b.score || 0) - (a.score || 0); })
433
+ .slice(0, L1_LIMIT);
434
+
435
+ if (top.length > 0) {
436
+ lines.push('[MEMORY_PALACE_L1] Essential story (' + top.length + ' drawer' + (top.length !== 1 ? 's' : '') + '):');
437
+ top.forEach(function(d) {
438
+ var ns = (d.wing || '?') + '/' + (d.room || '?') + (d.hall ? '/' + d.hall : '');
439
+ var dateStr = d.ts ? d.ts.slice(0, 10) : '?';
440
+ var snippet = (d.content || '').slice(0, 300).replace(/\n/g, ' ');
441
+ lines.push('[' + ns + ' ' + dateStr + '] ' + snippet);
442
+ });
443
+ }
444
+ } catch (e) { /* non-fatal */ }
445
+ }
446
+
447
+ return lines.join('\n');
448
+ }
449
+
450
+ // ── exports ───────────────────────────────────────────────────────────────────
451
+ module.exports = {
452
+ wakeUp,
453
+ storeVerbatim,
454
+ buildClosets,
455
+ search,
456
+ recall,
457
+ bm25,
458
+ kgAdd,
459
+ kgQuery,
460
+ kgTimeline,
461
+ };
@@ -1,40 +1,146 @@
1
1
  'use strict';
2
2
  /**
3
3
  * Memory context bridge for hook-handler.cjs
4
- * Bridges to the CLI memory subsystem via file-based storage fallback.
5
- * Used by handlers to retrieve relevant context and store outcomes.
4
+ * Also usable as a CLI script: node memory.cjs [get|set|delete|clear|keys] [args...]
5
+ *
6
+ * CLI data path: $CLAUDE_PROJECT_DIR/.monomind/data/memory.json (or process.cwd())
7
+ * Module API: store(key, value, namespace), retrieve(query, namespace)
6
8
  */
7
9
 
8
10
  const path = require('path');
9
11
  const fs = require('fs');
10
12
 
11
13
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
12
- const MEMORY_DIR = path.join(CWD, '.monomind', 'memory');
13
- const MEMORY_INDEX = path.join(MEMORY_DIR, 'hook-memory.json');
14
14
 
15
- function ensureDir() {
16
- try { fs.mkdirSync(MEMORY_DIR, { recursive: true }); } catch (_) {}
15
+ // ── CLI storage (flat JSON key→value map) ─────────────────────────────────────
16
+
17
+ const DATA_DIR = path.join(CWD, '.monomind', 'data');
18
+ const MEMORY_FILE = path.join(DATA_DIR, 'memory.json');
19
+
20
+ function ensureDataDir() {
21
+ try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
22
+ }
23
+
24
+ function loadMemory() {
25
+ try {
26
+ if (!fs.existsSync(MEMORY_FILE)) return {};
27
+ var st = fs.statSync(MEMORY_FILE);
28
+ if (st.size > 10 * 1024 * 1024) return {};
29
+ return JSON.parse(fs.readFileSync(MEMORY_FILE, 'utf-8'));
30
+ } catch (_) {
31
+ return {};
32
+ }
17
33
  }
18
34
 
19
- var MAX_INDEX_SIZE = 50 * 1024 * 1024; // 50 MiB guard
35
+ function saveMemory(data) {
36
+ ensureDataDir();
37
+ try {
38
+ fs.writeFileSync(MEMORY_FILE, JSON.stringify(data, null, 2), 'utf-8');
39
+ } catch (_) {}
40
+ }
41
+
42
+ // ── CLI commands ──────────────────────────────────────────────────────────────
43
+
44
+ function cmdGet(key) {
45
+ var data = loadMemory();
46
+ if (!key) {
47
+ // Return all non-internal keys
48
+ var out = {};
49
+ for (var k in data) {
50
+ if (!k.startsWith('_')) out[k] = data[k];
51
+ }
52
+ process.stdout.write(JSON.stringify(out) + '\n');
53
+ return;
54
+ }
55
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
56
+ process.stdout.write(JSON.stringify(data[key]) + '\n');
57
+ } else {
58
+ process.stdout.write('undefined\n');
59
+ }
60
+ process.exit(0);
61
+ }
62
+
63
+ function cmdSet(key, value) {
64
+ if (!key) {
65
+ process.stderr.write('Key required\n');
66
+ process.exit(1);
67
+ }
68
+ var data = loadMemory();
69
+ data[key] = value;
70
+ data._updated = new Date().toISOString();
71
+ saveMemory(data);
72
+ process.stdout.write('Set: ' + key + '\n');
73
+ process.exit(0);
74
+ }
75
+
76
+ function cmdDelete(key) {
77
+ if (!key) {
78
+ process.stderr.write('Key required\n');
79
+ process.exit(1);
80
+ }
81
+ var data = loadMemory();
82
+ delete data[key];
83
+ data._updated = new Date().toISOString();
84
+ saveMemory(data);
85
+ process.stdout.write('Deleted: ' + key + '\n');
86
+ process.exit(0);
87
+ }
88
+
89
+ function cmdClear() {
90
+ saveMemory({});
91
+ process.stdout.write('Memory cleared\n');
92
+ process.exit(0);
93
+ }
94
+
95
+ function cmdKeys() {
96
+ var data = loadMemory();
97
+ var keys = Object.keys(data).filter(function(k) { return !k.startsWith('_'); });
98
+ if (keys.length > 0) {
99
+ process.stdout.write(keys.join('\n') + '\n');
100
+ } else {
101
+ process.stdout.write('');
102
+ }
103
+ process.exit(0);
104
+ }
105
+
106
+ function cmdUsage() {
107
+ process.stdout.write(
108
+ 'Usage: memory.cjs [get|set|delete|clear|keys] [key] [value...]\n' +
109
+ ' get [key] — get a key or all keys\n' +
110
+ ' set <key> <value> — set a key\n' +
111
+ ' delete <key> — delete a key\n' +
112
+ ' clear — clear all keys\n' +
113
+ ' keys — list all user-defined keys\n'
114
+ );
115
+ process.exit(0);
116
+ }
117
+
118
+ // ── Module API (for hook-handler.cjs require()) ───────────────────────────────
119
+
120
+ const HOOK_MEMORY_DIR = path.join(CWD, '.monomind', 'memory');
121
+ const HOOK_MEMORY_INDEX = path.join(HOOK_MEMORY_DIR, 'hook-memory.json');
122
+ var MAX_INDEX_SIZE = 50 * 1024 * 1024;
123
+
124
+ function ensureHookDir() {
125
+ try { fs.mkdirSync(HOOK_MEMORY_DIR, { recursive: true }); } catch (_) {}
126
+ }
20
127
 
21
128
  function loadIndex() {
22
129
  try {
23
- if (!fs.existsSync(MEMORY_INDEX)) return [];
24
- var st = fs.statSync(MEMORY_INDEX);
130
+ if (!fs.existsSync(HOOK_MEMORY_INDEX)) return [];
131
+ var st = fs.statSync(HOOK_MEMORY_INDEX);
25
132
  if (st.size > MAX_INDEX_SIZE) return [];
26
- return JSON.parse(fs.readFileSync(MEMORY_INDEX, 'utf-8'));
133
+ return JSON.parse(fs.readFileSync(HOOK_MEMORY_INDEX, 'utf-8'));
27
134
  } catch (_) {
28
135
  return [];
29
136
  }
30
137
  }
31
138
 
32
139
  function saveIndex(entries) {
33
- ensureDir();
140
+ ensureHookDir();
34
141
  try {
35
- // Keep most recent 500 entries
36
142
  var trimmed = entries.slice(-500);
37
- fs.writeFileSync(MEMORY_INDEX, JSON.stringify(trimmed, null, 2), 'utf-8');
143
+ fs.writeFileSync(HOOK_MEMORY_INDEX, JSON.stringify(trimmed, null, 2), 'utf-8');
38
144
  } catch (_) {}
39
145
  }
40
146
 
@@ -42,7 +148,6 @@ function store(key, value, namespace) {
42
148
  var entries = loadIndex();
43
149
  var ns = String(namespace || 'default').slice(0, 128);
44
150
  key = String(key || '').slice(0, 512);
45
- // Remove existing entry with same key+namespace
46
151
  entries = entries.filter(function(e) { return !(e.key === key && e.namespace === ns); });
47
152
  entries.push({ key: key, value: value, namespace: ns, storedAt: new Date().toISOString() });
48
153
  saveIndex(entries);
@@ -53,7 +158,6 @@ function retrieve(query, namespace) {
53
158
  var ns = namespace || null;
54
159
  if (ns) entries = entries.filter(function(e) { return e.namespace === ns; });
55
160
  if (!query) return entries.slice(-10);
56
- // Simple keyword search
57
161
  var q = String(query).toLowerCase();
58
162
  var scored = entries.map(function(e) {
59
163
  var text = (e.key + ' ' + JSON.stringify(e.value || '')).toLowerCase();
@@ -71,4 +175,19 @@ function retrieve(query, namespace) {
71
175
  .map(function(s) { return s.entry; });
72
176
  }
73
177
 
178
+ // ── Entry point: run CLI when spawned directly ────────────────────────────────
179
+
180
+ if (require.main === module) {
181
+ var args = process.argv.slice(2);
182
+ var cmd = args[0];
183
+ switch (cmd) {
184
+ case 'get': cmdGet(args[1]); break;
185
+ case 'set': cmdSet(args[1], args.slice(2).join(' ')); break;
186
+ case 'delete': cmdDelete(args[1]); break;
187
+ case 'clear': cmdClear(); break;
188
+ case 'keys': cmdKeys(); break;
189
+ default: cmdUsage(); break;
190
+ }
191
+ }
192
+
74
193
  module.exports = { store, retrieve };