metame-cli 1.5.19 → 1.5.21

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.
Files changed (40) hide show
  1. package/index.js +157 -80
  2. package/package.json +2 -2
  3. package/scripts/bin/bootstrap-worktree.sh +20 -0
  4. package/scripts/core/audit.js +190 -0
  5. package/scripts/core/handoff.js +780 -0
  6. package/scripts/core/handoff.test.js +1074 -0
  7. package/scripts/core/memory-model.js +183 -0
  8. package/scripts/core/memory-model.test.js +486 -0
  9. package/scripts/core/reactive-paths.js +44 -0
  10. package/scripts/core/reactive-paths.test.js +35 -0
  11. package/scripts/core/reactive-prompt.js +51 -0
  12. package/scripts/core/reactive-prompt.test.js +88 -0
  13. package/scripts/core/reactive-signal.js +40 -0
  14. package/scripts/core/reactive-signal.test.js +88 -0
  15. package/scripts/core/thread-chat-id.js +52 -0
  16. package/scripts/core/thread-chat-id.test.js +113 -0
  17. package/scripts/daemon-bridges.js +92 -38
  18. package/scripts/daemon-claude-engine.js +373 -444
  19. package/scripts/daemon-command-router.js +82 -8
  20. package/scripts/daemon-engine-runtime.js +7 -10
  21. package/scripts/daemon-reactive-lifecycle.js +100 -33
  22. package/scripts/daemon-session-commands.js +133 -43
  23. package/scripts/daemon-session-store.js +300 -82
  24. package/scripts/daemon-team-dispatch.js +16 -16
  25. package/scripts/daemon.js +21 -175
  26. package/scripts/deploy-manifest.js +90 -0
  27. package/scripts/docs/maintenance-manual.md +14 -11
  28. package/scripts/docs/pointer-map.md +13 -4
  29. package/scripts/feishu-adapter.js +31 -27
  30. package/scripts/hooks/intent-engine.js +6 -3
  31. package/scripts/hooks/intent-memory-recall.js +1 -0
  32. package/scripts/hooks/intent-perpetual.js +1 -1
  33. package/scripts/memory-extract.js +5 -97
  34. package/scripts/memory-gc.js +35 -90
  35. package/scripts/memory-migrate-v2.js +304 -0
  36. package/scripts/memory-nightly-reflect.js +40 -41
  37. package/scripts/memory.js +340 -859
  38. package/scripts/migrate-reactive-paths.js +122 -0
  39. package/scripts/signal-capture.js +4 -0
  40. package/scripts/sync-plugin.js +56 -0
package/scripts/memory.js CHANGED
@@ -1,20 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * memory.js — MetaMe Lightweight Session Memory
4
+ * memory.js — MetaMe Unified Memory Store
5
5
  *
6
- * SQLite + FTS5 keyword search, Node.js native (node:sqlite), zero deps.
7
- * Stores distilled session summaries for cross-session recall.
6
+ * Single table: memory_items (kind: profile|convention|episode|insight)
7
+ * SQLite + FTS5 trigram search, Node.js native (node:sqlite), zero deps.
8
8
  *
9
9
  * DB: ~/.metame/memory.db
10
10
  *
11
- * API:
12
- * saveSession({ sessionId, project, scope, summary, keywords, mood })
13
- * searchSessions(query, { limit, project, scope })
14
- * recentSessions({ limit, project, scope })
15
- * getSession(sessionId)
16
- * stats()
17
- * close()
11
+ * Core API:
12
+ * saveMemoryItem(item)
13
+ * searchMemoryItems(query, opts)
14
+ * promoteItem(id)
15
+ * archiveItem(id, supersededById?)
16
+ * bumpSearchCount(id)
17
+ * readWorkingMemory(agentKey?)
18
+ * assembleContext({ query, scope, budget })
19
+ *
20
+ * Compatibility API (same-signature wrappers for existing callers):
21
+ * saveSession, saveFacts, saveFactLabels,
22
+ * searchFacts, searchFactsAsync, searchSessions,
23
+ * recentSessions, stats
18
24
  */
19
25
 
20
26
  'use strict';
@@ -24,17 +30,10 @@ const os = require('os');
24
30
  const fs = require('fs');
25
31
 
26
32
  const DB_PATH = path.join(os.homedir(), '.metame', 'memory.db');
33
+ const METAME_DIR = path.join(os.homedir(), '.metame');
34
+ const WORKING_MEMORY_DIR = path.join(METAME_DIR, 'memory', 'now');
27
35
 
28
- /** Minimal structured logger. level: 'INFO' | 'WARN' | 'ERROR' */
29
- function log(level, msg) {
30
- const ts = new Date().toISOString();
31
- process.stderr.write(`${ts} [${level}] ${msg}\n`);
32
- }
33
-
34
- // Lazy-init: only open DB when first called
35
36
  let _db = null;
36
- // Counts external callers that have called acquire() but not yet release().
37
- // Internal helpers (getDb, _trackSearch, etc.) do NOT affect this counter.
38
37
  let _refCount = 0;
39
38
 
