metame-cli 1.3.23 → 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,439 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * memory.js — MetaMe Lightweight Session Memory
5
+ *
6
+ * SQLite + FTS5 keyword search, Node.js native (node:sqlite), zero deps.
7
+ * Stores distilled session summaries for cross-session recall.
8
+ *
9
+ * DB: ~/.metame/memory.db
10
+ *
11
+ * API:
12
+ * saveSession({ sessionId, project, summary, keywords, mood })
13
+ * searchSessions(query, { limit, project })
14
+ * recentSessions({ limit, project })
15
+ * getSession(sessionId)
16
+ * stats()
17
+ * close()
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const fs = require('fs');
25
+
26
+ const DB_PATH = path.join(os.homedir(), '.metame', 'memory.db');
27
+
28
+ // Lazy-init: only open DB when first called
29
+ let _db = null;
30
+
31
+ function getDb() {
32
+ if (_db) return _db;
33
+
34
+ const dir = path.dirname(DB_PATH);
35
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
36
+
37
+ const { DatabaseSync } = require('node:sqlite');
38
+ _db = new DatabaseSync(DB_PATH);
39
+
40
+ _db.exec('PRAGMA journal_mode = WAL');
41
+ _db.exec('PRAGMA busy_timeout = 3000');
42
+
43
+ // Core table
44
+ _db.exec(`
45
+ CREATE TABLE IF NOT EXISTS sessions (
46
+ id TEXT PRIMARY KEY,
47
+ project TEXT NOT NULL,
48
+ summary TEXT NOT NULL,
49
+ keywords TEXT DEFAULT '',
50
+ mood TEXT DEFAULT '',
51
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
52
+ token_cost INTEGER DEFAULT 0
53
+ )
54
+ `);
55
+
56
+ // FTS5 index for keyword search over summary + keywords
57
+ try {
58
+ _db.exec(`
59
+ CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
60
+ summary, keywords, project,
61
+ content='sessions',
62
+ content_rowid='rowid',
63
+ tokenize='trigram'
64
+ )
65
+ `);
66
+ } catch {
67
+ // FTS table may already exist with different schema on upgrade
68
+ }
69
+
70
+ // Triggers to keep FTS in sync
71
+ const triggers = [
72
+ `CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
73
+ INSERT INTO sessions_fts(rowid, summary, keywords, project)
74
+ VALUES (new.rowid, new.summary, new.keywords, new.project);
75
+ END`,
76
+ `CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
77
+ INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords, project)
78
+ VALUES ('delete', old.rowid, old.summary, old.keywords, old.project);
79
+ END`,
80
+ `CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
81
+ INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords, project)
82
+ VALUES ('delete', old.rowid, old.summary, old.keywords, old.project);
83
+ INSERT INTO sessions_fts(rowid, summary, keywords, project)
84
+ VALUES (new.rowid, new.summary, new.keywords, new.project);
85
+ END`,
86
+ ];
87
+ for (const t of triggers) {
88
+ try { _db.exec(t); } catch { /* trigger may already exist */ }
89
+ }
90
+
91
+
92
+ // ── Facts table: atomic knowledge triples ──
93
+ _db.exec(`
94
+ CREATE TABLE IF NOT EXISTS facts (
95
+ id TEXT PRIMARY KEY,
96
+ entity TEXT NOT NULL,
97
+ relation TEXT NOT NULL,
98
+ value TEXT NOT NULL,
99
+ confidence TEXT NOT NULL DEFAULT 'medium',
100
+ source_type TEXT NOT NULL DEFAULT 'session',
101
+ source_id TEXT,
102
+ project TEXT NOT NULL DEFAULT '*',
103
+ tags TEXT DEFAULT '[]',
104
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
105
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
106
+ superseded_by TEXT
107
+ )
108
+ `);
109
+
110
+ // FTS5 index for facts (separate from sessions_fts, zero compatibility risk)
111
+ try {
112
+ _db.exec(`
113
+ CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
114
+ entity, relation, value, tags,
115
+ content='facts',
116
+ content_rowid='rowid',
117
+ tokenize='trigram'
118
+ )
119
+ `);
120
+ } catch { /* already exists */ }
121
+
122
+ // Triggers to keep facts_fts in sync
123
+ const factTriggers = [
124
+ `CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
125
+ INSERT INTO facts_fts(rowid, entity, relation, value, tags)
126
+ VALUES (new.rowid, new.entity, new.relation, new.value, new.tags);
127
+ END`,
128
+ `CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
129
+ INSERT INTO facts_fts(facts_fts, rowid, entity, relation, value, tags)
130
+ VALUES ('delete', old.rowid, old.entity, old.relation, old.value, old.tags);
131
+ END`,
132
+ `CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
133
+ INSERT INTO facts_fts(facts_fts, rowid, entity, relation, value, tags)
134
+ VALUES ('delete', old.rowid, old.entity, old.relation, old.value, old.tags);
135
+ INSERT INTO facts_fts(rowid, entity, relation, value, tags)
136
+ VALUES (new.rowid, new.entity, new.relation, new.value, new.tags);
137
+ END`,
138
+ ];
139
+ for (const t of factTriggers) {
140
+ try { _db.exec(t); } catch { /* trigger may already exist */ }
141
+ }
142
+
143
+ // Indexes
144
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity)'); } catch {}
145
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project)'); } catch {}
146
+
147
+ return _db;
148
+ }
149
+
150
+ /**
151
+ * Save a distilled session summary.
152
+ *
153
+ * @param {object} opts
154
+ * @param {string} opts.sessionId - Claude session ID (unique key)
155
+ * @param {string} opts.project - Project key (e.g. 'metame', 'desktop')
156
+ * @param {string} opts.summary - Distilled summary text
157
+ * @param {string} [opts.keywords] - Comma-separated keywords for search boost
158
+ * @param {string} [opts.mood] - User mood/sentiment detected
159
+ * @param {number} [opts.tokenCost] - Approximate token cost of the session
160
+ * @returns {{ ok: boolean, id: string }}
161
+ */
162
+ function saveSession({ sessionId, project, summary, keywords = '', mood = '', tokenCost = 0 }) {
163
+ if (!sessionId || !project || !summary) {
164
+ throw new Error('saveSession requires sessionId, project, summary');
165
+ }
166
+ const db = getDb();
167
+ const stmt = db.prepare(`
168
+ INSERT INTO sessions (id, project, summary, keywords, mood, token_cost)
169
+ VALUES (?, ?, ?, ?, ?, ?)
170
+ ON CONFLICT(id) DO UPDATE SET
171
+ summary = excluded.summary,
172
+ keywords = excluded.keywords,
173
+ mood = excluded.mood,
174
+ token_cost = excluded.token_cost
175
+ `);
176
+ stmt.run(sessionId, project, summary.slice(0, 10000), keywords.slice(0, 1000), mood.slice(0, 100), tokenCost);
177
+ return { ok: true, id: sessionId };
178
+ }
179
+
180
+ /**
181
+ * Save atomic facts extracted from a session.
182
+ *
183
+ * @param {string} sessionId - Source session ID
184
+ * @param {string} project - Project key ('metame', 'desktop', '*' for global)
185
+ * @param {Array} facts - Array of { entity, relation, value, confidence, tags }
186
+ * @returns {{ saved: number, skipped: number }}
187
+ */
188
+ function saveFacts(sessionId, project, facts) {
189
+ if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0 };
190
+ const db = getDb();
191
+
192
+ // Load existing facts for dedup check
193
+ const existing = db.prepare(
194
+ "SELECT entity, relation, value FROM facts WHERE project IN (?, '*')"
195
+ ).all(project);
196
+
197
+ const insert = db.prepare(`
198
+ INSERT INTO facts (id, entity, relation, value, confidence, source_type, source_id, project, tags, created_at, updated_at)
199
+ VALUES (?, ?, ?, ?, ?, 'session', ?, ?, ?, datetime('now'), datetime('now'))
200
+ ON CONFLICT(id) DO NOTHING
201
+ `);
202
+
203
+ let saved = 0;
204
+ let skipped = 0;
205
+ const savedFacts = [];
206
+
207
+ for (const f of facts) {
208
+ // Basic validation
209
+ if (!f.entity || !f.relation || !f.value) { skipped++; continue; }
210
+ if (f.value.length < 20 || f.value.length > 300) { skipped++; continue; }
211
+
212
+ // Dedup: same entity+relation with similar value prefix
213
+ const dupKey = `${f.entity}::${f.relation}`;
214
+ const prefix = f.value.slice(0, 50);
215
+ const isDup = existing.some(e =>
216
+ `${e.entity}::${e.relation}` === dupKey && e.value.slice(0, 50) === prefix
217
+ );
218
+ if (isDup) { skipped++; continue; }
219
+
220
+ const id = `f-${sessionId.slice(0, 8)}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
221
+ const tags = JSON.stringify(Array.isArray(f.tags) ? f.tags.slice(0, 3) : []);
222
+ try {
223
+ insert.run(id, f.entity, f.relation, f.value.slice(0, 300),
224
+ f.confidence || 'medium', sessionId, project === '*' ? '*' : project, tags);
225
+ savedFacts.push({ id, entity: f.entity, relation: f.relation, value: f.value,
226
+ project: project === '*' ? '*' : project, tags: f.tags || [], created_at: new Date().toISOString() });
227
+ saved++;
228
+ } catch { skipped++; }
229
+ }
230
+
231
+ // Async sync to QMD (non-blocking, non-fatal)
232
+ if (savedFacts.length > 0) {
233
+ let qmdClient = null;
234
+ try { qmdClient = require('./qmd-client'); } catch { /* qmd-client not available */ }
235
+ if (qmdClient) qmdClient.upsertFacts(savedFacts);
236
+ }
237
+
238
+ return { saved, skipped };
239
+ }
240
+
241
+ /**
242
+ * Search facts: QMD hybrid search (if available) → FTS5 → LIKE fallback.
243
+ *
244
+ * @param {string} query - Search keywords / natural language
245
+ * @param {object} [opts]
246
+ * @param {number} [opts.limit=5] - Max results
247
+ * @param {string} [opts.project] - Filter by project (also always includes '*')
248
+ * @returns {Promise<Array>|Array} Fact objects
249
+ */
250
+ async function searchFactsAsync(query, { limit = 5, project = null } = {}) {
251
+ // Try QMD hybrid search first
252
+ let qmdClient = null;
253
+ try { qmdClient = require('./qmd-client'); } catch { /* not available */ }
254
+
255
+ if (qmdClient && qmdClient.isAvailable()) {
256
+ try {
257
+ const ids = await qmdClient.search(query, limit * 2); // fetch extra for project filter
258
+ if (ids && ids.length > 0) {
259
+ const db = getDb();
260
+ const placeholders = ids.map(() => '?').join(',');
261
+ let rows = db.prepare(
262
+ `SELECT id, entity, relation, value, confidence, project, tags, created_at
263
+ FROM facts WHERE id IN (${placeholders}) AND superseded_by IS NULL`
264
+ ).all(...ids);
265
+
266
+ // Apply project filter
267
+ if (project) {
268
+ rows = rows.filter(r => r.project === project || r.project === '*');
269
+ }
270
+
271
+ // Preserve QMD ranking order
272
+ const idOrder = new Map(ids.map((id, i) => [id, i]));
273
+ rows.sort((a, b) => (idOrder.get(a.id) ?? 999) - (idOrder.get(b.id) ?? 999));
274
+
275
+ if (rows.length > 0) return rows.slice(0, limit);
276
+ }
277
+ } catch { /* QMD failed, fall through to FTS5 */ }
278
+ }
279
+
280
+ return searchFacts(query, { limit, project });
281
+ }
282
+
283
+ /**
284
+ * Search facts by keyword (FTS5 + LIKE fallback). Synchronous.
285
+ *
286
+ * @param {string} query - Search keywords
287
+ * @param {object} [opts]
288
+ * @param {number} [opts.limit=5] - Max results
289
+ * @param {string} [opts.project] - Filter by project (also always includes '*')
290
+ * @returns {Array<{ id, entity, relation, value, confidence, project, tags, created_at }>}
291
+ */
292
+ function searchFacts(query, { limit = 5, project = null } = {}) {
293
+ if (!query || !query.trim()) return [];
294
+ const db = getDb();
295
+
296
+ const sanitized = query.trim().split(/\s+/)
297
+ .map(t => '"' + t.replace(/"/g, '') + '"').join(' ');
298
+
299
+ // FTS5 path
300
+ try {
301
+ let sql, params;
302
+ if (project) {
303
+ sql = `
304
+ SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.tags, f.created_at, rank
305
+ FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
306
+ WHERE facts_fts MATCH ? AND (f.project = ? OR f.project = '*') AND f.superseded_by IS NULL
307
+ ORDER BY rank LIMIT ?
308
+ `;
309
+ params = [sanitized, project, limit];
310
+ } else {
311
+ sql = `
312
+ SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.tags, f.created_at, rank
313
+ FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
314
+ WHERE facts_fts MATCH ? AND f.superseded_by IS NULL
315
+ ORDER BY rank LIMIT ?
316
+ `;
317
+ params = [sanitized, limit];
318
+ }
319
+ const ftsResults = db.prepare(sql).all(...params);
320
+ if (ftsResults.length > 0) return ftsResults;
321
+ } catch { /* FTS error, fall through */ }
322
+
323
+ // LIKE fallback
324
+ const like = '%' + query.trim() + '%';
325
+ const likeSql = project
326
+ ? `SELECT id, entity, relation, value, confidence, project, tags, created_at
327
+ FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
328
+ AND (project = ? OR project = '*') AND superseded_by IS NULL
329
+ ORDER BY created_at DESC LIMIT ?`
330
+ : `SELECT id, entity, relation, value, confidence, project, tags, created_at
331
+ FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
332
+ AND superseded_by IS NULL
333
+ ORDER BY created_at DESC LIMIT ?`;
334
+ return project
335
+ ? db.prepare(likeSql).all(like, like, like, project, limit)
336
+ : db.prepare(likeSql).all(like, like, like, limit);
337
+ }
338
+
339
+ /**
340
+ * Search sessions by keyword (FTS5 match).
341
+ *
342
+ * @param {string} query - Search query (FTS5 syntax supported)
343
+ * @param {object} [opts]
344
+ * @param {number} [opts.limit=5] - Max results
345
+ * @param {string} [opts.project] - Filter by project
346
+ * @returns {Array<{ id, project, summary, keywords, mood, created_at, rank }>}
347
+ */
348
+ function searchSessions(query, { limit = 5, project = null } = {}) {
349
+ if (!query || !query.trim()) return [];
350
+ const db = getDb();
351
+
352
+ // Sanitize: wrap each term in quotes to prevent FTS5 syntax errors
353
+ const sanitized = query.trim().split(/\s+/).map(t => '"' + t.replace(/"/g, '') + '"').join(' ');
354
+
355
+ let sql, params;
356
+ if (project) {
357
+ sql = `
358
+ SELECT s.id, s.project, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
359
+ FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
360
+ WHERE sessions_fts MATCH ? AND s.project = ?
361
+ ORDER BY rank LIMIT ?
362
+ `;
363
+ params = [sanitized, project, limit];
364
+ } else {
365
+ sql = `
366
+ SELECT s.id, s.project, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
367
+ FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
368
+ WHERE sessions_fts MATCH ?
369
+ ORDER BY rank LIMIT ?
370
+ `;
371
+ params = [sanitized, limit];
372
+ }
373
+
374
+ // Try FTS first, fall back to LIKE if FTS errors OR returns 0 (e.g. short CJK queries < 3 chars)
375
+ let ftsResults = [];
376
+ try { ftsResults = db.prepare(sql).all(...params); } catch { /* FTS syntax error */ }
377
+ if (ftsResults.length > 0) return ftsResults;
378
+
379
+ // LIKE fallback (handles short CJK terms like "飞书" that trigram can't match)
380
+ const likeParam = '%' + query.trim() + '%';
381
+ const likeSql = project
382
+ ? 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions WHERE (summary LIKE ? OR keywords LIKE ?) AND project = ? ORDER BY created_at DESC LIMIT ?'
383
+ : 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions WHERE (summary LIKE ? OR keywords LIKE ?) ORDER BY created_at DESC LIMIT ?';
384
+ return project
385
+ ? db.prepare(likeSql).all(likeParam, likeParam, project, limit)
386
+ : db.prepare(likeSql).all(likeParam, likeParam, limit);
387
+ }
388
+
389
+ /**
390
+ * Get most recent sessions.
391
+ *
392
+ * @param {object} [opts]
393
+ * @param {number} [opts.limit=3] - Max results
394
+ * @param {string} [opts.project] - Filter by project
395
+ * @returns {Array<{ id, project, summary, keywords, mood, created_at }>}
396
+ */
397
+ function recentSessions({ limit = 3, project = null } = {}) {
398
+ const db = getDb();
399
+ if (project) {
400
+ return db.prepare(
401
+ 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions WHERE project = ? ORDER BY created_at DESC LIMIT ?'
402
+ ).all(project, limit);
403
+ }
404
+ return db.prepare(
405
+ 'SELECT id, project, summary, keywords, mood, created_at, token_cost FROM sessions ORDER BY created_at DESC LIMIT ?'
406
+ ).all(limit);
407
+ }
408
+
409
+ /**
410
+ * Get a single session by ID.
411
+ * @param {string} sessionId
412
+ * @returns {object|null}
413
+ */
414
+ function getSession(sessionId) {
415
+ const db = getDb();
416
+ return db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) || null;
417
+ }
418
+
419
+ /**
420
+ * Get total memory stats.
421
+ * @returns {{ count, dbSizeKB, oldestDate, newestDate }}
422
+ */
423
+ function stats() {
424
+ const db = getDb();
425
+ const row = db.prepare('SELECT COUNT(*) as count, MIN(created_at) as oldest, MAX(created_at) as newest FROM sessions').get();
426
+ const factsRow = db.prepare('SELECT COUNT(*) as count FROM facts WHERE superseded_by IS NULL').get();
427
+ let dbSizeKB = 0;
428
+ try { dbSizeKB = Math.round(fs.statSync(DB_PATH).size / 1024); } catch { /* */ }
429
+ return { count: row.count, facts: factsRow.count, dbSizeKB, oldestDate: row.oldest || null, newestDate: row.newest || null };
430
+ }
431
+
432
+ /**
433
+ * Close the database connection (for clean shutdown).
434
+ */
435
+ function close() {
436
+ if (_db) { _db.close(); _db = null; }
437
+ }
438
+
439
+ module.exports = { saveSession, saveFacts, searchFacts, searchFactsAsync, searchSessions, recentSessions, getSession, stats, close, DB_PATH };
@@ -208,6 +208,37 @@ function listFormatted() {
208
208
  return lines.join('\n');
209
209
  }
210
210
 
211
+ // ---------------------------------------------------------
212
+ // Claude subprocess helper (shared by distill.js + skill-evolution.js)
213
+ // ---------------------------------------------------------
214
+ /**
215
+ * Call `claude -p --model haiku` as a subprocess with extra env vars.
216
+ * Deletes CLAUDECODE from env to prevent recursive session detection.
217
+ */
218
+ function callHaiku(input, extraEnv, timeout) {
219
+ const { execFile } = require('child_process');
220
+ const env = { ...process.env, ...extraEnv };
221
+ delete env.CLAUDECODE;
222
+ return new Promise((resolve, reject) => {
223
+ const proc = execFile(
224
+ 'claude',
225
+ ['-p', '--model', 'haiku', '--no-session-persistence'],
226
+ { env, timeout, maxBuffer: 10 * 1024 * 1024 },
227
+ (err, stdout, stderr) => {
228
+ if (err) {
229
+ const detail = (stderr || stdout || '').trim().split('\n')[0];
230
+ err.message = detail || err.message;
231
+ err.stdout = stdout;
232
+ err.stderr = stderr;
233
+ reject(err);
234
+ } else resolve(stdout.trim());
235
+ },
236
+ );
237
+ proc.stdin.write(input);
238
+ proc.stdin.end();
239
+ });
240
+ }
241
+
211
242
  // ---------------------------------------------------------
212
243
  // EXPORTS
213
244
  // ---------------------------------------------------------
@@ -226,5 +257,6 @@ module.exports = {
226
257
  removeProvider,
227
258
  setRole,
228
259
  listFormatted,
260
+ callHaiku,
229
261
  PROVIDERS_FILE,
230
262
  };