@mnemai/memory-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/index.js +1765 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1765 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/tools/createNode.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
// ../shared-types/dist/index.js
|
|
11
|
+
var MEMORY_NODE_TYPES = [
|
|
12
|
+
"preference",
|
|
13
|
+
"architecture_decision",
|
|
14
|
+
"incident",
|
|
15
|
+
"constraint",
|
|
16
|
+
"repo_landmark",
|
|
17
|
+
"tool_outcome",
|
|
18
|
+
"general"
|
|
19
|
+
];
|
|
20
|
+
var MEMORY_SCOPES = ["session", "project", "team"];
|
|
21
|
+
var RELATION_TYPES = [
|
|
22
|
+
"supports",
|
|
23
|
+
"contradicts",
|
|
24
|
+
"depends_on",
|
|
25
|
+
"supersedes",
|
|
26
|
+
"related_to",
|
|
27
|
+
"caused_by",
|
|
28
|
+
"blocks"
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// src/trustPolicy.ts
|
|
32
|
+
var HIGH_CONFIDENCE_MIN = 0.7;
|
|
33
|
+
var MAX_CONFIDENCE_WITHOUT_EVIDENCE = 0.69;
|
|
34
|
+
|
|
35
|
+
// src/graph/node.ts
|
|
36
|
+
import { v4 as uuidv4 } from "uuid";
|
|
37
|
+
|
|
38
|
+
// src/graph/store.ts
|
|
39
|
+
import initSqlJs from "sql.js";
|
|
40
|
+
import { homedir } from "os";
|
|
41
|
+
import { join } from "path";
|
|
42
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
43
|
+
|
|
44
|
+
// src/retrieval/tokenize.ts
|
|
45
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
46
|
+
"a",
|
|
47
|
+
"an",
|
|
48
|
+
"the",
|
|
49
|
+
"and",
|
|
50
|
+
"or",
|
|
51
|
+
"but",
|
|
52
|
+
"in",
|
|
53
|
+
"on",
|
|
54
|
+
"at",
|
|
55
|
+
"to",
|
|
56
|
+
"for",
|
|
57
|
+
"of",
|
|
58
|
+
"as",
|
|
59
|
+
"by",
|
|
60
|
+
"is",
|
|
61
|
+
"it",
|
|
62
|
+
"be",
|
|
63
|
+
"we",
|
|
64
|
+
"you",
|
|
65
|
+
"they",
|
|
66
|
+
"this",
|
|
67
|
+
"that",
|
|
68
|
+
"with",
|
|
69
|
+
"from",
|
|
70
|
+
"are",
|
|
71
|
+
"was",
|
|
72
|
+
"were",
|
|
73
|
+
"has",
|
|
74
|
+
"have",
|
|
75
|
+
"had",
|
|
76
|
+
"not"
|
|
77
|
+
]);
|
|
78
|
+
var MAX_TOKENS_PER_FIELD = 256;
|
|
79
|
+
function normalizeWord(raw) {
|
|
80
|
+
const w = raw.toLowerCase().replace(/[^a-z0-9_]/g, "");
|
|
81
|
+
if (w.length < 2 || STOPWORDS.has(w)) return null;
|
|
82
|
+
return w;
|
|
83
|
+
}
|
|
84
|
+
function tokenFrequenciesFromText(text) {
|
|
85
|
+
const out = /* @__PURE__ */ new Map();
|
|
86
|
+
const words = text.toLowerCase().split(/[\s/.,;:!?()[\]{}'"`]+/g);
|
|
87
|
+
let added = 0;
|
|
88
|
+
for (const raw of words) {
|
|
89
|
+
const w = normalizeWord(raw);
|
|
90
|
+
if (!w) continue;
|
|
91
|
+
out.set(w, (out.get(w) ?? 0) + 1);
|
|
92
|
+
added++;
|
|
93
|
+
if (added >= MAX_TOKENS_PER_FIELD) break;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function mergeTokenMaps(a, b) {
|
|
98
|
+
const out = new Map(a);
|
|
99
|
+
for (const [k, v] of b) {
|
|
100
|
+
out.set(k, (out.get(k) ?? 0) + v);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function queryTermsFromString(query) {
|
|
105
|
+
const freq = tokenFrequenciesFromText(query);
|
|
106
|
+
return [...freq.keys()];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/retrieval/tokenIndex.ts
|
|
110
|
+
function replaceSearchIndexForNode(database, nodeId, content, tagsJson) {
|
|
111
|
+
deleteSearchIndexForNode(database, nodeId);
|
|
112
|
+
let tagsText = "";
|
|
113
|
+
try {
|
|
114
|
+
const tags = JSON.parse(tagsJson);
|
|
115
|
+
if (Array.isArray(tags)) {
|
|
116
|
+
tagsText = tags.filter((t) => typeof t === "string").join(" ");
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
tagsText = "";
|
|
120
|
+
}
|
|
121
|
+
const contentFreq = tokenFrequenciesFromText(content);
|
|
122
|
+
const tagFreq = tokenFrequenciesFromText(tagsText);
|
|
123
|
+
const merged = mergeTokenMaps(contentFreq, tagFreq);
|
|
124
|
+
database.run("BEGIN");
|
|
125
|
+
try {
|
|
126
|
+
for (const [token, count] of merged) {
|
|
127
|
+
const tf = 1 + Math.log(1 + count);
|
|
128
|
+
database.run("INSERT INTO node_search_tokens (token, node_id, tf) VALUES (?, ?, ?)", [token, nodeId, tf]);
|
|
129
|
+
}
|
|
130
|
+
database.run("COMMIT");
|
|
131
|
+
} catch (err) {
|
|
132
|
+
database.run("ROLLBACK");
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function deleteSearchIndexForNode(database, nodeId) {
|
|
137
|
+
database.run("DELETE FROM node_search_tokens WHERE node_id = ?", [nodeId]);
|
|
138
|
+
}
|
|
139
|
+
function backfillSearchIndex(database) {
|
|
140
|
+
const stmt = database.prepare("SELECT node_id, content, tags FROM nodes");
|
|
141
|
+
while (stmt.step()) {
|
|
142
|
+
const row = stmt.getAsObject();
|
|
143
|
+
replaceSearchIndexForNode(database, row.node_id, row.content, row.tags);
|
|
144
|
+
}
|
|
145
|
+
stmt.free();
|
|
146
|
+
}
|
|
147
|
+
function countMemoryNodes(database) {
|
|
148
|
+
const s = database.prepare("SELECT COUNT(*) as c FROM nodes");
|
|
149
|
+
s.step();
|
|
150
|
+
const row = s.getAsObject();
|
|
151
|
+
s.free();
|
|
152
|
+
return Number(row.c ?? 0);
|
|
153
|
+
}
|
|
154
|
+
function documentFrequency(database, token) {
|
|
155
|
+
const s = database.prepare(
|
|
156
|
+
"SELECT COUNT(DISTINCT node_id) as df FROM node_search_tokens WHERE token = ?"
|
|
157
|
+
);
|
|
158
|
+
s.bind([token]);
|
|
159
|
+
s.step();
|
|
160
|
+
const row = s.getAsObject();
|
|
161
|
+
s.free();
|
|
162
|
+
return Math.max(0, Number(row.df ?? 0));
|
|
163
|
+
}
|
|
164
|
+
function computeLexicalIndexRawScores(database, queryTokens) {
|
|
165
|
+
const scores = /* @__PURE__ */ new Map();
|
|
166
|
+
if (queryTokens.length === 0) return scores;
|
|
167
|
+
const N = Math.max(1, countMemoryNodes(database));
|
|
168
|
+
const idfByToken = /* @__PURE__ */ new Map();
|
|
169
|
+
for (const t of queryTokens) {
|
|
170
|
+
const df = documentFrequency(database, t);
|
|
171
|
+
const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5));
|
|
172
|
+
idfByToken.set(t, Math.max(0, idf));
|
|
173
|
+
}
|
|
174
|
+
const placeholders = queryTokens.map(() => "?").join(",");
|
|
175
|
+
const sql = `SELECT node_id, token, tf FROM node_search_tokens WHERE token IN (${placeholders})`;
|
|
176
|
+
const stmt = database.prepare(sql);
|
|
177
|
+
stmt.bind(queryTokens);
|
|
178
|
+
while (stmt.step()) {
|
|
179
|
+
const row = stmt.getAsObject();
|
|
180
|
+
const idf = idfByToken.get(row.token) ?? 0;
|
|
181
|
+
const contrib = idf * Number(row.tf ?? 0);
|
|
182
|
+
const id = row.node_id;
|
|
183
|
+
scores.set(id, (scores.get(id) ?? 0) + contrib);
|
|
184
|
+
}
|
|
185
|
+
stmt.free();
|
|
186
|
+
return scores;
|
|
187
|
+
}
|
|
188
|
+
function normalizeScores(raw) {
|
|
189
|
+
let max = 0;
|
|
190
|
+
for (const v of raw.values()) {
|
|
191
|
+
if (v > max) max = v;
|
|
192
|
+
}
|
|
193
|
+
const out = /* @__PURE__ */ new Map();
|
|
194
|
+
if (max <= 0) return out;
|
|
195
|
+
for (const [k, v] of raw) {
|
|
196
|
+
out.set(k, Math.min(1, v / max));
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
function searchIndexTableExists(database) {
|
|
201
|
+
const s = database.prepare(
|
|
202
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='node_search_tokens'"
|
|
203
|
+
);
|
|
204
|
+
const ok = s.step();
|
|
205
|
+
s.free();
|
|
206
|
+
return ok;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/graph/store.ts
|
|
210
|
+
var db = null;
|
|
211
|
+
var dbPath = null;
|
|
212
|
+
var saveTimer = null;
|
|
213
|
+
var SYNC_WRITES = process.env.TENGU_MEMORY_SYNC_WRITES === "1";
|
|
214
|
+
function getDbPath() {
|
|
215
|
+
const envPath = process.env.TENGU_MEMORY_DB;
|
|
216
|
+
if (envPath) return envPath;
|
|
217
|
+
const dir = join(homedir(), ".tengu");
|
|
218
|
+
mkdirSync(dir, { recursive: true });
|
|
219
|
+
return join(dir, "memory.db");
|
|
220
|
+
}
|
|
221
|
+
async function initDb() {
|
|
222
|
+
if (db) return db;
|
|
223
|
+
const SQL = await initSqlJs();
|
|
224
|
+
dbPath = getDbPath();
|
|
225
|
+
if (existsSync(dbPath)) {
|
|
226
|
+
const buffer = readFileSync(dbPath);
|
|
227
|
+
db = new SQL.Database(buffer);
|
|
228
|
+
} else {
|
|
229
|
+
db = new SQL.Database();
|
|
230
|
+
}
|
|
231
|
+
initSchema(db);
|
|
232
|
+
return db;
|
|
233
|
+
}
|
|
234
|
+
function getDb() {
|
|
235
|
+
if (!db) throw new Error("Database not initialized. Call initDb() first.");
|
|
236
|
+
return db;
|
|
237
|
+
}
|
|
238
|
+
function saveDb() {
|
|
239
|
+
if (!db || !dbPath) return;
|
|
240
|
+
const data = db.export();
|
|
241
|
+
const buffer = Buffer.from(data);
|
|
242
|
+
writeFileSync(dbPath, buffer);
|
|
243
|
+
}
|
|
244
|
+
function scheduleSave() {
|
|
245
|
+
if (SYNC_WRITES) {
|
|
246
|
+
saveDb();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
250
|
+
saveTimer = setTimeout(() => saveDb(), 500);
|
|
251
|
+
}
|
|
252
|
+
function flushDb() {
|
|
253
|
+
if (saveTimer) {
|
|
254
|
+
clearTimeout(saveTimer);
|
|
255
|
+
saveTimer = null;
|
|
256
|
+
}
|
|
257
|
+
saveDb();
|
|
258
|
+
}
|
|
259
|
+
function initSchema(database) {
|
|
260
|
+
database.run(`
|
|
261
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
262
|
+
node_id TEXT PRIMARY KEY,
|
|
263
|
+
node_type TEXT NOT NULL,
|
|
264
|
+
content TEXT NOT NULL,
|
|
265
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
266
|
+
freshness_score REAL NOT NULL DEFAULT 1.0,
|
|
267
|
+
created_at INTEGER NOT NULL,
|
|
268
|
+
updated_at INTEGER NOT NULL,
|
|
269
|
+
source_scope TEXT NOT NULL DEFAULT 'session',
|
|
270
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
271
|
+
metadata TEXT NOT NULL DEFAULT '{}'
|
|
272
|
+
)
|
|
273
|
+
`);
|
|
274
|
+
database.run(`
|
|
275
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
276
|
+
edge_id TEXT PRIMARY KEY,
|
|
277
|
+
from_node_id TEXT NOT NULL,
|
|
278
|
+
to_node_id TEXT NOT NULL,
|
|
279
|
+
relation_type TEXT NOT NULL,
|
|
280
|
+
weight REAL NOT NULL DEFAULT 1.0,
|
|
281
|
+
created_at INTEGER NOT NULL,
|
|
282
|
+
FOREIGN KEY (from_node_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
|
|
283
|
+
FOREIGN KEY (to_node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
284
|
+
)
|
|
285
|
+
`);
|
|
286
|
+
database.run(`
|
|
287
|
+
CREATE TABLE IF NOT EXISTS evidence_refs (
|
|
288
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
289
|
+
node_id TEXT NOT NULL,
|
|
290
|
+
type TEXT NOT NULL,
|
|
291
|
+
uri TEXT NOT NULL,
|
|
292
|
+
label TEXT,
|
|
293
|
+
timestamp INTEGER,
|
|
294
|
+
FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
295
|
+
)
|
|
296
|
+
`);
|
|
297
|
+
database.run("CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(node_type)");
|
|
298
|
+
database.run("CREATE INDEX IF NOT EXISTS idx_nodes_scope ON nodes(source_scope)");
|
|
299
|
+
database.run("CREATE INDEX IF NOT EXISTS idx_nodes_freshness ON nodes(freshness_score)");
|
|
300
|
+
database.run("CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id)");
|
|
301
|
+
database.run("CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id)");
|
|
302
|
+
database.run("CREATE INDEX IF NOT EXISTS idx_evidence_node ON evidence_refs(node_id)");
|
|
303
|
+
database.run("PRAGMA foreign_keys = ON");
|
|
304
|
+
runMigrations(database);
|
|
305
|
+
saveDb();
|
|
306
|
+
}
|
|
307
|
+
function readUserVersion(database) {
|
|
308
|
+
const stmt = database.prepare("PRAGMA user_version");
|
|
309
|
+
stmt.step();
|
|
310
|
+
const row = stmt.getAsObject();
|
|
311
|
+
stmt.free();
|
|
312
|
+
return Number(row.user_version ?? 0);
|
|
313
|
+
}
|
|
314
|
+
function setUserVersion(database, v) {
|
|
315
|
+
database.run(`PRAGMA user_version = ${v}`);
|
|
316
|
+
}
|
|
317
|
+
function tableColumns(database, table) {
|
|
318
|
+
const cols = /* @__PURE__ */ new Set();
|
|
319
|
+
const stmt = database.prepare(`PRAGMA table_info(${table})`);
|
|
320
|
+
while (stmt.step()) {
|
|
321
|
+
const row = stmt.getAsObject();
|
|
322
|
+
if (row.name) cols.add(row.name);
|
|
323
|
+
}
|
|
324
|
+
stmt.free();
|
|
325
|
+
return cols;
|
|
326
|
+
}
|
|
327
|
+
function runMigrations(database) {
|
|
328
|
+
let v = readUserVersion(database);
|
|
329
|
+
if (v < 1) {
|
|
330
|
+
const cols = tableColumns(database, "nodes");
|
|
331
|
+
if (!cols.has("review_interval_ms")) {
|
|
332
|
+
database.run(
|
|
333
|
+
"ALTER TABLE nodes ADD COLUMN review_interval_ms INTEGER NOT NULL DEFAULT 604800000"
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (!cols.has("next_review_at")) {
|
|
337
|
+
database.run("ALTER TABLE nodes ADD COLUMN next_review_at INTEGER");
|
|
338
|
+
}
|
|
339
|
+
if (!cols.has("last_verified_at")) {
|
|
340
|
+
database.run("ALTER TABLE nodes ADD COLUMN last_verified_at INTEGER");
|
|
341
|
+
}
|
|
342
|
+
database.run(
|
|
343
|
+
`UPDATE nodes SET next_review_at = updated_at + COALESCE(review_interval_ms, 604800000)
|
|
344
|
+
WHERE next_review_at IS NULL`
|
|
345
|
+
);
|
|
346
|
+
v = 1;
|
|
347
|
+
setUserVersion(database, v);
|
|
348
|
+
}
|
|
349
|
+
if (v < 2) {
|
|
350
|
+
database.run(`
|
|
351
|
+
CREATE TABLE IF NOT EXISTS node_search_tokens (
|
|
352
|
+
token TEXT NOT NULL,
|
|
353
|
+
node_id TEXT NOT NULL,
|
|
354
|
+
tf REAL NOT NULL,
|
|
355
|
+
PRIMARY KEY (token, node_id),
|
|
356
|
+
FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
357
|
+
)
|
|
358
|
+
`);
|
|
359
|
+
database.run(
|
|
360
|
+
"CREATE INDEX IF NOT EXISTS idx_node_search_tokens_token ON node_search_tokens(token)"
|
|
361
|
+
);
|
|
362
|
+
database.run(`
|
|
363
|
+
CREATE TABLE IF NOT EXISTS node_embeddings (
|
|
364
|
+
node_id TEXT PRIMARY KEY,
|
|
365
|
+
model TEXT NOT NULL,
|
|
366
|
+
dims INTEGER NOT NULL,
|
|
367
|
+
updated_at INTEGER NOT NULL,
|
|
368
|
+
vector BLOB NOT NULL,
|
|
369
|
+
FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
370
|
+
)
|
|
371
|
+
`);
|
|
372
|
+
backfillSearchIndex(database);
|
|
373
|
+
v = 2;
|
|
374
|
+
setUserVersion(database, v);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function closeDb() {
|
|
378
|
+
flushDb();
|
|
379
|
+
if (db) {
|
|
380
|
+
db.close();
|
|
381
|
+
db = null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/freshness/scorer.ts
|
|
386
|
+
var DEFAULT_CONFIG = {
|
|
387
|
+
halfLifeMs: 7 * 24 * 60 * 60 * 1e3,
|
|
388
|
+
minScore: 0.05,
|
|
389
|
+
decayFunction: "exponential",
|
|
390
|
+
refreshBoost: 0.3
|
|
391
|
+
};
|
|
392
|
+
var config = { ...DEFAULT_CONFIG };
|
|
393
|
+
function computeFreshness(updatedAt, now = Date.now()) {
|
|
394
|
+
const ageMs = now - updatedAt;
|
|
395
|
+
if (ageMs <= 0) return 1;
|
|
396
|
+
let score;
|
|
397
|
+
switch (config.decayFunction) {
|
|
398
|
+
case "exponential":
|
|
399
|
+
score = Math.pow(0.5, ageMs / config.halfLifeMs);
|
|
400
|
+
break;
|
|
401
|
+
case "linear":
|
|
402
|
+
score = Math.max(0, 1 - ageMs / (config.halfLifeMs * 2));
|
|
403
|
+
break;
|
|
404
|
+
case "step": {
|
|
405
|
+
const steps = Math.floor(ageMs / config.halfLifeMs);
|
|
406
|
+
score = Math.pow(0.5, steps);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return Math.max(config.minScore, score);
|
|
411
|
+
}
|
|
412
|
+
function computeDecayedFreshness(baseFreshness, updatedAt, now = Date.now()) {
|
|
413
|
+
const boundedBase = Math.min(1, Math.max(config.minScore, baseFreshness));
|
|
414
|
+
const decay = computeFreshness(updatedAt, now);
|
|
415
|
+
return Math.max(config.minScore, Math.min(1, boundedBase * decay));
|
|
416
|
+
}
|
|
417
|
+
function refreshNode(nodeId) {
|
|
418
|
+
const db2 = getDb();
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
const stmt = db2.prepare("SELECT freshness_score FROM nodes WHERE node_id = ?");
|
|
421
|
+
stmt.bind([nodeId]);
|
|
422
|
+
if (!stmt.step()) {
|
|
423
|
+
stmt.free();
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
const row = stmt.getAsObject();
|
|
427
|
+
stmt.free();
|
|
428
|
+
const currentScore = row.freshness_score;
|
|
429
|
+
const newScore = Math.min(1, currentScore + config.refreshBoost);
|
|
430
|
+
db2.run("UPDATE nodes SET freshness_score = ?, updated_at = ? WHERE node_id = ?", [newScore, now, nodeId]);
|
|
431
|
+
scheduleSave();
|
|
432
|
+
return newScore;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/graph/node.ts
|
|
436
|
+
var DEFAULT_REVIEW_INTERVAL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
437
|
+
function createNode(params) {
|
|
438
|
+
const db2 = getDb();
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
const nodeId = uuidv4();
|
|
441
|
+
let confidence = params.confidence ?? 0.5;
|
|
442
|
+
const hasEvidence = Boolean(params.evidenceRefs?.length);
|
|
443
|
+
if (!hasEvidence && confidence >= HIGH_CONFIDENCE_MIN) {
|
|
444
|
+
confidence = MAX_CONFIDENCE_WITHOUT_EVIDENCE;
|
|
445
|
+
}
|
|
446
|
+
const reviewIntervalMs = params.reviewIntervalMs ?? DEFAULT_REVIEW_INTERVAL_MS;
|
|
447
|
+
const nextReviewAt = now + reviewIntervalMs;
|
|
448
|
+
const tagsJson = JSON.stringify(params.tags ?? []);
|
|
449
|
+
db2.run(
|
|
450
|
+
`INSERT INTO nodes (node_id, node_type, content, confidence, freshness_score, created_at, updated_at, source_scope, tags, metadata,
|
|
451
|
+
review_interval_ms, next_review_at, last_verified_at)
|
|
452
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
|
|
453
|
+
[
|
|
454
|
+
nodeId,
|
|
455
|
+
params.nodeType,
|
|
456
|
+
params.content,
|
|
457
|
+
confidence,
|
|
458
|
+
1,
|
|
459
|
+
now,
|
|
460
|
+
now,
|
|
461
|
+
params.sourceScope ?? "session",
|
|
462
|
+
tagsJson,
|
|
463
|
+
JSON.stringify(params.metadata ?? {}),
|
|
464
|
+
reviewIntervalMs,
|
|
465
|
+
nextReviewAt
|
|
466
|
+
]
|
|
467
|
+
);
|
|
468
|
+
if (searchIndexTableExists(db2)) {
|
|
469
|
+
replaceSearchIndexForNode(db2, nodeId, params.content, tagsJson);
|
|
470
|
+
}
|
|
471
|
+
if (params.evidenceRefs?.length) {
|
|
472
|
+
for (const ref of params.evidenceRefs) {
|
|
473
|
+
db2.run(
|
|
474
|
+
"INSERT INTO evidence_refs (node_id, type, uri, label, timestamp) VALUES (?, ?, ?, ?, ?)",
|
|
475
|
+
[nodeId, ref.type, ref.uri, ref.label ?? null, ref.timestamp ?? null]
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
scheduleSave();
|
|
480
|
+
return getNode(nodeId);
|
|
481
|
+
}
|
|
482
|
+
function getNode(nodeId) {
|
|
483
|
+
const db2 = getDb();
|
|
484
|
+
const stmt = db2.prepare("SELECT * FROM nodes WHERE node_id = ?");
|
|
485
|
+
stmt.bind([nodeId]);
|
|
486
|
+
if (!stmt.step()) {
|
|
487
|
+
stmt.free();
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
const row = stmt.getAsObject();
|
|
491
|
+
stmt.free();
|
|
492
|
+
const evidenceRows = queryAll(db2, "SELECT * FROM evidence_refs WHERE node_id = ?", [nodeId]);
|
|
493
|
+
return rowToNode(row, evidenceRows);
|
|
494
|
+
}
|
|
495
|
+
function patchNodeMetadata(nodeId, patch) {
|
|
496
|
+
const node = getNode(nodeId);
|
|
497
|
+
if (!node) return null;
|
|
498
|
+
const db2 = getDb();
|
|
499
|
+
const metadata = { ...node.metadata, ...patch };
|
|
500
|
+
db2.run("UPDATE nodes SET metadata = ?, updated_at = ? WHERE node_id = ?", [
|
|
501
|
+
JSON.stringify(metadata),
|
|
502
|
+
Date.now(),
|
|
503
|
+
nodeId
|
|
504
|
+
]);
|
|
505
|
+
scheduleSave();
|
|
506
|
+
return getNode(nodeId);
|
|
507
|
+
}
|
|
508
|
+
function listNodes(params) {
|
|
509
|
+
const db2 = getDb();
|
|
510
|
+
const conditions = [];
|
|
511
|
+
const values = [];
|
|
512
|
+
if (params?.nodeType) {
|
|
513
|
+
conditions.push("node_type = ?");
|
|
514
|
+
values.push(params.nodeType);
|
|
515
|
+
}
|
|
516
|
+
if (params?.sourceScope) {
|
|
517
|
+
conditions.push("source_scope = ?");
|
|
518
|
+
values.push(params.sourceScope);
|
|
519
|
+
}
|
|
520
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
521
|
+
const limit = params?.limit ?? 100;
|
|
522
|
+
const offset = params?.offset ?? 0;
|
|
523
|
+
const rows = queryAll(
|
|
524
|
+
db2,
|
|
525
|
+
`SELECT * FROM nodes ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
|
|
526
|
+
[...values, limit, offset]
|
|
527
|
+
);
|
|
528
|
+
return rows.map((row) => {
|
|
529
|
+
const evidenceRows = queryAll(db2, "SELECT * FROM evidence_refs WHERE node_id = ?", [row.node_id]);
|
|
530
|
+
return rowToNode(row, evidenceRows);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
function listRecentNodeIds(params) {
|
|
534
|
+
const db2 = getDb();
|
|
535
|
+
const conditions = [];
|
|
536
|
+
const values = [];
|
|
537
|
+
if (params.nodeType) {
|
|
538
|
+
conditions.push("node_type = ?");
|
|
539
|
+
values.push(params.nodeType);
|
|
540
|
+
}
|
|
541
|
+
if (params.sourceScope) {
|
|
542
|
+
conditions.push("source_scope = ?");
|
|
543
|
+
values.push(params.sourceScope);
|
|
544
|
+
}
|
|
545
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
546
|
+
const rows = queryAll(
|
|
547
|
+
db2,
|
|
548
|
+
`SELECT node_id FROM nodes ${where} ORDER BY updated_at DESC LIMIT ?`,
|
|
549
|
+
[...values, params.limit]
|
|
550
|
+
);
|
|
551
|
+
return rows.map((r) => r.node_id);
|
|
552
|
+
}
|
|
553
|
+
function getNodesByIds(nodeIds) {
|
|
554
|
+
if (nodeIds.length === 0) return [];
|
|
555
|
+
const db2 = getDb();
|
|
556
|
+
const out = [];
|
|
557
|
+
const chunkSize = 80;
|
|
558
|
+
for (let i = 0; i < nodeIds.length; i += chunkSize) {
|
|
559
|
+
const chunk = nodeIds.slice(i, i + chunkSize);
|
|
560
|
+
const ph = chunk.map(() => "?").join(",");
|
|
561
|
+
const rows = queryAll(db2, `SELECT * FROM nodes WHERE node_id IN (${ph})`, chunk);
|
|
562
|
+
for (const row of rows) {
|
|
563
|
+
const evidenceRows = queryAll(db2, "SELECT * FROM evidence_refs WHERE node_id = ?", [row.node_id]);
|
|
564
|
+
out.push(rowToNode(row, evidenceRows));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
function queryAll(db2, sql, params = []) {
|
|
570
|
+
const results = [];
|
|
571
|
+
const stmt = db2.prepare(sql);
|
|
572
|
+
stmt.bind(params);
|
|
573
|
+
while (stmt.step()) {
|
|
574
|
+
results.push(stmt.getAsObject());
|
|
575
|
+
}
|
|
576
|
+
stmt.free();
|
|
577
|
+
return results;
|
|
578
|
+
}
|
|
579
|
+
function rowToNode(row, evidenceRows) {
|
|
580
|
+
const reviewIntervalRaw = row.review_interval_ms;
|
|
581
|
+
const nextReviewRaw = row.next_review_at;
|
|
582
|
+
const lastVerifiedRaw = row.last_verified_at;
|
|
583
|
+
return {
|
|
584
|
+
nodeId: row.node_id,
|
|
585
|
+
nodeType: row.node_type,
|
|
586
|
+
content: row.content,
|
|
587
|
+
confidence: row.confidence,
|
|
588
|
+
freshnessScore: computeDecayedFreshness(row.freshness_score, row.updated_at),
|
|
589
|
+
createdAt: row.created_at,
|
|
590
|
+
updatedAt: row.updated_at,
|
|
591
|
+
sourceScope: row.source_scope,
|
|
592
|
+
tags: JSON.parse(row.tags),
|
|
593
|
+
metadata: JSON.parse(row.metadata),
|
|
594
|
+
evidenceRefs: evidenceRows.map((e) => ({
|
|
595
|
+
type: e.type,
|
|
596
|
+
uri: e.uri,
|
|
597
|
+
label: e.label ?? void 0,
|
|
598
|
+
timestamp: e.timestamp ?? void 0
|
|
599
|
+
})),
|
|
600
|
+
reviewIntervalMs: reviewIntervalRaw != null ? Number(reviewIntervalRaw) : void 0,
|
|
601
|
+
nextReviewAt: nextReviewRaw != null ? Number(nextReviewRaw) : null,
|
|
602
|
+
lastVerifiedAt: lastVerifiedRaw != null ? Number(lastVerifiedRaw) : null
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/tools/createNode.ts
|
|
607
|
+
var createNodeSchema = z.object({
|
|
608
|
+
nodeType: z.enum(MEMORY_NODE_TYPES),
|
|
609
|
+
content: z.string().min(1).describe("The memory content to store"),
|
|
610
|
+
confidence: z.number().min(0).max(1).optional().describe(
|
|
611
|
+
`Initial confidence (0\u20131), default 0.5. Without evidenceRefs, values \u2265${HIGH_CONFIDENCE_MIN} are capped (RFC \xA76.3).`
|
|
612
|
+
),
|
|
613
|
+
sourceScope: z.enum(MEMORY_SCOPES).optional().describe("Scope: session, project, or team"),
|
|
614
|
+
tags: z.array(z.string()).optional().describe("Tags for categorization"),
|
|
615
|
+
metadata: z.record(z.unknown()).optional().describe("Arbitrary metadata"),
|
|
616
|
+
evidenceRefs: z.array(z.object({
|
|
617
|
+
type: z.enum(["transcript", "tool_result", "verification", "code_anchor", "decision_record", "external"]),
|
|
618
|
+
uri: z.string(),
|
|
619
|
+
label: z.string().optional(),
|
|
620
|
+
timestamp: z.number().int().optional()
|
|
621
|
+
})).optional().describe("Evidence references backing this memory"),
|
|
622
|
+
reviewIntervalDays: z.number().min(1).max(365).optional().describe(
|
|
623
|
+
"Days until first scheduled verification (spaced review); default 7."
|
|
624
|
+
)
|
|
625
|
+
});
|
|
626
|
+
function handleCreateNode(args) {
|
|
627
|
+
const requestedConfidence = args.confidence ?? 0.5;
|
|
628
|
+
const hadEvidence = Boolean(args.evidenceRefs?.length);
|
|
629
|
+
const reviewIntervalMs = args.reviewIntervalDays != null ? args.reviewIntervalDays * 24 * 60 * 60 * 1e3 : void 0;
|
|
630
|
+
const node = createNode({
|
|
631
|
+
nodeType: args.nodeType,
|
|
632
|
+
content: args.content,
|
|
633
|
+
confidence: args.confidence,
|
|
634
|
+
sourceScope: args.sourceScope,
|
|
635
|
+
tags: args.tags,
|
|
636
|
+
metadata: args.metadata,
|
|
637
|
+
evidenceRefs: args.evidenceRefs,
|
|
638
|
+
reviewIntervalMs
|
|
639
|
+
});
|
|
640
|
+
const confidenceCappedForMissingEvidence = !hadEvidence && requestedConfidence >= HIGH_CONFIDENCE_MIN && node.confidence < requestedConfidence;
|
|
641
|
+
return {
|
|
642
|
+
content: [
|
|
643
|
+
{
|
|
644
|
+
type: "text",
|
|
645
|
+
text: JSON.stringify(
|
|
646
|
+
{
|
|
647
|
+
node,
|
|
648
|
+
trustPolicy: { confidenceCappedForMissingEvidence }
|
|
649
|
+
},
|
|
650
|
+
null,
|
|
651
|
+
2
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
]
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// src/tools/queryMemory.ts
|
|
659
|
+
import { z as z2 } from "zod";
|
|
660
|
+
|
|
661
|
+
// src/graph/edge.ts
|
|
662
|
+
import { v4 as uuidv42 } from "uuid";
|
|
663
|
+
function createEdge(params) {
|
|
664
|
+
const db2 = getDb();
|
|
665
|
+
const edgeId = uuidv42();
|
|
666
|
+
const now = Date.now();
|
|
667
|
+
db2.run(
|
|
668
|
+
`INSERT INTO edges (edge_id, from_node_id, to_node_id, relation_type, weight, created_at)
|
|
669
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
670
|
+
[
|
|
671
|
+
edgeId,
|
|
672
|
+
params.fromNodeId,
|
|
673
|
+
params.toNodeId,
|
|
674
|
+
params.relationType,
|
|
675
|
+
params.weight ?? 1,
|
|
676
|
+
now
|
|
677
|
+
]
|
|
678
|
+
);
|
|
679
|
+
scheduleSave();
|
|
680
|
+
return {
|
|
681
|
+
edgeId,
|
|
682
|
+
fromNodeId: params.fromNodeId,
|
|
683
|
+
toNodeId: params.toNodeId,
|
|
684
|
+
relationType: params.relationType,
|
|
685
|
+
weight: params.weight ?? 1,
|
|
686
|
+
createdAt: now
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function getEdgesForNode(nodeId) {
|
|
690
|
+
const db2 = getDb();
|
|
691
|
+
return queryEdges(db2, "SELECT * FROM edges WHERE from_node_id = ? OR to_node_id = ?", [nodeId, nodeId]);
|
|
692
|
+
}
|
|
693
|
+
function getContradictions(nodeId) {
|
|
694
|
+
const db2 = getDb();
|
|
695
|
+
return queryEdges(
|
|
696
|
+
db2,
|
|
697
|
+
`SELECT * FROM edges WHERE (from_node_id = ? OR to_node_id = ?) AND relation_type = 'contradicts'`,
|
|
698
|
+
[nodeId, nodeId]
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
function queryEdges(db2, sql, params) {
|
|
702
|
+
const results = [];
|
|
703
|
+
const stmt = db2.prepare(sql);
|
|
704
|
+
stmt.bind(params);
|
|
705
|
+
while (stmt.step()) {
|
|
706
|
+
const row = stmt.getAsObject();
|
|
707
|
+
results.push({
|
|
708
|
+
edgeId: row.edge_id,
|
|
709
|
+
fromNodeId: row.from_node_id,
|
|
710
|
+
toNodeId: row.to_node_id,
|
|
711
|
+
relationType: row.relation_type,
|
|
712
|
+
weight: row.weight,
|
|
713
|
+
createdAt: row.created_at
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
stmt.free();
|
|
717
|
+
return results;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/freshness/policy.ts
|
|
721
|
+
var STALE_THRESHOLD = 0.2;
|
|
722
|
+
function isStale(freshnessScore) {
|
|
723
|
+
return freshnessScore < STALE_THRESHOLD;
|
|
724
|
+
}
|
|
725
|
+
function hasContradictions(nodeId) {
|
|
726
|
+
return getContradictions(nodeId).length > 0;
|
|
727
|
+
}
|
|
728
|
+
function getStaleNodeIds() {
|
|
729
|
+
const db2 = getDb();
|
|
730
|
+
const results = [];
|
|
731
|
+
const stmt = db2.prepare("SELECT node_id, freshness_score, updated_at FROM nodes");
|
|
732
|
+
while (stmt.step()) {
|
|
733
|
+
const row = stmt.getAsObject();
|
|
734
|
+
const freshness = computeDecayedFreshness(row.freshness_score, row.updated_at);
|
|
735
|
+
if (freshness < STALE_THRESHOLD) {
|
|
736
|
+
results.push(row.node_id);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
stmt.free();
|
|
740
|
+
return results;
|
|
741
|
+
}
|
|
742
|
+
function getContradictionPairs() {
|
|
743
|
+
const db2 = getDb();
|
|
744
|
+
const results = [];
|
|
745
|
+
const stmt = db2.prepare(`SELECT from_node_id, to_node_id FROM edges WHERE relation_type = 'contradicts'`);
|
|
746
|
+
while (stmt.step()) {
|
|
747
|
+
const row = stmt.getAsObject();
|
|
748
|
+
results.push({
|
|
749
|
+
nodeA: row.from_node_id,
|
|
750
|
+
nodeB: row.to_node_id
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
stmt.free();
|
|
754
|
+
return results;
|
|
755
|
+
}
|
|
756
|
+
function countHighConfidenceWithoutEvidence() {
|
|
757
|
+
const db2 = getDb();
|
|
758
|
+
const stmt = db2.prepare(
|
|
759
|
+
`SELECT COUNT(*) as count FROM nodes n
|
|
760
|
+
WHERE n.confidence >= ? AND NOT EXISTS (SELECT 1 FROM evidence_refs e WHERE e.node_id = n.node_id)`
|
|
761
|
+
);
|
|
762
|
+
stmt.bind([HIGH_CONFIDENCE_MIN]);
|
|
763
|
+
stmt.step();
|
|
764
|
+
const row = stmt.getAsObject();
|
|
765
|
+
stmt.free();
|
|
766
|
+
return Number(row.count);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/retrieval/ranker.ts
|
|
770
|
+
var DEFAULT_WEIGHTS = {
|
|
771
|
+
relevance: 0.3,
|
|
772
|
+
freshness: 0.25,
|
|
773
|
+
evidence: 0.25,
|
|
774
|
+
salience: 0.2
|
|
775
|
+
};
|
|
776
|
+
var INTENT_WEIGHTS = {
|
|
777
|
+
general: DEFAULT_WEIGHTS,
|
|
778
|
+
decision_recall: { relevance: 0.25, freshness: 0.2, evidence: 0.35, salience: 0.2 },
|
|
779
|
+
incident_triage: { relevance: 0.35, freshness: 0.35, evidence: 0.2, salience: 0.1 },
|
|
780
|
+
preference_personalization: { relevance: 0.35, freshness: 0.2, evidence: 0.15, salience: 0.3 }
|
|
781
|
+
};
|
|
782
|
+
function parseEnvWeight(name, fallback) {
|
|
783
|
+
const v = process.env[name];
|
|
784
|
+
if (v == null || v === "") return fallback;
|
|
785
|
+
const n = Number(v);
|
|
786
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
787
|
+
return n;
|
|
788
|
+
}
|
|
789
|
+
function matchBlendWeights(useSemantic) {
|
|
790
|
+
let lex = parseEnvWeight("TENGU_MEMORY_MATCH_LEXICAL", 0.35);
|
|
791
|
+
let idx = parseEnvWeight("TENGU_MEMORY_MATCH_INDEX", 0.45);
|
|
792
|
+
let sem = parseEnvWeight("TENGU_MEMORY_MATCH_SEMANTIC", 0.2);
|
|
793
|
+
if (!useSemantic) {
|
|
794
|
+
const total = lex + idx;
|
|
795
|
+
if (total <= 0) return { lex: 0.55, idx: 0.45, sem: 0 };
|
|
796
|
+
return { lex: lex / total, idx: idx / total, sem: 0 };
|
|
797
|
+
}
|
|
798
|
+
const t = lex + idx + sem;
|
|
799
|
+
if (t <= 0) return { lex: 0.35, idx: 0.45, sem: 0.2 };
|
|
800
|
+
return { lex: lex / t, idx: idx / t, sem: sem / t };
|
|
801
|
+
}
|
|
802
|
+
function rankNodes(nodes, queryTerms, weights = {}, intent = "general", hybrid) {
|
|
803
|
+
const w = { ...INTENT_WEIGHTS[intent], ...weights };
|
|
804
|
+
const nodeById = new Map(nodes.map((n) => [n.nodeId, n]));
|
|
805
|
+
const useSemantic = Boolean(hybrid?.useSemanticInBlend && hybrid.semanticMatchByNodeId);
|
|
806
|
+
const blend = matchBlendWeights(useSemantic);
|
|
807
|
+
const indexActive = Boolean(hybrid?.indexMatchByNodeId != null && queryTerms.length > 0);
|
|
808
|
+
let wLex = blend.lex;
|
|
809
|
+
let wIdx = indexActive ? blend.idx : 0;
|
|
810
|
+
let wSem = useSemantic ? blend.sem : 0;
|
|
811
|
+
let sumChannels = wLex + wIdx + wSem;
|
|
812
|
+
if (sumChannels <= 0) {
|
|
813
|
+
wLex = 1;
|
|
814
|
+
wIdx = 0;
|
|
815
|
+
wSem = 0;
|
|
816
|
+
sumChannels = 1;
|
|
817
|
+
}
|
|
818
|
+
const nLex = wLex / sumChannels;
|
|
819
|
+
const nIdx = wIdx / sumChannels;
|
|
820
|
+
const nSem = wSem / sumChannels;
|
|
821
|
+
const results = nodes.map((node) => {
|
|
822
|
+
const substringScore = computeMatchScore(node, queryTerms);
|
|
823
|
+
const idxScore = hybrid?.indexMatchByNodeId?.get(node.nodeId) ?? 0;
|
|
824
|
+
const semScore = hybrid?.semanticMatchByNodeId?.get(node.nodeId) ?? 0;
|
|
825
|
+
const matchScore = nLex * substringScore + nIdx * idxScore + nSem * semScore;
|
|
826
|
+
const edges = getEdgesForNode(node.nodeId);
|
|
827
|
+
const conflict = computeConflict(node, edges, nodeById);
|
|
828
|
+
const evidenceQualityScore = computeEvidenceQualityScore(node, conflict.hasConflict);
|
|
829
|
+
const salienceScore = computeSalienceScore(node);
|
|
830
|
+
const evidenceStrength = evidenceQualityScore;
|
|
831
|
+
const compositeScore = matchScore * w.relevance + node.freshnessScore * w.freshness + evidenceStrength * w.evidence + salienceScore * w.salience;
|
|
832
|
+
const out = {
|
|
833
|
+
node,
|
|
834
|
+
matchScore,
|
|
835
|
+
compositeScore,
|
|
836
|
+
freshnessScore: node.freshnessScore,
|
|
837
|
+
evidenceStrength,
|
|
838
|
+
salienceScore,
|
|
839
|
+
evidenceQualityScore,
|
|
840
|
+
conflict,
|
|
841
|
+
edges
|
|
842
|
+
};
|
|
843
|
+
if (indexActive) out.indexMatchScore = idxScore;
|
|
844
|
+
if (useSemantic) out.semanticMatchScore = semScore;
|
|
845
|
+
return out;
|
|
846
|
+
});
|
|
847
|
+
results.sort((a, b) => b.compositeScore - a.compositeScore);
|
|
848
|
+
return results;
|
|
849
|
+
}
|
|
850
|
+
function computeMatchScore(node, queryTerms) {
|
|
851
|
+
if (queryTerms.length === 0) return 0.5;
|
|
852
|
+
const content = node.content.toLowerCase();
|
|
853
|
+
const tags = node.tags.map((t) => t.toLowerCase());
|
|
854
|
+
let matchCount = 0;
|
|
855
|
+
for (const term of queryTerms) {
|
|
856
|
+
const lower = term.toLowerCase();
|
|
857
|
+
if (content.includes(lower)) matchCount++;
|
|
858
|
+
if (tags.some((t) => t.includes(lower))) matchCount += 0.5;
|
|
859
|
+
}
|
|
860
|
+
return Math.min(1, matchCount / queryTerms.length);
|
|
861
|
+
}
|
|
862
|
+
function computeEvidenceQualityScore(node, hasConflict) {
|
|
863
|
+
if (node.evidenceRefs.length === 0) return 0;
|
|
864
|
+
const now = Date.now();
|
|
865
|
+
const weighted = node.evidenceRefs.map((ref) => {
|
|
866
|
+
const typeWeight = evidenceTypeWeight(ref.type);
|
|
867
|
+
const ts = ref.timestamp ?? now;
|
|
868
|
+
const ageDays = Math.max(0, (now - ts) / (1e3 * 60 * 60 * 24));
|
|
869
|
+
const recencyWeight = 1 / (1 + ageDays / 30);
|
|
870
|
+
return typeWeight * recencyWeight;
|
|
871
|
+
});
|
|
872
|
+
const avg = weighted.reduce((sum, v) => sum + v, 0) / weighted.length;
|
|
873
|
+
const volume = Math.min(1, node.evidenceRefs.length / 5);
|
|
874
|
+
const base = Math.min(1, avg * 0.7 + volume * 0.3);
|
|
875
|
+
return hasConflict ? base * 0.85 : base;
|
|
876
|
+
}
|
|
877
|
+
function evidenceTypeWeight(type) {
|
|
878
|
+
switch (type) {
|
|
879
|
+
case "verification":
|
|
880
|
+
return 1;
|
|
881
|
+
case "decision_record":
|
|
882
|
+
return 0.95;
|
|
883
|
+
case "tool_result":
|
|
884
|
+
return 0.85;
|
|
885
|
+
case "code_anchor":
|
|
886
|
+
return 0.8;
|
|
887
|
+
case "transcript":
|
|
888
|
+
return 0.65;
|
|
889
|
+
case "external":
|
|
890
|
+
return 0.6;
|
|
891
|
+
default:
|
|
892
|
+
return 0.5;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function computeSalienceScore(node) {
|
|
896
|
+
const metadata = node.metadata ?? {};
|
|
897
|
+
const reaffirmationCount = Number(metadata.reaffirmationCount ?? 0);
|
|
898
|
+
const outcomeImpact = Number(metadata.outcomeImpact ?? 0);
|
|
899
|
+
const userCorrected = Boolean(metadata.userCorrected);
|
|
900
|
+
let score = 0.2;
|
|
901
|
+
if (node.nodeType === "architecture_decision" || node.nodeType === "constraint" || node.nodeType === "incident") {
|
|
902
|
+
score += 0.2;
|
|
903
|
+
}
|
|
904
|
+
if (node.tags.some((tag) => ["critical", "decision", "incident", "blocker"].includes(tag.toLowerCase()))) {
|
|
905
|
+
score += 0.15;
|
|
906
|
+
}
|
|
907
|
+
if (userCorrected) {
|
|
908
|
+
score += 0.2;
|
|
909
|
+
}
|
|
910
|
+
score += Math.min(0.2, reaffirmationCount * 0.05);
|
|
911
|
+
score += Math.min(0.25, Math.max(0, outcomeImpact) * 0.25);
|
|
912
|
+
return Math.min(1, score);
|
|
913
|
+
}
|
|
914
|
+
function computeConflict(node, edges, nodeById) {
|
|
915
|
+
const contradicting = edges.filter((e) => e.relationType === "contradicts");
|
|
916
|
+
if (contradicting.length === 0) {
|
|
917
|
+
return { hasConflict: false, status: "none" };
|
|
918
|
+
}
|
|
919
|
+
let strongestOpponentScore = -1;
|
|
920
|
+
let winnerNodeId;
|
|
921
|
+
const selfScore = node.confidence * node.freshnessScore * Math.max(0.1, computeEvidenceQualityScore(node, false));
|
|
922
|
+
for (const edge of contradicting) {
|
|
923
|
+
const otherId = edge.fromNodeId === node.nodeId ? edge.toNodeId : edge.fromNodeId;
|
|
924
|
+
const other = nodeById.get(otherId);
|
|
925
|
+
if (!other) continue;
|
|
926
|
+
const otherScore = other.confidence * other.freshnessScore * Math.max(0.1, computeEvidenceQualityScore(other, false)) * edge.weight;
|
|
927
|
+
if (otherScore > strongestOpponentScore) {
|
|
928
|
+
strongestOpponentScore = otherScore;
|
|
929
|
+
winnerNodeId = otherId;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (strongestOpponentScore < 0) {
|
|
933
|
+
return { hasConflict: true, status: "contested" };
|
|
934
|
+
}
|
|
935
|
+
const delta = Math.abs(selfScore - strongestOpponentScore);
|
|
936
|
+
if (delta < 0.1) {
|
|
937
|
+
return { hasConflict: true, status: "contested", winnerNodeId, confidenceDelta: Number(delta.toFixed(3)) };
|
|
938
|
+
}
|
|
939
|
+
const selfWins = selfScore > strongestOpponentScore;
|
|
940
|
+
return {
|
|
941
|
+
hasConflict: true,
|
|
942
|
+
status: "resolved",
|
|
943
|
+
winnerNodeId: selfWins ? node.nodeId : winnerNodeId,
|
|
944
|
+
confidenceDelta: Number(delta.toFixed(3))
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// src/retrieval/embedding.ts
|
|
949
|
+
var DEFAULT_MODEL = "text-embedding-3-small";
|
|
950
|
+
function isEmbeddingsConfigured() {
|
|
951
|
+
return Boolean(process.env.TENGU_MEMORY_EMBED_URL?.trim() && process.env.TENGU_MEMORY_EMBED_KEY?.trim());
|
|
952
|
+
}
|
|
953
|
+
function getEmbeddingModel() {
|
|
954
|
+
return process.env.TENGU_MEMORY_EMBED_MODEL?.trim() || DEFAULT_MODEL;
|
|
955
|
+
}
|
|
956
|
+
async function embedText(text) {
|
|
957
|
+
if (!isEmbeddingsConfigured()) return null;
|
|
958
|
+
const url = process.env.TENGU_MEMORY_EMBED_URL.trim();
|
|
959
|
+
const key = process.env.TENGU_MEMORY_EMBED_KEY.trim();
|
|
960
|
+
const model = getEmbeddingModel();
|
|
961
|
+
const body = JSON.stringify({ model, input: text.slice(0, 8e3) });
|
|
962
|
+
const res = await fetch(url, {
|
|
963
|
+
method: "POST",
|
|
964
|
+
headers: {
|
|
965
|
+
"Content-Type": "application/json",
|
|
966
|
+
Authorization: `Bearer ${key}`
|
|
967
|
+
},
|
|
968
|
+
body
|
|
969
|
+
});
|
|
970
|
+
if (!res.ok) {
|
|
971
|
+
const err = await res.text().catch(() => res.statusText);
|
|
972
|
+
throw new Error(`embedding_http_${res.status}: ${err.slice(0, 500)}`);
|
|
973
|
+
}
|
|
974
|
+
const json = await res.json();
|
|
975
|
+
const vec = json.data?.[0]?.embedding;
|
|
976
|
+
if (!vec?.length) return null;
|
|
977
|
+
return Float32Array.from(vec);
|
|
978
|
+
}
|
|
979
|
+
function cosineSimilarity(a, b) {
|
|
980
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
981
|
+
let dot = 0;
|
|
982
|
+
let na = 0;
|
|
983
|
+
let nb = 0;
|
|
984
|
+
for (let i = 0; i < a.length; i++) {
|
|
985
|
+
dot += a[i] * b[i];
|
|
986
|
+
na += a[i] * a[i];
|
|
987
|
+
nb += b[i] * b[i];
|
|
988
|
+
}
|
|
989
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
990
|
+
if (denom <= 0) return 0;
|
|
991
|
+
return Math.max(0, Math.min(1, dot / denom));
|
|
992
|
+
}
|
|
993
|
+
function embeddingsTableExists(database) {
|
|
994
|
+
const s = database.prepare(
|
|
995
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='node_embeddings'"
|
|
996
|
+
);
|
|
997
|
+
const ok = s.step();
|
|
998
|
+
s.free();
|
|
999
|
+
return ok;
|
|
1000
|
+
}
|
|
1001
|
+
function upsertNodeEmbedding(database, nodeId, vector) {
|
|
1002
|
+
const model = getEmbeddingModel();
|
|
1003
|
+
const now = Date.now();
|
|
1004
|
+
const u8 = new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
1005
|
+
database.run(
|
|
1006
|
+
"REPLACE INTO node_embeddings (node_id, model, dims, updated_at, vector) VALUES (?, ?, ?, ?, ?)",
|
|
1007
|
+
[nodeId, model, vector.length, now, u8]
|
|
1008
|
+
);
|
|
1009
|
+
scheduleSave();
|
|
1010
|
+
}
|
|
1011
|
+
function loadEmbeddingsForNodes(nodeIds) {
|
|
1012
|
+
const out = /* @__PURE__ */ new Map();
|
|
1013
|
+
if (nodeIds.length === 0) return out;
|
|
1014
|
+
const database = getDb();
|
|
1015
|
+
if (!embeddingsTableExists(database)) return out;
|
|
1016
|
+
const chunkSize = 80;
|
|
1017
|
+
for (let i = 0; i < nodeIds.length; i += chunkSize) {
|
|
1018
|
+
const chunk = nodeIds.slice(i, i + chunkSize);
|
|
1019
|
+
const ph = chunk.map(() => "?").join(",");
|
|
1020
|
+
const stmt = database.prepare(`SELECT node_id, vector, dims FROM node_embeddings WHERE node_id IN (${ph})`);
|
|
1021
|
+
stmt.bind(chunk);
|
|
1022
|
+
while (stmt.step()) {
|
|
1023
|
+
const row = stmt.getAsObject();
|
|
1024
|
+
const raw = row.vector;
|
|
1025
|
+
if (!raw || !(raw instanceof Uint8Array)) continue;
|
|
1026
|
+
const buf = Buffer.from(raw);
|
|
1027
|
+
const dims = Number(row.dims);
|
|
1028
|
+
if (buf.length < dims * 4) continue;
|
|
1029
|
+
out.set(row.node_id, new Float32Array(buf.buffer, buf.byteOffset, dims));
|
|
1030
|
+
}
|
|
1031
|
+
stmt.free();
|
|
1032
|
+
}
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// src/retrieval/candidateSelection.ts
|
|
1037
|
+
function parseNonNegativeInt(raw, fallback) {
|
|
1038
|
+
if (raw == null || raw === "") return fallback;
|
|
1039
|
+
const n = Number.parseInt(raw, 10);
|
|
1040
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
1041
|
+
return n;
|
|
1042
|
+
}
|
|
1043
|
+
function parseQueryRetrievalBudget() {
|
|
1044
|
+
return {
|
|
1045
|
+
fullScanMaxNodes: parseNonNegativeInt(process.env.TENGU_MEMORY_QUERY_FULL_SCAN_MAX_NODES, 1600),
|
|
1046
|
+
indexCandidateCapFloor: Math.max(1, parseNonNegativeInt(process.env.TENGU_MEMORY_QUERY_INDEX_CANDIDATE_CAP, 600)),
|
|
1047
|
+
recentSeedSize: parseNonNegativeInt(process.env.TENGU_MEMORY_QUERY_RECENT_SEED, 200)
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
function effectiveIndexHitCap(limit, capFloor) {
|
|
1051
|
+
return Math.max(capFloor, limit * 25);
|
|
1052
|
+
}
|
|
1053
|
+
function selectIndexHitNodeIds(rawScores, cap) {
|
|
1054
|
+
if (rawScores.size <= cap) {
|
|
1055
|
+
return [...rawScores.keys()];
|
|
1056
|
+
}
|
|
1057
|
+
return [...rawScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, cap).map(([id]) => id);
|
|
1058
|
+
}
|
|
1059
|
+
function mergeCandidateNodeIds(indexIds, recentIds) {
|
|
1060
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1061
|
+
const out = [];
|
|
1062
|
+
for (const id of indexIds) {
|
|
1063
|
+
const k = String(id);
|
|
1064
|
+
if (seen.has(k)) continue;
|
|
1065
|
+
seen.add(k);
|
|
1066
|
+
out.push(id);
|
|
1067
|
+
}
|
|
1068
|
+
for (const id of recentIds) {
|
|
1069
|
+
const k = String(id);
|
|
1070
|
+
if (seen.has(k)) continue;
|
|
1071
|
+
seen.add(k);
|
|
1072
|
+
out.push(id);
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/retrieval/query.ts
|
|
1078
|
+
function filterNodesByParams(nodes, params) {
|
|
1079
|
+
return nodes.filter((n) => {
|
|
1080
|
+
if (params.nodeType && n.nodeType !== params.nodeType) return false;
|
|
1081
|
+
if (params.sourceScope && n.sourceScope !== params.sourceScope) return false;
|
|
1082
|
+
return true;
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
async function queryMemory(params) {
|
|
1086
|
+
const db2 = getDb();
|
|
1087
|
+
const queryTerms = queryTermsFromString(params.query);
|
|
1088
|
+
const budget = parseQueryRetrievalBudget();
|
|
1089
|
+
const totalNodes = countMemoryNodes(db2);
|
|
1090
|
+
const indexWanted = params.useLexicalIndex !== false && searchIndexTableExists(db2) && queryTerms.length > 0;
|
|
1091
|
+
const rawIndex = indexWanted ? computeLexicalIndexRawScores(db2, queryTerms) : /* @__PURE__ */ new Map();
|
|
1092
|
+
const indexNorm = indexWanted && rawIndex.size > 0 ? normalizeScores(rawIndex) : void 0;
|
|
1093
|
+
const limit = params.limit ?? 20;
|
|
1094
|
+
const useIndexedCandidates = params.forceFullScan !== true && indexWanted && rawIndex.size > 0 && (budget.fullScanMaxNodes === 0 || totalNodes > budget.fullScanMaxNodes);
|
|
1095
|
+
let nodes;
|
|
1096
|
+
if (!useIndexedCandidates) {
|
|
1097
|
+
nodes = fetchAllNodes(params);
|
|
1098
|
+
} else {
|
|
1099
|
+
const cap = effectiveIndexHitCap(limit, budget.indexCandidateCapFloor);
|
|
1100
|
+
const indexIds = selectIndexHitNodeIds(rawIndex, cap);
|
|
1101
|
+
const recentIds = listRecentNodeIds({
|
|
1102
|
+
nodeType: params.nodeType,
|
|
1103
|
+
sourceScope: params.sourceScope,
|
|
1104
|
+
limit: budget.recentSeedSize
|
|
1105
|
+
});
|
|
1106
|
+
const mergedIds = mergeCandidateNodeIds(indexIds, recentIds);
|
|
1107
|
+
nodes = filterNodesByParams(getNodesByIds(mergedIds), params);
|
|
1108
|
+
if (nodes.length === 0) {
|
|
1109
|
+
nodes = fetchAllNodes(params);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const filtered = nodes.filter((node) => {
|
|
1113
|
+
if (!params.includeStale && isStale(node.freshnessScore)) {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
if (params.minFreshness !== void 0 && node.freshnessScore < params.minFreshness) {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
if (params.minConfidence !== void 0 && node.confidence < params.minConfidence) {
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
return true;
|
|
1123
|
+
});
|
|
1124
|
+
let semanticByNode;
|
|
1125
|
+
let useSemanticInBlend = false;
|
|
1126
|
+
if (params.useSemantic !== false && isEmbeddingsConfigured()) {
|
|
1127
|
+
try {
|
|
1128
|
+
const qEmb = await embedText(params.query);
|
|
1129
|
+
if (qEmb) {
|
|
1130
|
+
semanticByNode = /* @__PURE__ */ new Map();
|
|
1131
|
+
const embMap = loadEmbeddingsForNodes(filtered.map((n) => n.nodeId));
|
|
1132
|
+
for (const n of filtered) {
|
|
1133
|
+
const v = embMap.get(n.nodeId);
|
|
1134
|
+
semanticByNode.set(n.nodeId, v ? cosineSimilarity(qEmb, v) : 0);
|
|
1135
|
+
}
|
|
1136
|
+
useSemanticInBlend = true;
|
|
1137
|
+
}
|
|
1138
|
+
} catch {
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
const ranked = rankNodes(
|
|
1142
|
+
filtered,
|
|
1143
|
+
queryTerms,
|
|
1144
|
+
{},
|
|
1145
|
+
params.intent ?? "general",
|
|
1146
|
+
{
|
|
1147
|
+
indexMatchByNodeId: indexNorm,
|
|
1148
|
+
semanticMatchByNodeId: semanticByNode,
|
|
1149
|
+
useSemanticInBlend
|
|
1150
|
+
}
|
|
1151
|
+
);
|
|
1152
|
+
return ranked.slice(0, limit);
|
|
1153
|
+
}
|
|
1154
|
+
function fetchAllNodes(params) {
|
|
1155
|
+
const pageSize = 500;
|
|
1156
|
+
const all = [];
|
|
1157
|
+
let offset = 0;
|
|
1158
|
+
while (true) {
|
|
1159
|
+
const page = listNodes({
|
|
1160
|
+
nodeType: params.nodeType,
|
|
1161
|
+
sourceScope: params.sourceScope,
|
|
1162
|
+
limit: pageSize,
|
|
1163
|
+
offset
|
|
1164
|
+
});
|
|
1165
|
+
all.push(...page);
|
|
1166
|
+
if (page.length < pageSize) break;
|
|
1167
|
+
offset += pageSize;
|
|
1168
|
+
}
|
|
1169
|
+
return all;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/tools/queryMemory.ts
|
|
1173
|
+
var queryMemorySchema = z2.object({
|
|
1174
|
+
query: z2.string().min(1).describe("Search query \u2014 keywords or natural language"),
|
|
1175
|
+
nodeType: z2.enum(MEMORY_NODE_TYPES).optional().describe("Filter by memory type"),
|
|
1176
|
+
sourceScope: z2.enum(MEMORY_SCOPES).optional().describe("Filter by scope"),
|
|
1177
|
+
limit: z2.number().min(1).max(100).optional().describe("Max results, default 20"),
|
|
1178
|
+
includeStale: z2.boolean().optional().describe("Include stale memories, default false"),
|
|
1179
|
+
minFreshness: z2.number().min(0).max(1).optional().describe("Min freshness score"),
|
|
1180
|
+
minConfidence: z2.number().min(0).max(1).optional().describe("Min confidence score"),
|
|
1181
|
+
intent: z2.enum(["general", "decision_recall", "incident_triage", "preference_personalization"]).optional().describe("Retrieval profile for weighting and ranking behavior"),
|
|
1182
|
+
useLexicalIndex: z2.boolean().optional().describe(
|
|
1183
|
+
"Include BM25-style portable token index in hybrid rank (default true when index exists)."
|
|
1184
|
+
),
|
|
1185
|
+
useSemantic: z2.boolean().optional().describe(
|
|
1186
|
+
"Allow query-time embedding call when TENGU_MEMORY_EMBED_* is configured (default true)."
|
|
1187
|
+
),
|
|
1188
|
+
fullScan: z2.boolean().optional().describe(
|
|
1189
|
+
"Load the entire filtered graph for ranking (slower, strongest recall). Default uses index-bounded candidates when the graph is larger than TENGU_MEMORY_QUERY_FULL_SCAN_MAX_NODES."
|
|
1190
|
+
)
|
|
1191
|
+
});
|
|
1192
|
+
async function handleQueryMemory(args) {
|
|
1193
|
+
const results = await queryMemory({
|
|
1194
|
+
query: args.query,
|
|
1195
|
+
nodeType: args.nodeType,
|
|
1196
|
+
sourceScope: args.sourceScope,
|
|
1197
|
+
limit: args.limit,
|
|
1198
|
+
includeStale: args.includeStale ?? false,
|
|
1199
|
+
minFreshness: args.minFreshness,
|
|
1200
|
+
minConfidence: args.minConfidence,
|
|
1201
|
+
intent: args.intent,
|
|
1202
|
+
useLexicalIndex: args.useLexicalIndex,
|
|
1203
|
+
useSemantic: args.useSemantic,
|
|
1204
|
+
forceFullScan: args.fullScan
|
|
1205
|
+
});
|
|
1206
|
+
const enriched = results.map((r) => ({
|
|
1207
|
+
...r,
|
|
1208
|
+
flags: {
|
|
1209
|
+
stale: isStale(r.node.freshnessScore),
|
|
1210
|
+
contradicted: hasContradictions(r.node.nodeId),
|
|
1211
|
+
hasEvidence: r.node.evidenceRefs.length > 0
|
|
1212
|
+
}
|
|
1213
|
+
}));
|
|
1214
|
+
return {
|
|
1215
|
+
content: [
|
|
1216
|
+
{
|
|
1217
|
+
type: "text",
|
|
1218
|
+
text: JSON.stringify({ count: enriched.length, results: enriched }, null, 2)
|
|
1219
|
+
}
|
|
1220
|
+
]
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/tools/addEdge.ts
|
|
1225
|
+
import { z as z3 } from "zod";
|
|
1226
|
+
var addEdgeSchema = z3.object({
|
|
1227
|
+
fromNodeId: z3.string().describe("Source node ID"),
|
|
1228
|
+
toNodeId: z3.string().describe("Target node ID"),
|
|
1229
|
+
relationType: z3.enum(RELATION_TYPES).describe("Relationship type between nodes"),
|
|
1230
|
+
weight: z3.number().min(0).max(1).optional().describe("Edge weight (0-1), default 1.0")
|
|
1231
|
+
});
|
|
1232
|
+
function handleAddEdge(args) {
|
|
1233
|
+
const fromNode = getNode(args.fromNodeId);
|
|
1234
|
+
if (!fromNode) {
|
|
1235
|
+
return {
|
|
1236
|
+
content: [{ type: "text", text: `Error: source node ${args.fromNodeId} not found` }],
|
|
1237
|
+
isError: true
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
const toNode = getNode(args.toNodeId);
|
|
1241
|
+
if (!toNode) {
|
|
1242
|
+
return {
|
|
1243
|
+
content: [{ type: "text", text: `Error: target node ${args.toNodeId} not found` }],
|
|
1244
|
+
isError: true
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
const edge = createEdge({
|
|
1248
|
+
fromNodeId: args.fromNodeId,
|
|
1249
|
+
toNodeId: args.toNodeId,
|
|
1250
|
+
relationType: args.relationType,
|
|
1251
|
+
weight: args.weight
|
|
1252
|
+
});
|
|
1253
|
+
return {
|
|
1254
|
+
content: [
|
|
1255
|
+
{
|
|
1256
|
+
type: "text",
|
|
1257
|
+
text: JSON.stringify(edge, null, 2)
|
|
1258
|
+
}
|
|
1259
|
+
]
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/tools/attachEvidence.ts
|
|
1264
|
+
import { z as z4 } from "zod";
|
|
1265
|
+
|
|
1266
|
+
// src/graph/evidence.ts
|
|
1267
|
+
function attachEvidence(nodeId, refs) {
|
|
1268
|
+
const db2 = getDb();
|
|
1269
|
+
const now = Date.now();
|
|
1270
|
+
for (const ref of refs) {
|
|
1271
|
+
db2.run(
|
|
1272
|
+
"INSERT INTO evidence_refs (node_id, type, uri, label, timestamp) VALUES (?, ?, ?, ?, ?)",
|
|
1273
|
+
[nodeId, ref.type, ref.uri, ref.label ?? null, ref.timestamp ?? now]
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
db2.run(
|
|
1277
|
+
"UPDATE nodes SET updated_at = ?, confidence = MIN(1.0, confidence + 0.1) WHERE node_id = ?",
|
|
1278
|
+
[now, nodeId]
|
|
1279
|
+
);
|
|
1280
|
+
scheduleSave();
|
|
1281
|
+
return getEvidenceForNode(nodeId);
|
|
1282
|
+
}
|
|
1283
|
+
function getEvidenceForNode(nodeId) {
|
|
1284
|
+
const db2 = getDb();
|
|
1285
|
+
const results = [];
|
|
1286
|
+
const stmt = db2.prepare("SELECT * FROM evidence_refs WHERE node_id = ?");
|
|
1287
|
+
stmt.bind([nodeId]);
|
|
1288
|
+
while (stmt.step()) {
|
|
1289
|
+
const row = stmt.getAsObject();
|
|
1290
|
+
results.push({
|
|
1291
|
+
type: row.type,
|
|
1292
|
+
uri: row.uri,
|
|
1293
|
+
label: row.label ?? void 0,
|
|
1294
|
+
timestamp: row.timestamp ?? void 0
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
stmt.free();
|
|
1298
|
+
return results;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/tools/attachEvidence.ts
|
|
1302
|
+
var attachEvidenceSchema = z4.object({
|
|
1303
|
+
nodeId: z4.string().describe("Node ID to attach evidence to"),
|
|
1304
|
+
evidenceRefs: z4.array(z4.object({
|
|
1305
|
+
type: z4.enum(["transcript", "tool_result", "verification", "code_anchor", "decision_record", "external"]),
|
|
1306
|
+
uri: z4.string(),
|
|
1307
|
+
label: z4.string().optional(),
|
|
1308
|
+
timestamp: z4.number().int().optional()
|
|
1309
|
+
})).min(1).describe("Evidence references to attach")
|
|
1310
|
+
});
|
|
1311
|
+
function handleAttachEvidence(args) {
|
|
1312
|
+
const node = getNode(args.nodeId);
|
|
1313
|
+
if (!node) {
|
|
1314
|
+
return {
|
|
1315
|
+
content: [{ type: "text", text: `Error: node ${args.nodeId} not found` }],
|
|
1316
|
+
isError: true
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
const allEvidence = attachEvidence(args.nodeId, args.evidenceRefs);
|
|
1320
|
+
return {
|
|
1321
|
+
content: [
|
|
1322
|
+
{
|
|
1323
|
+
type: "text",
|
|
1324
|
+
text: JSON.stringify({
|
|
1325
|
+
nodeId: args.nodeId,
|
|
1326
|
+
totalEvidenceCount: allEvidence.length,
|
|
1327
|
+
attached: args.evidenceRefs.length,
|
|
1328
|
+
evidence: allEvidence
|
|
1329
|
+
}, null, 2)
|
|
1330
|
+
}
|
|
1331
|
+
]
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// src/tools/refreshNode.ts
|
|
1336
|
+
import { z as z5 } from "zod";
|
|
1337
|
+
var refreshNodeSchema = z5.object({
|
|
1338
|
+
nodeId: z5.string().describe("Node ID to refresh/reaffirm"),
|
|
1339
|
+
reaffirmationNote: z5.string().max(2e3).optional().describe("Optional provenance text for this explicit reaffirmation (RFC \xA77.2). Stored in node metadata.")
|
|
1340
|
+
});
|
|
1341
|
+
function handleRefreshNode(args) {
|
|
1342
|
+
const node = getNode(args.nodeId);
|
|
1343
|
+
if (!node) {
|
|
1344
|
+
return {
|
|
1345
|
+
content: [{ type: "text", text: `Error: node ${args.nodeId} not found` }],
|
|
1346
|
+
isError: true
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
const previousFreshness = node.freshnessScore;
|
|
1350
|
+
const newFreshness = refreshNode(args.nodeId);
|
|
1351
|
+
const note = args.reaffirmationNote?.trim();
|
|
1352
|
+
if (note) {
|
|
1353
|
+
patchNodeMetadata(args.nodeId, {
|
|
1354
|
+
lastReaffirmationNote: note,
|
|
1355
|
+
lastReaffirmationAt: Date.now()
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
const reaffirmedNode = patchNodeMetadata(args.nodeId, {
|
|
1359
|
+
reaffirmationCount: Number(node.metadata.reaffirmationCount ?? 0) + 1,
|
|
1360
|
+
lastReaffirmationAt: Date.now()
|
|
1361
|
+
});
|
|
1362
|
+
return {
|
|
1363
|
+
content: [
|
|
1364
|
+
{
|
|
1365
|
+
type: "text",
|
|
1366
|
+
text: JSON.stringify({
|
|
1367
|
+
nodeId: args.nodeId,
|
|
1368
|
+
previousFreshness,
|
|
1369
|
+
newFreshness,
|
|
1370
|
+
boosted: newFreshness > previousFreshness,
|
|
1371
|
+
reaffirmationRecorded: Boolean(note),
|
|
1372
|
+
reaffirmationCount: Number(reaffirmedNode?.metadata.reaffirmationCount ?? 0)
|
|
1373
|
+
}, null, 2)
|
|
1374
|
+
}
|
|
1375
|
+
]
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// src/tools/getStats.ts
|
|
1380
|
+
function firstRow(db2, sql, bind = []) {
|
|
1381
|
+
const stmt = db2.prepare(sql);
|
|
1382
|
+
stmt.bind(bind);
|
|
1383
|
+
if (!stmt.step()) {
|
|
1384
|
+
stmt.free();
|
|
1385
|
+
return null;
|
|
1386
|
+
}
|
|
1387
|
+
const row = stmt.getAsObject();
|
|
1388
|
+
stmt.free();
|
|
1389
|
+
return row;
|
|
1390
|
+
}
|
|
1391
|
+
function handleGetStats() {
|
|
1392
|
+
const db2 = getDb();
|
|
1393
|
+
const totalNodes = Number(firstRow(db2, "SELECT COUNT(*) as count FROM nodes")?.count ?? 0);
|
|
1394
|
+
const totalEdges = Number(firstRow(db2, "SELECT COUNT(*) as count FROM edges")?.count ?? 0);
|
|
1395
|
+
const nodesByType = {};
|
|
1396
|
+
for (const nodeType of MEMORY_NODE_TYPES) {
|
|
1397
|
+
const row = firstRow(db2, "SELECT COUNT(*) as count FROM nodes WHERE node_type = ?", [nodeType]);
|
|
1398
|
+
nodesByType[nodeType] = Number(row?.count ?? 0);
|
|
1399
|
+
}
|
|
1400
|
+
const nodesByScope = {};
|
|
1401
|
+
for (const scope of MEMORY_SCOPES) {
|
|
1402
|
+
const row = firstRow(db2, "SELECT COUNT(*) as count FROM nodes WHERE source_scope = ?", [scope]);
|
|
1403
|
+
nodesByScope[scope] = Number(row?.count ?? 0);
|
|
1404
|
+
}
|
|
1405
|
+
const avgFreshness = totalNodes > 0 ? Number(firstRow(db2, "SELECT AVG(freshness_score) as avg FROM nodes")?.avg ?? 0) : 0;
|
|
1406
|
+
const avgConfidence = totalNodes > 0 ? Number(firstRow(db2, "SELECT AVG(confidence) as avg FROM nodes")?.avg ?? 0) : 0;
|
|
1407
|
+
const lexicalIndexRowCount = Number(
|
|
1408
|
+
firstRow(db2, "SELECT COUNT(*) as count FROM node_search_tokens")?.count ?? 0
|
|
1409
|
+
);
|
|
1410
|
+
const embeddedNodeCount = Number(
|
|
1411
|
+
firstRow(db2, "SELECT COUNT(*) as count FROM node_embeddings")?.count ?? 0
|
|
1412
|
+
);
|
|
1413
|
+
const stats = {
|
|
1414
|
+
totalNodes,
|
|
1415
|
+
totalEdges,
|
|
1416
|
+
nodesByType,
|
|
1417
|
+
nodesByScope,
|
|
1418
|
+
averageFreshness: Math.round(avgFreshness * 1e3) / 1e3,
|
|
1419
|
+
averageConfidence: Math.round(avgConfidence * 1e3) / 1e3,
|
|
1420
|
+
staleNodeCount: getStaleNodeIds().length,
|
|
1421
|
+
contradictionCount: getContradictionPairs().length,
|
|
1422
|
+
highConfidenceWithoutEvidenceCount: countHighConfidenceWithoutEvidence(),
|
|
1423
|
+
lexicalIndexRowCount,
|
|
1424
|
+
embeddedNodeCount
|
|
1425
|
+
};
|
|
1426
|
+
return {
|
|
1427
|
+
content: [
|
|
1428
|
+
{
|
|
1429
|
+
type: "text",
|
|
1430
|
+
text: JSON.stringify(stats, null, 2)
|
|
1431
|
+
}
|
|
1432
|
+
]
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/tools/listReviewQueue.ts
|
|
1437
|
+
import { z as z6 } from "zod";
|
|
1438
|
+
|
|
1439
|
+
// src/graph/review.ts
|
|
1440
|
+
var MAX_REVIEW_INTERVAL_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
1441
|
+
function queryAll2(db2, sql, params = []) {
|
|
1442
|
+
const results = [];
|
|
1443
|
+
const stmt = db2.prepare(sql);
|
|
1444
|
+
stmt.bind(params);
|
|
1445
|
+
while (stmt.step()) {
|
|
1446
|
+
results.push(stmt.getAsObject());
|
|
1447
|
+
}
|
|
1448
|
+
stmt.free();
|
|
1449
|
+
return results;
|
|
1450
|
+
}
|
|
1451
|
+
function listReviewQueue(params) {
|
|
1452
|
+
const db2 = getDb();
|
|
1453
|
+
const asOf = params.asOf ?? Date.now();
|
|
1454
|
+
const conditions = ["next_review_at IS NOT NULL"];
|
|
1455
|
+
const values = [];
|
|
1456
|
+
if (params.overdueOnly) {
|
|
1457
|
+
conditions.push("next_review_at <= ?");
|
|
1458
|
+
values.push(asOf);
|
|
1459
|
+
}
|
|
1460
|
+
if (params.sourceScope) {
|
|
1461
|
+
conditions.push("source_scope = ?");
|
|
1462
|
+
values.push(params.sourceScope);
|
|
1463
|
+
}
|
|
1464
|
+
const where = conditions.join(" AND ");
|
|
1465
|
+
const sql = `SELECT node_id FROM nodes WHERE ${where} ORDER BY next_review_at ASC LIMIT ?`;
|
|
1466
|
+
values.push(params.limit);
|
|
1467
|
+
const rows = queryAll2(db2, sql, values);
|
|
1468
|
+
const out = [];
|
|
1469
|
+
for (const r of rows) {
|
|
1470
|
+
const n = getNode(r.node_id);
|
|
1471
|
+
if (n) out.push(n);
|
|
1472
|
+
}
|
|
1473
|
+
return out;
|
|
1474
|
+
}
|
|
1475
|
+
function verifyNode(nodeId, note) {
|
|
1476
|
+
const node = getNode(nodeId);
|
|
1477
|
+
if (!node) return null;
|
|
1478
|
+
const db2 = getDb();
|
|
1479
|
+
const now = Date.now();
|
|
1480
|
+
const currentInterval = node.reviewIntervalMs ?? 7 * 24 * 60 * 60 * 1e3;
|
|
1481
|
+
const nextInterval = Math.min(MAX_REVIEW_INTERVAL_MS, currentInterval * 2);
|
|
1482
|
+
db2.run(
|
|
1483
|
+
`UPDATE nodes SET last_verified_at = ?, review_interval_ms = ?, next_review_at = ?, updated_at = ?
|
|
1484
|
+
WHERE node_id = ?`,
|
|
1485
|
+
[now, nextInterval, now + nextInterval, now, nodeId]
|
|
1486
|
+
);
|
|
1487
|
+
scheduleSave();
|
|
1488
|
+
refreshNode(nodeId);
|
|
1489
|
+
if (note?.trim()) {
|
|
1490
|
+
patchNodeMetadata(nodeId, {
|
|
1491
|
+
lastVerificationNote: note.trim(),
|
|
1492
|
+
lastVerificationAt: now
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
return getNode(nodeId);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/tools/listReviewQueue.ts
|
|
1499
|
+
var listReviewQueueSchema = z6.object({
|
|
1500
|
+
limit: z6.number().min(1).max(200).optional().describe("Max nodes to return, default 50"),
|
|
1501
|
+
overdueOnly: z6.boolean().optional().describe("Only nodes with next_review_at <= now (default false = upcoming queue sorted soonest)"),
|
|
1502
|
+
asOf: z6.number().int().optional().describe("Reference time (ms epoch); default Date.now()"),
|
|
1503
|
+
sourceScope: z6.enum(MEMORY_SCOPES).optional().describe("Filter by session / project / team")
|
|
1504
|
+
});
|
|
1505
|
+
function handleListReviewQueue(args) {
|
|
1506
|
+
const nodes = listReviewQueue({
|
|
1507
|
+
limit: args.limit ?? 50,
|
|
1508
|
+
overdueOnly: args.overdueOnly ?? false,
|
|
1509
|
+
asOf: args.asOf,
|
|
1510
|
+
sourceScope: args.sourceScope
|
|
1511
|
+
});
|
|
1512
|
+
return {
|
|
1513
|
+
content: [
|
|
1514
|
+
{
|
|
1515
|
+
type: "text",
|
|
1516
|
+
text: JSON.stringify({ count: nodes.length, nodes }, null, 2)
|
|
1517
|
+
}
|
|
1518
|
+
]
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/tools/verifyNode.ts
|
|
1523
|
+
import { z as z7 } from "zod";
|
|
1524
|
+
var verifyNodeToolSchema = z7.object({
|
|
1525
|
+
nodeId: z7.string().min(1).describe("Memory node to mark verified"),
|
|
1526
|
+
note: z7.string().max(2e3).optional().describe("Optional verification note (stored in metadata)")
|
|
1527
|
+
});
|
|
1528
|
+
function handleVerifyNode(args) {
|
|
1529
|
+
const updated = verifyNode(args.nodeId, args.note);
|
|
1530
|
+
if (!updated) {
|
|
1531
|
+
return {
|
|
1532
|
+
content: [{ type: "text", text: `Error: node ${args.nodeId} not found` }],
|
|
1533
|
+
isError: true
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
return {
|
|
1537
|
+
content: [
|
|
1538
|
+
{
|
|
1539
|
+
type: "text",
|
|
1540
|
+
text: JSON.stringify(
|
|
1541
|
+
{
|
|
1542
|
+
node: updated,
|
|
1543
|
+
nextReviewAt: updated.nextReviewAt,
|
|
1544
|
+
reviewIntervalMs: updated.reviewIntervalMs
|
|
1545
|
+
},
|
|
1546
|
+
null,
|
|
1547
|
+
2
|
|
1548
|
+
)
|
|
1549
|
+
}
|
|
1550
|
+
]
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// src/tools/embedNode.ts
|
|
1555
|
+
import { z as z8 } from "zod";
|
|
1556
|
+
var embedNodeSchema = z8.object({
|
|
1557
|
+
nodeId: z8.string().min(1).describe("Node whose content + tags will be embedded and stored")
|
|
1558
|
+
});
|
|
1559
|
+
async function handleEmbedNode(args) {
|
|
1560
|
+
if (!isEmbeddingsConfigured()) {
|
|
1561
|
+
return {
|
|
1562
|
+
content: [
|
|
1563
|
+
{
|
|
1564
|
+
type: "text",
|
|
1565
|
+
text: JSON.stringify({
|
|
1566
|
+
error: "embeddings_not_configured",
|
|
1567
|
+
hint: "Set TENGU_MEMORY_EMBED_URL (OpenAI-compatible embeddings endpoint), TENGU_MEMORY_EMBED_KEY, and optionally TENGU_MEMORY_EMBED_MODEL."
|
|
1568
|
+
}, null, 2)
|
|
1569
|
+
}
|
|
1570
|
+
],
|
|
1571
|
+
isError: true
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
const node = getNode(args.nodeId);
|
|
1575
|
+
if (!node) {
|
|
1576
|
+
return {
|
|
1577
|
+
content: [{ type: "text", text: `Error: node ${args.nodeId} not found` }],
|
|
1578
|
+
isError: true
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
const db2 = getDb();
|
|
1582
|
+
if (!embeddingsTableExists(db2)) {
|
|
1583
|
+
return {
|
|
1584
|
+
content: [
|
|
1585
|
+
{
|
|
1586
|
+
type: "text",
|
|
1587
|
+
text: JSON.stringify({ error: "embeddings_table_missing", hint: "Re-open DB to run migrations." }, null, 2)
|
|
1588
|
+
}
|
|
1589
|
+
],
|
|
1590
|
+
isError: true
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
const text = `${node.content}
|
|
1594
|
+
${node.tags.join(" ")}`.slice(0, 8e3);
|
|
1595
|
+
try {
|
|
1596
|
+
const vec = await embedText(text);
|
|
1597
|
+
if (!vec) {
|
|
1598
|
+
return {
|
|
1599
|
+
content: [{ type: "text", text: JSON.stringify({ error: "empty_embedding_response" }, null, 2) }],
|
|
1600
|
+
isError: true
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
const dims = vec.length;
|
|
1604
|
+
upsertNodeEmbedding(db2, node.nodeId, vec);
|
|
1605
|
+
return {
|
|
1606
|
+
content: [
|
|
1607
|
+
{
|
|
1608
|
+
type: "text",
|
|
1609
|
+
text: JSON.stringify({ nodeId: node.nodeId, dims, ok: true }, null, 2)
|
|
1610
|
+
}
|
|
1611
|
+
]
|
|
1612
|
+
};
|
|
1613
|
+
} catch (e) {
|
|
1614
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1615
|
+
return {
|
|
1616
|
+
content: [{ type: "text", text: JSON.stringify({ error: "embed_failed", message }, null, 2) }],
|
|
1617
|
+
isError: true
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// src/index.ts
|
|
1623
|
+
var server = new McpServer({
|
|
1624
|
+
name: "mnemai-memory",
|
|
1625
|
+
version: "0.1.0"
|
|
1626
|
+
});
|
|
1627
|
+
server.tool(
|
|
1628
|
+
"memory.create_node",
|
|
1629
|
+
"Create a typed memory node with content, scope, and optional evidence links",
|
|
1630
|
+
createNodeSchema.shape,
|
|
1631
|
+
async (args) => handleCreateNode(args)
|
|
1632
|
+
);
|
|
1633
|
+
server.tool(
|
|
1634
|
+
"memory.query",
|
|
1635
|
+
"Retrieve ranked memory nodes by hybrid lexical (substring + BM25-style token index) and optional embeddings, plus freshness and evidence strength",
|
|
1636
|
+
queryMemorySchema.shape,
|
|
1637
|
+
async (args) => handleQueryMemory(args)
|
|
1638
|
+
);
|
|
1639
|
+
server.tool(
|
|
1640
|
+
"memory.add_edge",
|
|
1641
|
+
"Create a typed relationship (supports, contradicts, depends_on, etc.) between two memory nodes",
|
|
1642
|
+
addEdgeSchema.shape,
|
|
1643
|
+
async (args) => handleAddEdge(args)
|
|
1644
|
+
);
|
|
1645
|
+
server.tool(
|
|
1646
|
+
"memory.attach_evidence",
|
|
1647
|
+
"Attach evidence references (transcript, tool result, code anchor, etc.) to an existing memory node",
|
|
1648
|
+
attachEvidenceSchema.shape,
|
|
1649
|
+
async (args) => handleAttachEvidence(args)
|
|
1650
|
+
);
|
|
1651
|
+
server.tool(
|
|
1652
|
+
"memory.refresh",
|
|
1653
|
+
"Reaffirm a memory node (explicit reaffirmation per RFC \xA77.2), boosting freshness; optional reaffirmationNote for provenance",
|
|
1654
|
+
refreshNodeSchema.shape,
|
|
1655
|
+
async (args) => handleRefreshNode(args)
|
|
1656
|
+
);
|
|
1657
|
+
server.tool(
|
|
1658
|
+
"memory.stats",
|
|
1659
|
+
"Get memory graph statistics: node counts by type/scope, freshness distribution, contradictions",
|
|
1660
|
+
{},
|
|
1661
|
+
async () => handleGetStats()
|
|
1662
|
+
);
|
|
1663
|
+
server.tool(
|
|
1664
|
+
"memory.list_review_queue",
|
|
1665
|
+
"List memory nodes scheduled for spaced verification (next_review_at), overdue or full upcoming queue",
|
|
1666
|
+
listReviewQueueSchema.shape,
|
|
1667
|
+
async (args) => handleListReviewQueue(args)
|
|
1668
|
+
);
|
|
1669
|
+
server.tool(
|
|
1670
|
+
"memory.verify_node",
|
|
1671
|
+
"Mark a memory as verified: doubles review interval (capped), schedules next_review_at, boosts freshness",
|
|
1672
|
+
verifyNodeToolSchema.shape,
|
|
1673
|
+
async (args) => handleVerifyNode(args)
|
|
1674
|
+
);
|
|
1675
|
+
server.tool(
|
|
1676
|
+
"memory.embed_node",
|
|
1677
|
+
"Store an embedding vector for a node (requires TENGU_MEMORY_EMBED_URL + TENGU_MEMORY_EMBED_KEY); enables semantic channel in memory.query",
|
|
1678
|
+
embedNodeSchema.shape,
|
|
1679
|
+
async (args) => handleEmbedNode(args)
|
|
1680
|
+
);
|
|
1681
|
+
server.registerResource(
|
|
1682
|
+
"memory_stats",
|
|
1683
|
+
"memory://stats",
|
|
1684
|
+
{
|
|
1685
|
+
description: "Read-only graph statistics snapshot",
|
|
1686
|
+
mimeType: "application/json"
|
|
1687
|
+
},
|
|
1688
|
+
async (uri) => {
|
|
1689
|
+
const stats = handleGetStats();
|
|
1690
|
+
return {
|
|
1691
|
+
contents: [{
|
|
1692
|
+
uri: uri.href,
|
|
1693
|
+
mimeType: "application/json",
|
|
1694
|
+
text: stats.content[0].text
|
|
1695
|
+
}]
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
);
|
|
1699
|
+
var memoryNodeTemplate = new ResourceTemplate("memory://node/{nodeId}", { list: void 0 });
|
|
1700
|
+
server.registerResource(
|
|
1701
|
+
"memory_node",
|
|
1702
|
+
memoryNodeTemplate,
|
|
1703
|
+
{
|
|
1704
|
+
description: "Read-only single memory node with edges (URI template memory://node/{nodeId})",
|
|
1705
|
+
mimeType: "application/json"
|
|
1706
|
+
},
|
|
1707
|
+
async (uri, variables) => {
|
|
1708
|
+
const raw = variables.nodeId;
|
|
1709
|
+
const nodeId = Array.isArray(raw) ? raw[0] : raw;
|
|
1710
|
+
if (!nodeId) {
|
|
1711
|
+
return {
|
|
1712
|
+
contents: [{
|
|
1713
|
+
uri: uri.href,
|
|
1714
|
+
mimeType: "application/json",
|
|
1715
|
+
text: JSON.stringify({ error: "Missing nodeId" })
|
|
1716
|
+
}]
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
const node = getNode(nodeId);
|
|
1720
|
+
if (!node) {
|
|
1721
|
+
return {
|
|
1722
|
+
contents: [{
|
|
1723
|
+
uri: uri.href,
|
|
1724
|
+
mimeType: "application/json",
|
|
1725
|
+
text: JSON.stringify({ error: "Node not found" })
|
|
1726
|
+
}]
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
const edges = getEdgesForNode(nodeId);
|
|
1730
|
+
return {
|
|
1731
|
+
contents: [{
|
|
1732
|
+
uri: uri.href,
|
|
1733
|
+
mimeType: "application/json",
|
|
1734
|
+
text: JSON.stringify({ node, edges }, null, 2)
|
|
1735
|
+
}]
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
);
|
|
1739
|
+
async function main() {
|
|
1740
|
+
try {
|
|
1741
|
+
await initDb();
|
|
1742
|
+
} catch (error) {
|
|
1743
|
+
console.error(
|
|
1744
|
+
JSON.stringify({
|
|
1745
|
+
error: "memory_db_unavailable",
|
|
1746
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1747
|
+
})
|
|
1748
|
+
);
|
|
1749
|
+
process.exit(1);
|
|
1750
|
+
}
|
|
1751
|
+
const transport = new StdioServerTransport();
|
|
1752
|
+
await server.connect(transport);
|
|
1753
|
+
process.on("SIGINT", () => {
|
|
1754
|
+
closeDb();
|
|
1755
|
+
process.exit(0);
|
|
1756
|
+
});
|
|
1757
|
+
process.on("SIGTERM", () => {
|
|
1758
|
+
closeDb();
|
|
1759
|
+
process.exit(0);
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
main().catch((error) => {
|
|
1763
|
+
console.error("Fatal error starting memory server:", error);
|
|
1764
|
+
process.exit(1);
|
|
1765
|
+
});
|