40
39
  function getDb() {
@@ -49,951 +48,433 @@ function getDb() {
49
48
  _db.exec('PRAGMA journal_mode = WAL');
50
49
  _db.exec('PRAGMA busy_timeout = 3000');
51
50
 
52
- // Core table
53
51
  _db.exec(`
54
- CREATE TABLE IF NOT EXISTS sessions (
55
- id TEXT PRIMARY KEY,
56
- project TEXT NOT NULL,
57
- scope TEXT DEFAULT NULL,
58
- summary TEXT NOT NULL,
59
- keywords TEXT DEFAULT '',
60
- mood TEXT DEFAULT '',
61
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
62
- token_cost INTEGER DEFAULT 0
52
+ CREATE TABLE IF NOT EXISTS memory_items (
53
+ id TEXT PRIMARY KEY,
54
+ kind TEXT NOT NULL,
55
+ state TEXT NOT NULL DEFAULT 'candidate',
56
+ title TEXT,
57
+ content TEXT NOT NULL,
58
+ summary TEXT,
59
+ confidence REAL DEFAULT 0.5,
60
+ project TEXT DEFAULT '*',
61
+ scope TEXT,
62
+ task_key TEXT,
63
+ session_id TEXT,
64
+ agent_key TEXT,
65
+ supersedes_id TEXT,
66
+ source_type TEXT,
67
+ source_id TEXT,
68
+ search_count INTEGER DEFAULT 0,
69
+ last_searched_at TEXT,
70
+ tags TEXT DEFAULT '[]',
71
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
72
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
63
73
  )
64
74
  `);
65
75
 
66
- // FTS5 index for keyword search over summary + keywords
67
76
  try {
68
77
  _db.exec(`
69
- CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
70
- summary, keywords, project,
71
- content='sessions',
72
- content_rowid='rowid',
78
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_items_fts USING fts5(
79
+ title, content, tags,
80
+ content=memory_items, content_rowid=rowid,
73
81
  tokenize='trigram'
74
82
  )
75
83
  `);
76
- } catch {
77
- // FTS table may already exist with different schema on upgrade
78
- }
84
+ } catch { /* already exists */ }
79
85
 
80
- // Triggers to keep FTS in sync
81
- const triggers = [
82
- `CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
83
- INSERT INTO sessions_fts(rowid, summary, keywords, project)
84
- VALUES (new.rowid, new.summary, new.keywords, new.project);
86
+ const miTriggers = [
87
+ `CREATE TRIGGER IF NOT EXISTS mi_ai AFTER INSERT ON memory_items BEGIN
88
+ INSERT INTO memory_items_fts(rowid, title, content, tags)
89
+ VALUES (new.rowid, new.title, new.content, new.tags);
85
90
  END`,
86
- `CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
87
- INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords, project)
88
- VALUES ('delete', old.rowid, old.summary, old.keywords, old.project);
91
+ `CREATE TRIGGER IF NOT EXISTS mi_ad AFTER DELETE ON memory_items BEGIN
92
+ INSERT INTO memory_items_fts(memory_items_fts, rowid, title, content, tags)
93
+ VALUES ('delete', old.rowid, old.title, old.content, old.tags);
89
94
  END`,
90
- `CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
91
- INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords, project)
92
- VALUES ('delete', old.rowid, old.summary, old.keywords, old.project);
93
- INSERT INTO sessions_fts(rowid, summary, keywords, project)
94
- VALUES (new.rowid, new.summary, new.keywords, new.project);
95
+ `CREATE TRIGGER IF NOT EXISTS mi_au AFTER UPDATE ON memory_items BEGIN
96
+ INSERT INTO memory_items_fts(memory_items_fts, rowid, title, content, tags)
97
+ VALUES ('delete', old.rowid, old.title, old.content, old.tags);
98
+ INSERT INTO memory_items_fts(rowid, title, content, tags)
99
+ VALUES (new.rowid, new.title, new.content, new.tags);
95
100
  END`,
96
101
  ];
97
- for (const t of triggers) {
102
+ for (const t of miTriggers) {
98
103
  try { _db.exec(t); } catch { /* trigger may already exist */ }
99
104
  }
100
105
 
101
- // Backward-compatible migration for old DBs without `scope`
102
- try { _db.exec('ALTER TABLE sessions ADD COLUMN scope TEXT DEFAULT NULL'); } catch { }
103
- try { _db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_scope ON sessions(scope)'); } catch { }
104
-
106
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_mi_kind_state ON memory_items(kind, state)'); } catch { }
107
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_mi_project ON memory_items(project)'); } catch { }
108
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_mi_scope ON memory_items(scope)'); } catch { }
109
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_mi_supersedes ON memory_items(supersedes_id)'); } catch { }
105
110
 
106
- // ── Facts table: atomic knowledge triples ──
107
- _db.exec(`
108
- CREATE TABLE IF NOT EXISTS facts (
109
- id TEXT PRIMARY KEY,
110
- entity TEXT NOT NULL,
111
- relation TEXT NOT NULL,
112
- value TEXT NOT NULL,
113
- confidence TEXT NOT NULL DEFAULT 'medium',
114
- source_type TEXT NOT NULL DEFAULT 'session',
115
- source_id TEXT,
116
- project TEXT NOT NULL DEFAULT '*',
117
- scope TEXT DEFAULT NULL,
118
- tags TEXT DEFAULT '[]',
119
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
120
- updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
121
- superseded_by TEXT
122
- )
123
- `);
124
-
125
- // Optional concept label side-table (non-invasive, no ALTER on facts schema)
126
- _db.exec(`
127
- CREATE TABLE IF NOT EXISTS fact_labels (
128
- fact_id TEXT NOT NULL REFERENCES facts(id) ON DELETE CASCADE,
129
- label TEXT NOT NULL,
130
- domain TEXT,
131
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
132
- PRIMARY KEY (fact_id, label)
133
- )
134
- `);
135
-
136
- // FTS5 index for facts (separate from sessions_fts, zero compatibility risk)
137
- try {
138
- _db.exec(`
139
- CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
140
- entity, relation, value, tags,
141
- content='facts',
142
- content_rowid='rowid',
143
- tokenize='trigram'
144
- )
145
- `);
146
- } catch { /* already exists */ }
111
+ return _db;
112
+ }
147
113
 
148
- // Triggers to keep facts_fts in sync
149
- const factTriggers = [
150
- `CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
151
- INSERT INTO facts_fts(rowid, entity, relation, value, tags)
152
- VALUES (new.rowid, new.entity, new.relation, new.value, new.tags);
153
- END`,
154
- `CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
155
- INSERT INTO facts_fts(facts_fts, rowid, entity, relation, value, tags)
156
- VALUES ('delete', old.rowid, old.entity, old.relation, old.value, old.tags);
157
- END`,
158
- `CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
159
- INSERT INTO facts_fts(facts_fts, rowid, entity, relation, value, tags)
160
- VALUES ('delete', old.rowid, old.entity, old.relation, old.value, old.tags);
161
- INSERT INTO facts_fts(rowid, entity, relation, value, tags)
162
- VALUES (new.rowid, new.entity, new.relation, new.value, new.tags);
163
- END`,
164
- ];
165
- for (const t of factTriggers) {
166
- try { _db.exec(t); } catch { /* trigger may already exist */ }
167
- }
114
+ // ═══════════════════════════════════════════════════════════════════
115
+ // Core API
116
+ // ═══════════════════════════════════════════════════════════════════
168
117
 
169
- // Indexes
170
- try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity)'); } catch { }
171
- try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity_relation ON facts(entity, relation)'); } catch { }
172
- try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project)'); } catch { }
173
- try { _db.exec('CREATE INDEX IF NOT EXISTS idx_fact_labels_label ON fact_labels(label)'); } catch { }
174
- try { _db.exec('CREATE INDEX IF NOT EXISTS idx_fact_labels_domain ON fact_labels(domain)'); } catch { }
175
-
176
- // Backward-compatible migration for old DBs without `scope`
177
- try { _db.exec('ALTER TABLE facts ADD COLUMN scope TEXT DEFAULT NULL'); } catch { }
178
- try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_scope ON facts(scope)'); } catch { }
179
-
180
- // Search frequency tracking: counts how many times a fact appeared in search results.
181
- // This is a RELEVANCE PROXY, not a usefulness score — "searched" ≠ "actually helpful".
182
- // Renamed from recall_count (was ambiguous). Migration copies existing data forward.
183
- try { _db.exec('ALTER TABLE facts ADD COLUMN recall_count INTEGER DEFAULT 0'); } catch { }
184
- try { _db.exec('ALTER TABLE facts ADD COLUMN search_count INTEGER DEFAULT 0'); } catch { }
185
- try { _db.exec('ALTER TABLE facts ADD COLUMN last_searched_at TEXT'); } catch { }
186
- // One-time migration: copy recall_count → search_count for existing rows
187
- try { _db.exec('UPDATE facts SET search_count = recall_count WHERE recall_count > 0 AND search_count = 0'); } catch { }
188
-
189
- // conflict_status: 'OK' (default) | 'CONFLICT' — set by _detectConflict for non-stateful relations
190
- try { _db.exec("ALTER TABLE facts ADD COLUMN conflict_status TEXT NOT NULL DEFAULT 'OK'"); } catch { }
118
+ const memoryModel = require('./core/memory-model');
191
119
 
192
- return _db;
120
+ function generateMemoryId() {
121
+ return `mi_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
193
122
  }
194
123
 
