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.
- package/index.js +157 -80
- package/package.json +2 -2
- package/scripts/bin/bootstrap-worktree.sh +20 -0
- package/scripts/core/audit.js +190 -0
- package/scripts/core/handoff.js +780 -0
- package/scripts/core/handoff.test.js +1074 -0
- package/scripts/core/memory-model.js +183 -0
- package/scripts/core/memory-model.test.js +486 -0
- package/scripts/core/reactive-paths.js +44 -0
- package/scripts/core/reactive-paths.test.js +35 -0
- package/scripts/core/reactive-prompt.js +51 -0
- package/scripts/core/reactive-prompt.test.js +88 -0
- package/scripts/core/reactive-signal.js +40 -0
- package/scripts/core/reactive-signal.test.js +88 -0
- package/scripts/core/thread-chat-id.js +52 -0
- package/scripts/core/thread-chat-id.test.js +113 -0
- package/scripts/daemon-bridges.js +92 -38
- package/scripts/daemon-claude-engine.js +373 -444
- package/scripts/daemon-command-router.js +82 -8
- package/scripts/daemon-engine-runtime.js +7 -10
- package/scripts/daemon-reactive-lifecycle.js +100 -33
- package/scripts/daemon-session-commands.js +133 -43
- package/scripts/daemon-session-store.js +300 -82
- package/scripts/daemon-team-dispatch.js +16 -16
- package/scripts/daemon.js +21 -175
- package/scripts/deploy-manifest.js +90 -0
- package/scripts/docs/maintenance-manual.md +14 -11
- package/scripts/docs/pointer-map.md +13 -4
- package/scripts/feishu-adapter.js +31 -27
- package/scripts/hooks/intent-engine.js +6 -3
- package/scripts/hooks/intent-memory-recall.js +1 -0
- package/scripts/hooks/intent-perpetual.js +1 -1
- package/scripts/memory-extract.js +5 -97
- package/scripts/memory-gc.js +35 -90
- package/scripts/memory-migrate-v2.js +304 -0
- package/scripts/memory-nightly-reflect.js +40 -41
- package/scripts/memory.js +340 -859
- package/scripts/migrate-reactive-paths.js +122 -0
- package/scripts/signal-capture.js +4 -0
- 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
|
|
4
|
+
* memory.js — MetaMe Unified Memory Store
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
55
|
-
id
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
content=
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
87
|
-
INSERT INTO
|
|
88
|
-
VALUES ('delete', old.rowid, old.
|
|
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
|
|
91
|
-
INSERT INTO
|
|
92
|
-
VALUES ('delete', old.rowid, old.
|
|
93
|
-
INSERT INTO
|
|
94
|
-
VALUES (new.rowid, new.
|
|
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
|
|
102
|
+
for (const t of miTriggers) {
|
|
98
103
|
try { _db.exec(t); } catch { /* trigger may already exist */ }
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
try { _db.exec('
|
|
103
|
-
try { _db.exec('CREATE INDEX IF NOT EXISTS
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
function generateMemoryId() {
|
|
121
|
+
return `mi_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
193
122
|
}
|
|
194
123
|
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
163
|
+
return { ok: true, id };
|
|
238
164
|
}
|
|
239
165
|
|
|
240
|
-
|
|
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
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
? '*'
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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 {
|
|
376
|
-
}
|
|
202
|
+
} catch { /* FTS error, fall through to LIKE */ }
|
|
377
203
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
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
|
-
|
|
445
|
-
const
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
function
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
556
|
-
return true;
|
|
296
|
+
return [];
|
|
557
297
|
}
|
|
558
298
|
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
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
|
-
|
|
608
|
-
*
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
621
|
-
.
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
809
|
-
return likeResults;
|
|
362
|
+
return { saved, skipped, superseded: 0, savedFacts };
|
|
810
363
|
}
|
|
811
364
|
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
866
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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(
|
|
956
|
-
|
|
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
|
-
|
|
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();
|
|
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,
|