195
- /**
196
- * Save a distilled session summary.
197
- *
198
- * @param {object} opts
199
- * @param {string} opts.sessionId - Claude session ID (unique key)
200
- * @param {string} opts.project - Project key (e.g. 'metame', 'desktop')
201
- * @param {string|null} [opts.scope] - Stable workspace scope ID (e.g. proj_<hash>)
202
- * @param {string} opts.summary - Distilled summary text
203
- * @param {string} [opts.keywords] - Comma-separated keywords for search boost
204
- * @param {string} [opts.mood] - User mood/sentiment detected
205
- * @param {number} [opts.tokenCost] - Approximate token cost of the session
206
- * @returns {{ ok: boolean, id: string }}
207
- */
208
- function saveSession({ sessionId, project, scope = null, summary, keywords = '', mood = '', tokenCost = 0 }) {
209
- if (!sessionId || !project || !summary) {
210
- throw new Error('saveSession requires sessionId, project, summary');
211
- }
212
- const normalizedProject = project === '*' ? '*' : String(project || 'unknown');
213
- const normalizedScope = normalizedProject === '*'
214
- ? '*'
215
- : (scope && typeof scope === 'string' ? scope : null);
124
+ function saveMemoryItem(item) {
125
+ if (!item || !item.content) throw new Error('saveMemoryItem requires content');
216
126
  const db = getDb();
127
+ const id = item.id || generateMemoryId();
217
128
  const stmt = db.prepare(`
218
- INSERT INTO sessions (id, project, scope, summary, keywords, mood, token_cost)
219
- VALUES (?, ?, ?, ?, ?, ?, ?)
129
+ INSERT INTO memory_items (id, kind, state, title, content, summary, confidence,
130
+ project, scope, task_key, session_id, agent_key, supersedes_id,
131
+ source_type, source_id, search_count, last_searched_at, tags,
132
+ created_at, updated_at)
133
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
220
134
  ON CONFLICT(id) DO UPDATE SET
221
- project = excluded.project,
222
- scope = excluded.scope,
223
- summary = excluded.summary,
224
- keywords = excluded.keywords,
225
- mood = excluded.mood,
226
- token_cost = excluded.token_cost
135
+ kind=excluded.kind, state=excluded.state, title=excluded.title,
136
+ content=excluded.content, summary=excluded.summary, confidence=excluded.confidence,
137
+ project=excluded.project, scope=excluded.scope, task_key=excluded.task_key,
138
+ session_id=excluded.session_id, agent_key=excluded.agent_key,
139
+ supersedes_id=excluded.supersedes_id, source_type=excluded.source_type,
140
+ source_id=excluded.source_id, tags=excluded.tags,
141
+ updated_at=datetime('now')
227
142
  `);
228
143
  stmt.run(
229
- sessionId,
230
- normalizedProject,
231
- normalizedScope,
232
- summary.slice(0, 10000),
233
- keywords.slice(0, 1000),
234
- mood.slice(0, 100),
235
- tokenCost
144
+ id,
145
+ item.kind || 'insight',
146
+ item.state || 'candidate',
147
+ item.title || null,
148
+ item.content.slice(0, 10000),
149
+ item.summary || null,
150
+ typeof item.confidence === 'number' ? item.confidence : 0.5,
151
+ item.project || '*',
152
+ item.scope || null,
153
+ item.task_key || null,
154
+ item.session_id || null,
155
+ item.agent_key || null,
156
+ item.supersedes_id || null,
157
+ item.source_type || null,
158
+ item.source_id || null,
159
+ item.search_count || 0,
160
+ item.last_searched_at || null,
161
+ typeof item.tags === 'string' ? item.tags : JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
236
162
  );
237
- return { ok: true, id: sessionId };
163
+ return { ok: true, id };
238
164
  }
239
165
 
240
- // Relations with "current state" semantics: new value replaces old.
241
- // Historical relations (tech_decision, bug_lesson, arch_convention, project_milestone) keep all versions.
242
- const STATEFUL_RELATIONS = new Set(['config_fact', 'config_change', 'workflow_rule']);
243
-
244
- /**
245
- * Save atomic facts extracted from a session.
246
- *
247
- * @param {string} sessionId - Source session ID
248
- * @param {string} project - Project key ('metame', 'desktop', '*' for global)
249
- * @param {Array} facts - Array of { entity, relation, value, confidence, tags }
250
- * @param {object} [opts]
251
- * @param {string|null} [opts.scope] - Stable workspace scope ID (e.g. proj_<hash>)
252
- * @returns {{ saved: number, skipped: number, superseded: number }}
253
- */
254
- function saveFacts(sessionId, project, facts, { scope = null } = {}) {
255
- if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0, superseded: 0 };
166
+ function searchMemoryItems(query, { kind = null, scope = null, project = null, state = 'active', limit = 20 } = {}) {
256
167
  const db = getDb();
257
- const normalizedProject = project === '*' ? '*' : String(project || 'unknown');
258
- const fallbackSessionScope = (() => {
259
- const sid = String(sessionId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24);
260
- return sid ? `sess_${sid}` : null;
261
- })();
262
- const normalizedScope = normalizedProject === '*'
263
- ? '*'
264
- : (scope && typeof scope === 'string' ? scope : (normalizedProject === 'unknown' ? fallbackSessionScope : null));
265
-
266
- let dedupScopeSql = '';
267
- let dedupScopeParams = [];
268
- if (normalizedScope === '*') {
269
- dedupScopeSql = `((scope = '*') OR (scope IS NULL AND project = '*'))`;
270
- } else if (normalizedScope) {
271
- dedupScopeSql = `((scope = ?) OR (scope = '*') OR (scope IS NULL AND project IN (?, '*')))`;
272
- dedupScopeParams = [normalizedScope, normalizedProject];
273
- } else {
274
- dedupScopeSql = `(project IN (?, '*'))`;
275
- dedupScopeParams = [normalizedProject];
168
+ const conditions = [];
169
+ const params = [];
170
+
171
+ if (state) { conditions.push('mi.state = ?'); params.push(state); }
172
+ if (kind) { conditions.push('mi.kind = ?'); params.push(kind); }
173
+ if (project && scope) {
174
+ conditions.push(`((mi.scope = ? OR mi.scope = '*') OR (mi.scope IS NULL AND (mi.project = ? OR mi.project = '*')))`);
175
+ params.push(scope, project);
176
+ } else if (scope) {
177
+ conditions.push(`(mi.scope = ? OR mi.scope = '*')`);
178
+ params.push(scope);
179
+ } else if (project) {
180
+ conditions.push(`(mi.project = ? OR mi.project = '*')`);
181
+ params.push(project);
276
182
  }
277
183
 
278
- const existsDup = db.prepare(`
279
- SELECT 1 AS ok
280
- FROM facts
281
- WHERE entity = ? AND relation = ? AND substr(value, 1, 50) = ?
282
- AND ${dedupScopeSql}
283
- LIMIT 1
284
- `);
285
-
286
- const insert = db.prepare(`
287
- INSERT INTO facts (id, entity, relation, value, confidence, source_type, source_id, project, scope, tags, created_at, updated_at)
288
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
289
- ON CONFLICT(id) DO NOTHING
290
- `);
291
-
292
- let saved = 0;
293
- let skipped = 0;
294
- let superseded = 0;
295
- let conflicts = 0;
296
- const savedFacts = [];
297
- const batchDedup = new Set();
298
-
299
- for (const f of facts) {
300
- // Basic validation
301
- if (!f.entity || !f.relation || !f.value) { skipped++; continue; }
302
- if (f.value.length < 20 || f.value.length > 300) { skipped++; continue; }
303
-
304
- // Dedup: same entity+relation with similar value prefix
305
- const dupKey = `${f.entity}::${f.relation}`;
306
- const prefix = f.value.slice(0, 50);
307
- const dedupKey = `${dupKey}::${prefix}`;
308
- const isBatchDup = batchDedup.has(dedupKey);
309
- const dbDup = existsDup.get(f.entity, f.relation, prefix, ...dedupScopeParams);
310
- const isDup = isBatchDup || !!(dbDup && dbDup.ok === 1);
311
- if (isDup) { skipped++; continue; }
312
-
313
- const id = `f-${sessionId.slice(0, 8)}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
314
- const tags = JSON.stringify(Array.isArray(f.tags) ? f.tags.slice(0, 3) : []);
184
+ if (query && query.trim()) {
185
+ const sanitized = query.trim().split(/\s+/)
186
+ .map(t => '"' + t.replace(/"/g, '') + '"').join(' ');
187
+ const where = conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '';
188
+ const sql = `
189
+ SELECT mi.*, (-fts.rank / (1.0 + ABS(fts.rank))) AS fts_rank
190
+ FROM memory_items_fts fts
191
+ JOIN memory_items mi ON mi.rowid = fts.rowid
192
+ WHERE memory_items_fts MATCH ? ${where}
193
+ ORDER BY fts.rank
194
+ LIMIT ?
195
+ `;
315
196
  try {
316
- const sourceType = f.source_type || 'session';
317
- insert.run(id, f.entity, f.relation, f.value.slice(0, 300),
318
- f.confidence || 'medium', sourceType, sessionId, normalizedProject, normalizedScope, tags);
319
- batchDedup.add(dedupKey);
320
- savedFacts.push({
321
- id, entity: f.entity, relation: f.relation, value: f.value,
322
- project: normalizedProject, scope: normalizedScope, tags: f.tags || [], created_at: new Date().toISOString()
323
- });
324
- saved++;
325
-
326
- // For stateful relations, mark older active facts with same entity::relation as superseded
327
- if (STATEFUL_RELATIONS.has(f.relation)) {
328
- let whereSql = '';
329
- let filterParams = [];
330
- if (normalizedScope === '*') {
331
- whereSql = `((scope = '*') OR (scope IS NULL AND project = '*'))`;
332
- } else if (normalizedScope) {
333
- whereSql = `((scope = ?) OR (scope IS NULL AND project = ?))`;
334
- filterParams = [normalizedScope, normalizedProject];
335
- } else {
336
- whereSql = `(project IN (?, '*'))`;
337
- filterParams = [normalizedProject];
338
- }
339
-
340
- // Fetch the IDs being superseded before running the update (for audit log)
341
- const db2 = getDb();
342
- const toSupersede = db2.prepare(
343
- `SELECT id, value FROM facts
344
- WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
345
- AND ${whereSql}`
346
- ).all(f.entity, f.relation, id, ...filterParams);
347
-
348
- const result = db.prepare(
349
- `UPDATE facts SET superseded_by = ?, updated_at = datetime('now')
350
- WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
351
- AND ${whereSql}`
352
- ).run(id, f.entity, f.relation, id, ...filterParams);
353
- const changes = result.changes || 0;
354
- superseded += changes;
355
-
356
- // Audit log: append to ~/.metame/memory_supersede_log.jsonl (never mutates, only appends)
357
- if (changes > 0) {
358
- _logSupersede(toSupersede, id, f.entity, f.relation, f.value, sessionId);
359
- }
360
- } else {
361
- // Conflict detection for non-stateful relations
362
- let whereSql = '';
363
- let filterParams = [];
364
- if (normalizedScope === '*') {
365
- whereSql = `((scope = '*') OR (scope IS NULL AND project = '*'))`;
366
- } else if (normalizedScope) {
367
- whereSql = `((scope = ?) OR (scope IS NULL AND project = ?))`;
368
- filterParams = [normalizedScope, normalizedProject];
369
- } else {
370
- whereSql = `(project IN (?, '*'))`;
371
- filterParams = [normalizedProject];
372
- }
373
- conflicts += _detectConflict(db, f, id, whereSql, filterParams, sessionId);
197
+ const rows = db.prepare(sql).all(sanitized, ...params, limit);
198
+ if (rows.length > 0) {
199
+ _trackSearch(rows.map(r => r.id));
200
+ return rows;
374
201
  }
375
- } catch { skipped++; }
376
- }
202
+ } catch { /* FTS error, fall through to LIKE */ }
377
203
 
378
- // Async sync to QMD (non-blocking, non-fatal)
379
- if (savedFacts.length > 0) {
380
- let qmdClient = null;
381
- try { qmdClient = require('./qmd-client'); } catch { /* qmd-client not available */ }
382
- if (qmdClient) qmdClient.upsertFacts(savedFacts);
204
+ const like = '%' + query.trim() + '%';
205
+ conditions.push('(mi.title LIKE ? OR mi.content LIKE ? OR mi.tags LIKE ?)');
206
+ params.push(like, like, like);
383
207
  }
384
208
 
385
- if (conflicts > 0) log('WARN', `[MEMORY] ${conflicts} conflict(s) detected`);
386
-
387
- return { saved, skipped, superseded, conflicts, savedFacts };
209
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
210
+ const fallbackSql = `SELECT mi.* FROM memory_items mi ${where} ORDER BY mi.created_at DESC LIMIT ?`;
211
+ const rows = db.prepare(fallbackSql).all(...params, limit);
212
+ if (rows.length > 0) _trackSearch(rows.map(r => r.id));
213
+ return rows;
388
214
  }
389
215
 
390
- /**
391
- * Save concept labels for facts (side-table).
392
- *
393
- * @param {Array<{fact_id:string,label:string,domain?:string}>} rows
394
- * @returns {{ saved: number, skipped: number }}
395
- */
396
- function saveFactLabels(rows) {
397
- if (!Array.isArray(rows) || rows.length === 0) return { saved: 0, skipped: 0 };
398
- const db = getDb();
399
- const upsert = db.prepare(`
400
- INSERT INTO fact_labels (fact_id, label, domain)
401
- VALUES (?, ?, ?)
402
- ON CONFLICT(fact_id, label) DO UPDATE SET
403
- domain = COALESCE(excluded.domain, fact_labels.domain)
404
- `);
405
-
406
- let saved = 0;
407
- let skipped = 0;
408
- for (const row of rows) {
409
- const factId = String(row && row.fact_id ? row.fact_id : '').trim();
410
- const label = String(row && row.label ? row.label : '').trim();
411
- const domainRaw = row && row.domain != null ? String(row.domain).trim() : '';
412
- const domain = domainRaw || null;
413
- if (!factId || !label) { skipped++; continue; }
414
- if (label.length > 60) { skipped++; continue; }
415
- if (domain && domain.length > 60) { skipped++; continue; }
416
- try {
417
- upsert.run(factId, label, domain);
418
- saved++;
419
- } catch {
420
- skipped++;
421
- }
422
- }
423
- return { saved, skipped };
424
- }
425
-
426
- /**
427
- * Increment search_count and last_searched_at for a list of fact IDs.
428
- * Semantics: "this fact appeared in search results" — NOT "this fact was useful".
429
- * High search_count = frequently retrieved. Low/zero = candidate for pruning.
430
- * Non-fatal; called after each successful search.
431
- */
432
216
  function _trackSearch(ids) {
433
217
  if (!ids || ids.length === 0) return;
434
218
  try {
435
219
  const db = getDb();
436
220
  const placeholders = ids.map(() => '?').join(',');
437
221
  db.prepare(
438
- `UPDATE facts SET search_count = search_count + 1, last_searched_at = datetime('now')
222
+ `UPDATE memory_items SET search_count = search_count + 1, last_searched_at = datetime('now')
439
223
  WHERE id IN (${placeholders})`
440
224
  ).run(...ids);
441
225
  } catch { /* non-fatal */ }
442
226
  }
443
227
 
444
- const SUPERSEDE_LOG = path.join(os.homedir(), '.metame', 'memory_supersede_log.jsonl');
445
- const CONFLICT_LOG = path.join(os.homedir(), '.metame', 'memory_conflict_log.jsonl');
228
+ function promoteItem(id) {
229
+ const db = getDb();
230
+ db.prepare(`UPDATE memory_items SET state = 'active', updated_at = datetime('now') WHERE id = ?`).run(id);
231
+ }
446
232
 
447
- /**
448
- * Append supersede operations to audit log (append-only, never mutated).
449
- * Each line: { ts, new_id, new_value_prefix, entity, relation, superseded: [{id, value_prefix}], session_id }
450
- * Use this to investigate accidental overwrites or replay if needed.
451
- */
452
- function _logSupersede(oldFacts, newId, entity, relation, newValue, sessionId) {
453
- if (!oldFacts || oldFacts.length === 0) return;
454
- try {
455
- const entry = {
456
- ts: new Date().toISOString(),
457
- entity,
458
- relation,
459
- new_id: newId,
460
- new_value: newValue.slice(0, 80),
461
- session_id: sessionId,
462
- superseded: oldFacts.map(f => ({ id: f.id, value: f.value.slice(0, 80) })),
463
- };
464
- fs.appendFileSync(SUPERSEDE_LOG, JSON.stringify(entry) + '\n', 'utf8');
465
- } catch { /* non-fatal */ }
233
+ function archiveItem(id, supersededById) {
234
+ const db = getDb();
235
+ if (supersededById) {
236
+ db.prepare(`UPDATE memory_items SET state = 'archived', supersedes_id = ?, updated_at = datetime('now') WHERE id = ?`)
237
+ .run(supersededById, id);
238
+ } else {
239
+ db.prepare(`UPDATE memory_items SET state = 'archived', updated_at = datetime('now') WHERE id = ?`).run(id);
240
+ }
466
241
  }
467
242
 
468
- /**
469
- * Detect value conflicts for non-stateful facts.
470
- *
471
- * When a new fact (entity, relation) already has an active record whose value
472
- * differs significantly from the incoming value, both are flagged CONFLICT.
473
- * "Significant difference" = trimmed values are not equal AND neither contains
474
- * the other as a substring (handles minor rewording and prefix matches).
475
- *
476
- * @param {object} db - DatabaseSync instance
477
- * @param {object} fact - The newly-inserted fact { entity, relation, value }
478
- * @param {string} newId - Row ID of the newly-inserted fact
479
- * @param {string} whereSql - Scope WHERE clause (reused from saveFacts)
480
- * @param {Array} filterParams - Bind params for whereSql
481
- * @param {string} sessionId - Source session ID (for audit log)
482
- * @returns {number} Number of conflicts detected (0 or more)
483
- */
484
- function _detectConflict(db, fact, newId, whereSql, filterParams, sessionId) {
243
+ function bumpSearchCount(id) {
244
+ const db = getDb();
245
+ db.prepare(`UPDATE memory_items SET search_count = search_count + 1, last_searched_at = datetime('now') WHERE id = ?`).run(id);
246
+ }
247
+
248
+ function readWorkingMemory(agentKey) {
485
249
  try {
486
- const existing = db.prepare(
487
- `SELECT id, value FROM facts
488
- WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
489
- AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
490
- AND ${whereSql}`
491
- ).all(fact.entity, fact.relation, newId, ...filterParams);
492
-
493
- if (existing.length === 0) return 0;
494
-
495
- const newVal = fact.value.trim();
496
- let conflictCount = 0;
497
-
498
- const conflicting = [];
499
- for (const row of existing) {
500
- const oldVal = row.value.trim();
501
- // Skip if values are equivalent or one contains the other
502
- if (oldVal === newVal) continue;
503
- if (oldVal.includes(newVal) || newVal.includes(oldVal)) continue;
504
-
505
- // Mark existing record as CONFLICT
506
- db.prepare(
507
- `UPDATE facts SET conflict_status = 'CONFLICT', updated_at = datetime('now') WHERE id = ?`
508
- ).run(row.id);
509
-
510
- conflicting.push({ id: row.id, value: row.value.slice(0, 80) });
511
- conflictCount++;
250
+ if (!fs.existsSync(WORKING_MEMORY_DIR)) return '';
251
+ if (agentKey) {
252
+ const filePath = path.join(WORKING_MEMORY_DIR, `${agentKey}.md`);
253
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8').trim() : '';
512
254
  }
513
-
514
- if (conflictCount > 0) {
515
- // Mark the new fact as CONFLICT too
516
- db.prepare(
517
- `UPDATE facts SET conflict_status = 'CONFLICT', updated_at = datetime('now') WHERE id = ?`
518
- ).run(newId);
519
-
520
- // Audit log (append-only, never mutated)
521
- try {
522
- const entry = {
523
- ts: new Date().toISOString(),
524
- entity: fact.entity,
525
- relation: fact.relation,
526
- new_id: newId,
527
- new_value: fact.value.slice(0, 80),
528
- session_id: sessionId,
529
- conflicting,
530
- };
531
- fs.appendFileSync(CONFLICT_LOG, JSON.stringify(entry) + '\n', 'utf8');
532
- } catch { /* non-fatal */ }
255
+ const files = fs.readdirSync(WORKING_MEMORY_DIR).filter(f => f.endsWith('.md'));
256
+ const parts = [];
257
+ for (const f of files) {
258
+ const content = fs.readFileSync(path.join(WORKING_MEMORY_DIR, f), 'utf8').trim();
259
+ if (content) parts.push(`[${f.replace('.md', '')}]\n${content}`);
533
260
  }
261
+ return parts.join('\n\n');
262
+ } catch { return ''; }
263
+ }
534
264
 
535
- return conflictCount;
536
- } catch { return 0; }
265
+ function assembleContext({ query, scope = {}, budget } = {}) {
266
+ const items = searchMemoryItems(query, {
267
+ state: 'active',
268
+ project: scope.project,
269
+ scope: scope.workspace,
270
+ limit: 50,
271
+ });
272
+ const working = readWorkingMemory(scope.agent);
273
+ const ranked = memoryModel.rankMemoryItems(items, query, {
274
+ project: scope.project,
275
+ scope: scope.workspace,
276
+ task: scope.task,
277
+ session: scope.session,
278
+ agent: scope.agent,
279
+ });
280
+ const allocated = memoryModel.allocateBudget(ranked, budget);
281
+ const blocks = memoryModel.assemblePromptBlocks(allocated);
282
+ blocks.working = working;
283
+ return blocks;
537
284
  }
538
285
 
539
- /**
540
- * Scope filter semantics (new + legacy):
541
- * - New rows: prefer `scope` exact match or global scope '*'
542
- * - Legacy rows (scope NULL): fallback to project match or project='*'
543
- */
544
- function _matchesFactScope(row, project, scope) {
545
- if (!row) return false;
546
- const rowScope = row.scope === undefined ? null : row.scope;
547
- if (scope) {
548
- if (rowScope === scope || rowScope === '*') return true;
549
- if (rowScope === null) {
550
- if (!project) return false;
551
- return row.project === project || row.project === '*';
552
- }
553
- return false;
286
+ // ═══════════════════════════════════════════════════════════════════
287
+ // Compatibility API same-signature wrappers for existing callers
288
+ // (extract, reflect, engine, search CLI work unchanged)
289
+ // ═══════════════════════════════════════════════════════════════════
290
+
291
+ function _parseTags(raw) {
292
+ if (Array.isArray(raw)) return raw;
293
+ if (typeof raw === 'string') {
294
+ try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; } catch { return []; }
554
295
  }
555
- if (project) return row.project === project || row.project === '*';
556
- return true;
296
+ return [];
557
297
  }
558
298
 
559
- /**
560
- * Search facts: QMD hybrid search (if available) → FTS5 → LIKE fallback.
561
- *
562
- * @param {string} query - Search keywords / natural language
563
- * @param {object} [opts]
564
- * @param {number} [opts.limit=5] - Max results
565
- * @param {string} [opts.project] - Filter by project (also always includes '*')
566
- * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
567
- * @returns {Promise<Array>|Array} Fact objects
568
- */
569
- async function searchFactsAsync(query, { limit = 5, project = null, scope = null } = {}) {
570
- // Try QMD hybrid search first
571
- let qmdClient = null;
572
- try { qmdClient = require('./qmd-client'); } catch { /* not available */ }
299
+ const CONVENTION_RELATIONS = new Set([
300
+ 'bug_lesson', 'arch_convention', 'workflow_rule', 'config_fact', 'config_change',
301
+ ]);
573
302
 
574
- if (qmdClient && qmdClient.isAvailable()) {
575
- try {
576
- const ids = await qmdClient.search(query, limit * 2); // fetch extra for project filter
577
- if (ids && ids.length > 0) {
578
- const db = getDb();
579
- const placeholders = ids.map(() => '?').join(',');
580
- let rows = db.prepare(
581
- `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
582
- FROM facts WHERE id IN (${placeholders}) AND superseded_by IS NULL
583
- AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))`
584
- ).all(...ids);
585
-
586
- // Apply project/scope filter
587
- if (project || scope) {
588
- rows = rows.filter(r => _matchesFactScope(r, project, scope));
589
- }
590
-
591
- // Preserve QMD ranking order
592
- const idOrder = new Map(ids.map((id, i) => [id, i]));
593
- rows.sort((a, b) => (idOrder.get(a.id) ?? 999) - (idOrder.get(b.id) ?? 999));
594
-
595
- if (rows.length > 0) {
596
- _trackSearch(rows.map(r => r.id));
597
- return rows.slice(0, limit);
598
- }
599
- }
600
- } catch { /* QMD failed, fall through to FTS5 */ }
303
+ function saveSession({ sessionId, project, scope = null, summary, keywords = '' }) {
304
+ if (!sessionId || !project || !summary) {
305
+ throw new Error('saveSession requires sessionId, project, summary');
601
306
  }
602
-
603
- return searchFacts(query, { limit, project, scope });
307
+ const tags = keywords ? keywords.split(',').map(k => k.trim()).filter(Boolean) : [];
308
+ return saveMemoryItem({
309
+ id: `mi_ses_${sessionId}`,
310
+ kind: 'episode',
311
+ state: 'active',
312
+ title: summary.slice(0, 80),
313
+ content: summary,
314
+ confidence: 0.7,
315
+ project: project === '*' ? '*' : String(project || 'unknown'),
316
+ scope: scope || null,
317
+ session_id: sessionId,
318
+ source_type: 'session',
319
+ source_id: sessionId,
320
+ tags,
321
+ });
604
322
  }
605
323
 
606
- /**
607
- * Search facts by keyword (FTS5 + LIKE fallback). Synchronous.
608
- *
609
- * @param {string} query - Search keywords
610
- * @param {object} [opts]
611
- * @param {number} [opts.limit=5] - Max results
612
- * @param {string} [opts.project] - Filter by project (also always includes '*')
613
- * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
614
- * @returns {Array<{ id, entity, relation, value, confidence, project, scope, tags, created_at }>}
615
- */
616
- function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
617
- if (!query || !query.trim()) return [];
618
- const db = getDb();
324
+ function saveFacts(sessionId, project, facts, { scope = null, source_type = null } = {}) {
325
+ if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0, superseded: 0, savedFacts: [] };
326
+ const normalizedProject = project === '*' ? '*' : String(project || 'unknown');
327
+ let saved = 0;
328
+ let skipped = 0;
329
+ const savedFacts = [];
619
330
 
620
- const sanitized = query.trim().split(/\s+/)
621
- .map(t => '"' + t.replace(/"/g, '') + '"').join(' ');
331
+ for (const f of facts) {
332
+ if (!f.entity || !f.relation || !f.value) { skipped++; continue; }
333
+ if (f.value.length < 20 || f.value.length > 300) { skipped++; continue; }
334
+
335
+ const kind = CONVENTION_RELATIONS.has(f.relation) ? 'convention' : 'insight';
336
+ const conf = f.confidence === 'high' ? 0.9 : f.confidence === 'medium' ? 0.7 : 0.4;
337
+ const id = `mi_f_${String(sessionId).slice(0, 8)}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
338
+ const tags = Array.isArray(f.tags) ? f.tags.slice(0, 3) : [];
622
339
 
623
- // FTS5 path
624
- try {
625
- let sql, params;
626
- if (scope && project) {
627
- sql = `
628
- SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
629
- FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
630
- WHERE facts_fts MATCH ?
631
- AND ((f.scope = ? OR f.scope = '*') OR (f.scope IS NULL AND (f.project = ? OR f.project = '*')))
632
- AND f.superseded_by IS NULL
633
- AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
634
- ORDER BY rank LIMIT ?
635
- `;
636
- params = [sanitized, scope, project, limit];
637
- } else if (scope) {
638
- sql = `
639
- SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
640
- FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
641
- WHERE facts_fts MATCH ? AND (f.scope = ? OR f.scope = '*') AND f.superseded_by IS NULL
642
- AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
643
- ORDER BY rank LIMIT ?
644
- `;
645
- params = [sanitized, scope, limit];
646
- } else if (project) {
647
- sql = `
648
- SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
649
- FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
650
- WHERE facts_fts MATCH ? AND (f.project = ? OR f.project = '*') AND f.superseded_by IS NULL
651
- AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
652
- ORDER BY rank LIMIT ?
653
- `;
654
- params = [sanitized, project, limit];
655
- } else {
656
- sql = `
657
- SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
658
- FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
659
- WHERE facts_fts MATCH ? AND f.superseded_by IS NULL
660
- AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
661
- ORDER BY rank LIMIT ?
662
- `;
663
- params = [sanitized, limit];
664
- }
665
- const ftsResults = db.prepare(sql).all(...params);
666
- if (ftsResults.length > 0) {
667
- // Supplement with fact_labels matches (concepts written by memory-extract).
668
- const ftsIds = new Set(ftsResults.map(r => r.id));
669
- const remaining = limit - ftsResults.length;
670
- if (remaining > 0) {
671
- try {
672
- const labelLike = '%' + query.trim() + '%';
673
- let labelSql = `
674
- SELECT DISTINCT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at
675
- FROM fact_labels fl JOIN facts f ON f.id = fl.fact_id
676
- WHERE fl.label LIKE ?
677
- AND f.superseded_by IS NULL
678
- AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))`;
679
- const labelParams = [labelLike];
680
- if (scope && project) {
681
- labelSql += ` AND ((f.scope = ? OR f.scope = '*') OR (f.scope IS NULL AND (f.project = ? OR f.project = '*')))`;
682
- labelParams.push(scope, project);
683
- } else if (scope) {
684
- labelSql += ` AND (f.scope = ? OR f.scope = '*')`;
685
- labelParams.push(scope);
686
- } else if (project) {
687
- labelSql += ` AND (f.project = ? OR f.project = '*')`;
688
- labelParams.push(project);
689
- }
690
- labelSql += ` LIMIT ?`;
691
- labelParams.push(remaining + ftsResults.length);
692
- const labelRows = db.prepare(labelSql).all(...labelParams);
693
- for (const row of labelRows) {
694
- if (!ftsIds.has(row.id) && ftsResults.length < limit) {
695
- ftsIds.add(row.id);
696
- ftsResults.push(row);
697
- }
698
- }
699
- } catch { /* fact_labels table may not exist yet */ }
700
- }
701
- _trackSearch(ftsResults.map(r => r.id));
702
- return ftsResults;
703
- }
704
- } catch { /* FTS error, fall through */ }
705
-
706
- // LIKE fallback (also check fact_labels)
707
- const like = '%' + query.trim() + '%';
708
- const likeSql = scope && project
709
- ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
710
- FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
711
- AND ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))
712
- AND superseded_by IS NULL
713
- AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
714
- ORDER BY created_at DESC LIMIT ?`
715
- : scope
716
- ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
717
- FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
718
- AND (scope = ? OR scope = '*') AND superseded_by IS NULL
719
- AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
720
- ORDER BY created_at DESC LIMIT ?`
721
- : project
722
- ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
723
- FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
724
- AND (project = ? OR project = '*') AND superseded_by IS NULL
725
- AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
726
- ORDER BY created_at DESC LIMIT ?`
727
- : `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
728
- FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
729
- AND superseded_by IS NULL
730
- AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
731
- ORDER BY created_at DESC LIMIT ?`;
732
- const likeResults = scope && project
733
- ? db.prepare(likeSql).all(like, like, like, scope, project, limit)
734
- : scope
735
- ? db.prepare(likeSql).all(like, like, like, scope, limit)
736
- : project
737
- ? db.prepare(likeSql).all(like, like, like, project, limit)
738
- : db.prepare(likeSql).all(like, like, like, limit);
739
- // Supplement LIKE results with fact_labels matches.
740
- if (likeResults.length < limit) {
741
340
  try {
742
- const labelLike = '%' + query.trim() + '%';
743
- const likeIds = new Set(likeResults.map(r => r.id));
744
- let labelSql2 = `
745
- SELECT DISTINCT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at
746
- FROM fact_labels fl JOIN facts f ON f.id = fl.fact_id
747
- WHERE fl.label LIKE ?
748
- AND f.superseded_by IS NULL
749
- AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))`;
750
- const labelParams2 = [labelLike];
751
- if (scope && project) {
752
- labelSql2 += ` AND ((f.scope = ? OR f.scope = '*') OR (f.scope IS NULL AND (f.project = ? OR f.project = '*')))`;
753
- labelParams2.push(scope, project);
754
- } else if (scope) {
755
- labelSql2 += ` AND (f.scope = ? OR f.scope = '*')`;
756
- labelParams2.push(scope);
757
- } else if (project) {
758
- labelSql2 += ` AND (f.project = ? OR f.project = '*')`;
759
- labelParams2.push(project);
760
- }
761
- labelSql2 += ` LIMIT ?`;
762
- labelParams2.push(limit);
763
- const labelRows = db.prepare(labelSql2).all(...labelParams2);
764
- for (const row of labelRows) {
765
- if (!likeIds.has(row.id) && likeResults.length < limit) {
766
- likeIds.add(row.id);
767
- likeResults.push(row);
768
- }
769
- }
770
- } catch { /* fact_labels table may not exist yet */ }
771
- }
772
- // Entity path expansion: supplement with entity hierarchy matches per query term.
773
- // Handles multi-word queries like "daemon dispatch" that won't match dotted entity paths
774
- // (e.g. "MetaMe.daemon.dispatch.cross-bot") via LIKE '%daemon dispatch%'.
775
- if (likeResults.length < limit) {
776
- const terms = query.trim().split(/\s+/).filter(t => t.length >= 3);
777
- if (terms.length > 0) {
778
- try {
779
- const likeIds = new Set(likeResults.map(r => r.id));
780
- const entityOrClauses = terms.map(() => `entity LIKE ?`).join(' OR ');
781
- const entityParams = terms.map(t => `%${t}%`);
782
- let entityExpSql = `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
783
- FROM facts WHERE (${entityOrClauses})
784
- AND superseded_by IS NULL
785
- AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))`;
786
- if (scope && project) {
787
- entityExpSql += ` AND ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))`;
788
- entityParams.push(scope, project);
789
- } else if (scope) {
790
- entityExpSql += ` AND (scope = ? OR scope = '*')`;
791
- entityParams.push(scope);
792
- } else if (project) {
793
- entityExpSql += ` AND (project = ? OR project = '*')`;
794
- entityParams.push(project);
795
- }
796
- entityExpSql += ` ORDER BY created_at DESC LIMIT ?`;
797
- entityParams.push(limit);
798
- const entityRows = db.prepare(entityExpSql).all(...entityParams);
799
- for (const row of entityRows) {
800
- if (!likeIds.has(row.id) && likeResults.length < limit) {
801
- likeIds.add(row.id);
802
- likeResults.push(row);
803
- }
804
- }
805
- } catch { /* entity expansion failed */ }
806
- }
341
+ saveMemoryItem({
342
+ id,
343
+ kind,
344
+ state: 'candidate',
345
+ title: `${f.entity} \u00b7 ${f.relation}`,
346
+ content: f.value.slice(0, 300),
347
+ confidence: conf,
348
+ project: normalizedProject,
349
+ scope: scope || null,
350
+ session_id: sessionId,
351
+ source_type: f.source_type || source_type || 'session',
352
+ source_id: sessionId,
353
+ tags,
354
+ });
355
+ savedFacts.push({
356
+ id, entity: f.entity, relation: f.relation, value: f.value,
357
+ project: normalizedProject, scope, tags, created_at: new Date().toISOString(),
358
+ });
359
+ saved++;
360
+ } catch { skipped++; }
807
361
  }
808
- if (likeResults.length > 0) _trackSearch(likeResults.map(r => r.id));
809
- return likeResults;
362
+ return { saved, skipped, superseded: 0, savedFacts };
810
363
  }
811
364
 
812
- /**
813
- * Search sessions by keyword (FTS5 match).
814
- *
815
- * @param {string} query - Search query (FTS5 syntax supported)
816
- * @param {object} [opts]
817
- * @param {number} [opts.limit=5] - Max results
818
- * @param {string} [opts.project] - Filter by project
819
- * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
820
- * @returns {Array<{ id, project, scope, summary, keywords, mood, created_at, rank }>}
821
- */
822
- function searchSessions(query, { limit = 5, project = null, scope = null } = {}) {
823
- if (!query || !query.trim()) return [];
824
- const db = getDb();
365
+ function saveFactLabels() {
366
+ return { saved: 0, skipped: 0 };
367
+ }
825
368
 
826
- // Sanitize: wrap each term in quotes to prevent FTS5 syntax errors
827
- const sanitized = query.trim().split(/\s+/).map(t => '"' + t.replace(/"/g, '') + '"').join(' ');
828
-
829
- let sql, params;
830
- if (scope && project) {
831
- sql = `
832
- SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
833
- FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
834
- WHERE sessions_fts MATCH ?
835
- AND ((s.scope = ? OR s.scope = '*') OR (s.scope IS NULL AND (s.project = ? OR s.project = '*')))
836
- ORDER BY rank LIMIT ?
837
- `;
838
- params = [sanitized, scope, project, limit];
839
- } else if (scope) {
840
- sql = `
841
- SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
842
- FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
843
- WHERE sessions_fts MATCH ? AND (s.scope = ? OR s.scope = '*')
844
- ORDER BY rank LIMIT ?
845
- `;
846
- params = [sanitized, scope, limit];
847
- } else if (project) {
848
- sql = `
849
- SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
850
- FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
851
- WHERE sessions_fts MATCH ? AND s.project = ?
852
- ORDER BY rank LIMIT ?
853
- `;
854
- params = [sanitized, project, limit];
855
- } else {
856
- sql = `
857
- SELECT s.id, s.project, s.scope, s.summary, s.keywords, s.mood, s.created_at, s.token_cost, rank
858
- FROM sessions_fts f JOIN sessions s ON s.rowid = f.rowid
859
- WHERE sessions_fts MATCH ?
860
- ORDER BY rank LIMIT ?
861
- `;
862
- params = [sanitized, limit];
863
- }
369
+ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
370
+ if (!query || !query.trim()) return [];
371
+ const rows = searchMemoryItems(query, {
372
+ state: 'active',
373
+ project: project || null,
374
+ scope: scope || null,
375
+ limit,
376
+ }).filter(r => r.kind === 'insight' || r.kind === 'convention');
377
+
378
+ return rows.map(r => ({
379
+ id: r.id,
380
+ entity: (r.title || '').split(' \u00b7 ')[0] || r.title || '',
381
+ relation: (r.title || '').split(' \u00b7 ')[1] || '',
382
+ value: r.content,
383
+ confidence: r.confidence >= 0.9 ? 'high' : r.confidence >= 0.6 ? 'medium' : 'low',
384
+ project: r.project,
385
+ scope: r.scope,
386
+ tags: _parseTags(r.tags),
387
+ created_at: r.created_at,
388
+ }));
389
+ }
864
390
 
865
- // Try FTS first, fall back to LIKE if FTS errors OR returns 0 (e.g. short CJK queries < 3 chars)
866
- let ftsResults = [];
867
- try { ftsResults = db.prepare(sql).all(...params); } catch { /* FTS syntax error */ }
868
- if (ftsResults.length > 0) return ftsResults;
869
-
870
- // LIKE fallback (handles short CJK terms like "飞书" that trigram can't match)
871
- const likeParam = '%' + query.trim() + '%';
872
- const likeSql = scope && project
873
- ? `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
874
- FROM sessions
875
- WHERE (summary LIKE ? OR keywords LIKE ?)
876
- AND ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))
877
- ORDER BY created_at DESC LIMIT ?`
878
- : scope
879
- ? `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
880
- FROM sessions
881
- WHERE (summary LIKE ? OR keywords LIKE ?)
882
- AND (scope = ? OR scope = '*')
883
- ORDER BY created_at DESC LIMIT ?`
884
- : project
885
- ? `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
886
- FROM sessions
887
- WHERE (summary LIKE ? OR keywords LIKE ?) AND project = ?
888
- ORDER BY created_at DESC LIMIT ?`
889
- : `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
890
- FROM sessions
891
- WHERE (summary LIKE ? OR keywords LIKE ?)
892
- ORDER BY created_at DESC LIMIT ?`;
893
- return scope && project
894
- ? db.prepare(likeSql).all(likeParam, likeParam, scope, project, limit)
895
- : scope
896
- ? db.prepare(likeSql).all(likeParam, likeParam, scope, limit)
897
- : project
898
- ? db.prepare(likeSql).all(likeParam, likeParam, project, limit)
899
- : db.prepare(likeSql).all(likeParam, likeParam, limit);
391
+ async function searchFactsAsync(query, opts) {
392
+ return searchFacts(query, opts);
900
393
  }
901
394
 
902
- /**
903
- * Get most recent sessions.
904
- *
905
- * @param {object} [opts]
906
- * @param {number} [opts.limit=3] - Max results
907
- * @param {string} [opts.project] - Filter by project
908
- * @param {string} [opts.scope] - Stable workspace scope (also includes global '*')
909
- * @returns {Array<{ id, project, scope, summary, keywords, mood, created_at }>}
910
- */
911
- function recentSessions({ limit = 3, project = null, scope = null } = {}) {
912
- const db = getDb();
913
- if (scope && project) {
914
- return db.prepare(
915
- `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
916
- FROM sessions
917
- WHERE ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))
918
- ORDER BY created_at DESC LIMIT ?`
919
- ).all(scope, project, limit);
920
- }
921
- if (scope) {
922
- return db.prepare(
923
- `SELECT id, project, scope, summary, keywords, mood, created_at, token_cost
924
- FROM sessions
925
- WHERE (scope = ? OR scope = '*')
926
- ORDER BY created_at DESC LIMIT ?`
927
- ).all(scope, limit);
928
- }
929
- if (project) {
930
- return db.prepare(
931
- 'SELECT id, project, scope, summary, keywords, mood, created_at, token_cost FROM sessions WHERE project = ? ORDER BY created_at DESC LIMIT ?'
932
- ).all(project, limit);
933
- }
934
- return db.prepare(
935
- 'SELECT id, project, scope, summary, keywords, mood, created_at, token_cost FROM sessions ORDER BY created_at DESC LIMIT ?'
936
- ).all(limit);
395
+ function searchSessions(query, { limit = 5, project = null, scope = null } = {}) {
396
+ if (!query || !query.trim()) return [];
397
+ return searchMemoryItems(query, {
398
+ kind: 'episode',
399
+ state: 'active',
400
+ project: project || null,
401
+ scope: scope || null,
402
+ limit,
403
+ }).map(r => ({
404
+ id: r.session_id || r.id,
405
+ project: r.project,
406
+ scope: r.scope,
407
+ summary: r.content,
408
+ keywords: _parseTags(r.tags).join(','),
409
+ mood: '',
410
+ created_at: r.created_at,
411
+ token_cost: 0,
412
+ }));
937
413
  }
938
414
 
939
- /**
940
- * Get a single session by ID.
941
- * @param {string} sessionId
942
- * @returns {object|null}
943
- */
944
- function getSession(sessionId) {
945
- const db = getDb();
946
- return db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) || null;
415
+ function recentSessions({ limit = 3, project = null, scope = null } = {}) {
416
+ return searchMemoryItems(null, {
417
+ kind: 'episode',
418
+ state: 'active',
419
+ project: project || null,
420
+ scope: scope || null,
421
+ limit,
422
+ }).map(r => ({
423
+ id: r.session_id || r.id,
424
+ project: r.project,
425
+ scope: r.scope,
426
+ summary: r.content,
427
+ keywords: _parseTags(r.tags).join(','),
428
+ mood: '',
429
+ created_at: r.created_at,
430
+ token_cost: 0,
431
+ }));
947
432
  }
948
433
 
949
- /**
950
- * Get total memory stats.
951
- * @returns {{ count, dbSizeKB, oldestDate, newestDate }}
952
- */
953
434
  function stats() {
954
435
  const db = getDb();
955
- const row = db.prepare('SELECT COUNT(*) as count, MIN(created_at) as oldest, MAX(created_at) as newest FROM sessions').get();
956
- const factsRow = db.prepare('SELECT COUNT(*) as count FROM facts WHERE superseded_by IS NULL').get();
436
+ const row = db.prepare(
437
+ `SELECT COUNT(*) as count, MIN(created_at) as oldest, MAX(created_at) as newest FROM memory_items WHERE state = 'active'`
438
+ ).get();
439
+ const factsRow = db.prepare(
440
+ `SELECT COUNT(*) as count FROM memory_items WHERE state = 'active' AND kind IN ('insight', 'convention')`
441
+ ).get();
957
442
  let dbSizeKB = 0;
958
443
  try { dbSizeKB = Math.round(fs.statSync(DB_PATH).size / 1024); } catch { /* */ }
959
444
  return { count: row.count, facts: factsRow.count, dbSizeKB, oldestDate: row.oldest || null, newestDate: row.newest || null };
960
445
  }
961
446
 
962
- /**
963
- * Close the database connection (for clean shutdown).
964
- */
965
- /**
966
- * Acquire a reference. Call once per logical "session" (e.g. per task run).
967
- * Ensures DB is open and increments the ref count.
968
- * Must be paired with a matching release() call.
969
- */
447
+ // ═══════════════════════════════════════════════════════════════════
448
+ // Lifecycle
449
+ // ═══════════════════════════════════════════════════════════════════
450
+
970
451
  function acquire() {
971
452
  _refCount++;
972
- getDb(); // ensure DB is initialised
453
+ getDb();
973
454
  }
974
455
 
975
- /**
976
- * Release a reference. When the last caller releases, the DB is closed.
977
- * Safe to call even if acquire() was never called (no-op when _refCount <= 0).
978
- */
979
456
  function release() {
980
457
  if (_refCount > 0) _refCount--;
981
458
  if (_refCount === 0 && _db) { _db.close(); _db = null; }
982
459
  }
983
460
 
984
- /**
985
- * Backwards-compatible alias. Equivalent to release().
986
- * External callers that previously called close() continue to work correctly.
987
- */
988
461
  function close() { release(); }
989
462
 
990
- /** Force-close regardless of ref count. Only call on process exit. */
991
463
  function forceClose() {
992
464
  _refCount = 0;
993
465
  if (_db) { _db.close(); _db = null; }
994
466
  }
995
467
 
996
468
  module.exports = {
469
+ // core
470
+ saveMemoryItem,
471
+ searchMemoryItems,
472
+ promoteItem,
473
+ archiveItem,
474
+ bumpSearchCount,
475
+ readWorkingMemory,
476
+ assembleContext,
477
+ // compatibility
997
478
  saveSession,
998
479
  saveFacts,
999
480
  saveFactLabels,
@@ -1001,8 +482,8 @@ module.exports = {
1001
482
  searchFactsAsync,
1002
483
  searchSessions,
1003
484
  recentSessions,
1004
- getSession,
1005
485
  stats,
486
+ // lifecycle
1006
487
  acquire,
1007
488
  release,
1008
489
  close,