@poprobertdaniel/openclaw-memory 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/LICENSE +21 -0
- package/README.md +410 -0
- package/dist/chunk-CRPEAZ44.cjs +1881 -0
- package/dist/chunk-CRPEAZ44.cjs.map +1 -0
- package/dist/chunk-JNWCMHOB.js +309 -0
- package/dist/chunk-JNWCMHOB.js.map +1 -0
- package/dist/chunk-JSQBXYDM.js +1881 -0
- package/dist/chunk-JSQBXYDM.js.map +1 -0
- package/dist/chunk-NHFPLDZK.js +236 -0
- package/dist/chunk-NHFPLDZK.js.map +1 -0
- package/dist/chunk-NMUPGLJW.cjs +752 -0
- package/dist/chunk-NMUPGLJW.cjs.map +1 -0
- package/dist/chunk-RFLG2CCR.js +752 -0
- package/dist/chunk-RFLG2CCR.js.map +1 -0
- package/dist/chunk-VXULEX3A.cjs +236 -0
- package/dist/chunk-VXULEX3A.cjs.map +1 -0
- package/dist/chunk-ZY2C2CJQ.cjs +309 -0
- package/dist/chunk-ZY2C2CJQ.cjs.map +1 -0
- package/dist/cli/index.cjs +764 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +764 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +48 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +790 -0
- package/dist/index.d.ts +790 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-service-6WDMF6KX.cjs +9 -0
- package/dist/memory-service-6WDMF6KX.cjs.map +1 -0
- package/dist/memory-service-GKEG6J2D.js +9 -0
- package/dist/memory-service-GKEG6J2D.js.map +1 -0
- package/dist/server-BTbRv-yX.d.cts +1199 -0
- package/dist/server-BTbRv-yX.d.ts +1199 -0
- package/dist/server.cjs +9 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +9 -0
- package/dist/server.js.map +1 -0
- package/docker/full.yml +45 -0
- package/docker/standard.yml +26 -0
- package/package.json +109 -0
- package/skill/SKILL.md +139 -0
- package/templates/.env.example +42 -0
- package/templates/docker-compose.full.yml +45 -0
- package/templates/docker-compose.standard.yml +26 -0
- package/templates/openclaw-memory.config.ts +49 -0
|
@@ -0,0 +1,1881 @@
|
|
|
1
|
+
// src/storage/orchestrator.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/storage/sqlite.ts
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { createRequire } from "module";
|
|
8
|
+
function createDatabase(dbPath) {
|
|
9
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
10
|
+
const req = createRequire(import.meta.url);
|
|
11
|
+
const { Database } = req("bun:sqlite");
|
|
12
|
+
const db = new Database(dbPath, { create: true });
|
|
13
|
+
return {
|
|
14
|
+
exec: (sql) => db.exec(sql),
|
|
15
|
+
prepare: (sql) => {
|
|
16
|
+
const stmt = db.prepare(sql);
|
|
17
|
+
return {
|
|
18
|
+
run: (params) => stmt.run(params || {}),
|
|
19
|
+
get: (params) => stmt.get(params || {}),
|
|
20
|
+
all: (params) => stmt.all(params || {})
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
close: () => db.close()
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const req = createRequire(import.meta.url);
|
|
28
|
+
const Database = req("better-sqlite3");
|
|
29
|
+
const db = new Database(dbPath);
|
|
30
|
+
return {
|
|
31
|
+
exec: (sql) => db.exec(sql),
|
|
32
|
+
prepare: (sql) => {
|
|
33
|
+
const stmt = db.prepare(sql);
|
|
34
|
+
return {
|
|
35
|
+
run: (params) => {
|
|
36
|
+
const result = stmt.run(params || {});
|
|
37
|
+
return { changes: result.changes };
|
|
38
|
+
},
|
|
39
|
+
get: (params) => stmt.get(params || {}),
|
|
40
|
+
all: (params) => stmt.all(params || {})
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
close: () => db.close()
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"No SQLite driver available. Install better-sqlite3 for Node.js, or use Bun runtime."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
var SqliteStorage = class {
|
|
52
|
+
db;
|
|
53
|
+
constructor(dbPath) {
|
|
54
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
55
|
+
this.db = createDatabase(dbPath);
|
|
56
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
57
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
58
|
+
this.db.exec("PRAGMA synchronous = NORMAL");
|
|
59
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
60
|
+
this.initSchema();
|
|
61
|
+
}
|
|
62
|
+
// ── Schema ──────────────────────────────────────────────────────────
|
|
63
|
+
initSchema() {
|
|
64
|
+
this.db.exec(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
66
|
+
id TEXT PRIMARY KEY,
|
|
67
|
+
agent_id TEXT NOT NULL,
|
|
68
|
+
scope TEXT NOT NULL CHECK (scope IN ('user', 'agent', 'global', 'project', 'session')),
|
|
69
|
+
subject_id TEXT,
|
|
70
|
+
content TEXT NOT NULL,
|
|
71
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
72
|
+
entities TEXT NOT NULL DEFAULT '[]',
|
|
73
|
+
source TEXT NOT NULL DEFAULT 'explicit',
|
|
74
|
+
created_by TEXT,
|
|
75
|
+
created_at TEXT NOT NULL,
|
|
76
|
+
updated_at TEXT NOT NULL,
|
|
77
|
+
expires_at TEXT,
|
|
78
|
+
embedding_hash TEXT
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_mem_agent ON memories(agent_id);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_mem_scope ON memories(scope);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_mem_subject ON memories(subject_id);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_mem_agent_scope ON memories(agent_id, scope);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_mem_source ON memories(source);
|
|
87
|
+
`);
|
|
88
|
+
const ftsExists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
|
|
89
|
+
if (!ftsExists) {
|
|
90
|
+
this.db.exec(`
|
|
91
|
+
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
|
92
|
+
content,
|
|
93
|
+
tags,
|
|
94
|
+
content=memories,
|
|
95
|
+
content_rowid=rowid
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TRIGGER mem_fts_insert AFTER INSERT ON memories BEGIN
|
|
99
|
+
INSERT INTO memories_fts(rowid, content, tags)
|
|
100
|
+
VALUES (new.rowid, new.content, new.tags);
|
|
101
|
+
END;
|
|
102
|
+
|
|
103
|
+
CREATE TRIGGER mem_fts_delete AFTER DELETE ON memories BEGIN
|
|
104
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tags)
|
|
105
|
+
VALUES ('delete', old.rowid, old.content, old.tags);
|
|
106
|
+
END;
|
|
107
|
+
|
|
108
|
+
CREATE TRIGGER mem_fts_update AFTER UPDATE ON memories BEGIN
|
|
109
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tags)
|
|
110
|
+
VALUES ('delete', old.rowid, old.content, old.tags);
|
|
111
|
+
INSERT INTO memories_fts(rowid, content, tags)
|
|
112
|
+
VALUES (new.rowid, new.content, new.tags);
|
|
113
|
+
END;
|
|
114
|
+
`);
|
|
115
|
+
}
|
|
116
|
+
this.db.exec(`
|
|
117
|
+
CREATE TABLE IF NOT EXISTS conversation_log (
|
|
118
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
119
|
+
agent_id TEXT NOT NULL,
|
|
120
|
+
session_id TEXT NOT NULL,
|
|
121
|
+
user_id TEXT NOT NULL,
|
|
122
|
+
channel TEXT NOT NULL,
|
|
123
|
+
role TEXT NOT NULL,
|
|
124
|
+
content TEXT NOT NULL,
|
|
125
|
+
timestamp TEXT NOT NULL
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
CREATE INDEX IF NOT EXISTS idx_convlog_agent ON conversation_log(agent_id);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS idx_convlog_session ON conversation_log(session_id);
|
|
130
|
+
CREATE INDEX IF NOT EXISTS idx_convlog_ts ON conversation_log(timestamp);
|
|
131
|
+
`);
|
|
132
|
+
this.db.exec(`
|
|
133
|
+
CREATE TABLE IF NOT EXISTS sync_queue (
|
|
134
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
135
|
+
memory_id TEXT NOT NULL,
|
|
136
|
+
layer TEXT NOT NULL CHECK (layer IN ('qdrant', 'age')),
|
|
137
|
+
operation TEXT NOT NULL CHECK (operation IN ('upsert', 'delete')),
|
|
138
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
last_error TEXT,
|
|
140
|
+
created_at TEXT NOT NULL,
|
|
141
|
+
UNIQUE(memory_id, layer, operation)
|
|
142
|
+
);
|
|
143
|
+
`);
|
|
144
|
+
}
|
|
145
|
+
// ── Memory CRUD ─────────────────────────────────────────────────────
|
|
146
|
+
createMemory(memory) {
|
|
147
|
+
this.db.prepare(`
|
|
148
|
+
INSERT INTO memories (id, agent_id, scope, subject_id, content, tags, entities, source, created_by, created_at, updated_at, expires_at, embedding_hash)
|
|
149
|
+
VALUES ($id, $agent_id, $scope, $subject_id, $content, $tags, $entities, $source, $created_by, $created_at, $updated_at, $expires_at, $embedding_hash)
|
|
150
|
+
`).run({
|
|
151
|
+
$id: memory.id,
|
|
152
|
+
$agent_id: memory.agent_id,
|
|
153
|
+
$scope: memory.scope,
|
|
154
|
+
$subject_id: memory.subject_id,
|
|
155
|
+
$content: memory.content,
|
|
156
|
+
$tags: JSON.stringify(memory.tags),
|
|
157
|
+
$entities: JSON.stringify(memory.entities),
|
|
158
|
+
$source: memory.source,
|
|
159
|
+
$created_by: memory.created_by,
|
|
160
|
+
$created_at: memory.created_at,
|
|
161
|
+
$updated_at: memory.updated_at,
|
|
162
|
+
$expires_at: memory.expires_at,
|
|
163
|
+
$embedding_hash: memory.embedding_hash
|
|
164
|
+
});
|
|
165
|
+
return memory;
|
|
166
|
+
}
|
|
167
|
+
getMemory(id) {
|
|
168
|
+
const row = this.db.prepare("SELECT * FROM memories WHERE id = $id").get({ $id: id });
|
|
169
|
+
if (!row) return null;
|
|
170
|
+
return this.rowToMemory(row);
|
|
171
|
+
}
|
|
172
|
+
updateMemory(id, updates) {
|
|
173
|
+
const existing = this.getMemory(id);
|
|
174
|
+
if (!existing) return null;
|
|
175
|
+
const updated = {
|
|
176
|
+
...existing,
|
|
177
|
+
...updates,
|
|
178
|
+
id: existing.id,
|
|
179
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
180
|
+
};
|
|
181
|
+
this.db.prepare(`
|
|
182
|
+
UPDATE memories SET
|
|
183
|
+
content = $content, tags = $tags, entities = $entities, scope = $scope, subject_id = $subject_id,
|
|
184
|
+
expires_at = $expires_at, embedding_hash = $embedding_hash, updated_at = $updated_at
|
|
185
|
+
WHERE id = $id
|
|
186
|
+
`).run({
|
|
187
|
+
$content: updated.content,
|
|
188
|
+
$tags: JSON.stringify(updated.tags),
|
|
189
|
+
$entities: JSON.stringify(updated.entities),
|
|
190
|
+
$scope: updated.scope,
|
|
191
|
+
$subject_id: updated.subject_id,
|
|
192
|
+
$expires_at: updated.expires_at,
|
|
193
|
+
$embedding_hash: updated.embedding_hash,
|
|
194
|
+
$updated_at: updated.updated_at,
|
|
195
|
+
$id: id
|
|
196
|
+
});
|
|
197
|
+
return updated;
|
|
198
|
+
}
|
|
199
|
+
deleteMemory(id) {
|
|
200
|
+
const result = this.db.prepare("DELETE FROM memories WHERE id = $id").run({ $id: id });
|
|
201
|
+
return result.changes > 0;
|
|
202
|
+
}
|
|
203
|
+
listMemories(query) {
|
|
204
|
+
const conditions = [];
|
|
205
|
+
const params = {};
|
|
206
|
+
if (query.agent_id) {
|
|
207
|
+
conditions.push("agent_id = $agent_id");
|
|
208
|
+
params.$agent_id = query.agent_id;
|
|
209
|
+
}
|
|
210
|
+
if (query.scope) {
|
|
211
|
+
conditions.push("scope = $scope");
|
|
212
|
+
params.$scope = query.scope;
|
|
213
|
+
}
|
|
214
|
+
if (query.subject_id) {
|
|
215
|
+
conditions.push("subject_id = $subject_id");
|
|
216
|
+
params.$subject_id = query.subject_id;
|
|
217
|
+
}
|
|
218
|
+
if (query.source) {
|
|
219
|
+
conditions.push("source = $source");
|
|
220
|
+
params.$source = query.source;
|
|
221
|
+
}
|
|
222
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
223
|
+
const order = query.order === "asc" ? "ASC" : "DESC";
|
|
224
|
+
const limit = query.limit || 50;
|
|
225
|
+
const offset = query.offset || 0;
|
|
226
|
+
params.$limit = limit;
|
|
227
|
+
params.$offset = offset;
|
|
228
|
+
const sql = `SELECT * FROM memories ${where} ORDER BY created_at ${order} LIMIT $limit OFFSET $offset`;
|
|
229
|
+
const rows = this.db.prepare(sql).all(params);
|
|
230
|
+
if (query.tags) {
|
|
231
|
+
const tagList = query.tags.split(",").map((t) => t.trim().toLowerCase());
|
|
232
|
+
return rows.map((r) => this.rowToMemory(r)).filter((m) => {
|
|
233
|
+
const memTags = m.tags.map((t) => t.toLowerCase());
|
|
234
|
+
return tagList.some((t) => memTags.includes(t));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return rows.map((r) => this.rowToMemory(r));
|
|
238
|
+
}
|
|
239
|
+
// ── Full-Text Search ────────────────────────────────────────────────
|
|
240
|
+
searchFullText(query, agentId, scopes, subjectId, limit = 10) {
|
|
241
|
+
const ftsQuery = query.split(/\s+/).filter(Boolean).map((term) => `"${term.replace(/"/g, "")}"`).join(" OR ");
|
|
242
|
+
if (!ftsQuery) return [];
|
|
243
|
+
const conditions = [];
|
|
244
|
+
const params = { $fts: ftsQuery, $limit: limit };
|
|
245
|
+
if (agentId) {
|
|
246
|
+
conditions.push("m.agent_id = $agent_id");
|
|
247
|
+
params.$agent_id = agentId;
|
|
248
|
+
}
|
|
249
|
+
if (scopes && scopes.length > 0) {
|
|
250
|
+
const scopePlaceholders = scopes.map((_, i) => `$scope_${i}`);
|
|
251
|
+
conditions.push(`m.scope IN (${scopePlaceholders.join(",")})`);
|
|
252
|
+
scopes.forEach((s, i) => {
|
|
253
|
+
params[`$scope_${i}`] = s;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (subjectId !== void 0 && subjectId !== null) {
|
|
257
|
+
conditions.push("m.subject_id = $subject_id");
|
|
258
|
+
params.$subject_id = subjectId;
|
|
259
|
+
}
|
|
260
|
+
const where = conditions.length > 0 ? `AND ${conditions.join(" AND ")}` : "";
|
|
261
|
+
const sql = `
|
|
262
|
+
SELECT m.*, rank
|
|
263
|
+
FROM memories_fts fts
|
|
264
|
+
JOIN memories m ON m.rowid = fts.rowid
|
|
265
|
+
WHERE memories_fts MATCH $fts
|
|
266
|
+
${where}
|
|
267
|
+
ORDER BY rank
|
|
268
|
+
LIMIT $limit
|
|
269
|
+
`;
|
|
270
|
+
const rows = this.db.prepare(sql).all(params);
|
|
271
|
+
return rows.map((r) => ({
|
|
272
|
+
...this.rowToMemory(r),
|
|
273
|
+
fts_rank: r.rank
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
// ── Conversation Log ────────────────────────────────────────────────
|
|
277
|
+
appendConversationLog(entry) {
|
|
278
|
+
this.db.prepare(
|
|
279
|
+
`INSERT INTO conversation_log (agent_id, session_id, user_id, channel, role, content, timestamp)
|
|
280
|
+
VALUES ($agent_id, $session_id, $user_id, $channel, $role, $content, $timestamp)`
|
|
281
|
+
).run({
|
|
282
|
+
$agent_id: entry.agent_id,
|
|
283
|
+
$session_id: entry.session_id,
|
|
284
|
+
$user_id: entry.user_id,
|
|
285
|
+
$channel: entry.channel,
|
|
286
|
+
$role: entry.role,
|
|
287
|
+
$content: entry.content,
|
|
288
|
+
$timestamp: entry.timestamp
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
getConversationLog(agentId, sessionId, limit = 100) {
|
|
292
|
+
return this.db.prepare(
|
|
293
|
+
`SELECT agent_id, session_id, user_id, channel, role, content, timestamp
|
|
294
|
+
FROM conversation_log
|
|
295
|
+
WHERE agent_id = $agent_id AND session_id = $session_id
|
|
296
|
+
ORDER BY timestamp ASC
|
|
297
|
+
LIMIT $limit`
|
|
298
|
+
).all({ $agent_id: agentId, $session_id: sessionId, $limit: limit });
|
|
299
|
+
}
|
|
300
|
+
// ── Sync Queue ──────────────────────────────────────────────────────
|
|
301
|
+
addToSyncQueue(memoryId, layer, operation) {
|
|
302
|
+
this.db.prepare(
|
|
303
|
+
`INSERT INTO sync_queue (memory_id, layer, operation, created_at)
|
|
304
|
+
VALUES ($memory_id, $layer, $operation, $created_at)
|
|
305
|
+
ON CONFLICT(memory_id, layer, operation) DO UPDATE SET
|
|
306
|
+
attempts = 0,
|
|
307
|
+
last_error = NULL,
|
|
308
|
+
created_at = excluded.created_at`
|
|
309
|
+
).run({
|
|
310
|
+
$memory_id: memoryId,
|
|
311
|
+
$layer: layer,
|
|
312
|
+
$operation: operation,
|
|
313
|
+
$created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
getSyncQueue(limit = 50) {
|
|
317
|
+
const rows = this.db.prepare(
|
|
318
|
+
`SELECT * FROM sync_queue
|
|
319
|
+
WHERE attempts < 5
|
|
320
|
+
ORDER BY created_at ASC
|
|
321
|
+
LIMIT $limit`
|
|
322
|
+
).all({ $limit: limit });
|
|
323
|
+
return rows.map((r) => ({
|
|
324
|
+
id: r.id,
|
|
325
|
+
memory_id: r.memory_id,
|
|
326
|
+
layer: r.layer,
|
|
327
|
+
operation: r.operation,
|
|
328
|
+
attempts: r.attempts,
|
|
329
|
+
last_error: r.last_error,
|
|
330
|
+
created_at: r.created_at
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
updateSyncQueueItem(id, attempts, lastError) {
|
|
334
|
+
this.db.prepare("UPDATE sync_queue SET attempts = $attempts, last_error = $last_error WHERE id = $id").run({ $attempts: attempts, $last_error: lastError, $id: id });
|
|
335
|
+
}
|
|
336
|
+
removeSyncQueueItem(id) {
|
|
337
|
+
this.db.prepare("DELETE FROM sync_queue WHERE id = $id").run({ $id: id });
|
|
338
|
+
}
|
|
339
|
+
clearCompletedSyncItems() {
|
|
340
|
+
const result = this.db.prepare("DELETE FROM sync_queue WHERE attempts >= 5").run();
|
|
341
|
+
return result.changes;
|
|
342
|
+
}
|
|
343
|
+
// ── Stats ───────────────────────────────────────────────────────────
|
|
344
|
+
getMemoryCount() {
|
|
345
|
+
const row = this.db.prepare("SELECT COUNT(*) as count FROM memories").get();
|
|
346
|
+
return row.count;
|
|
347
|
+
}
|
|
348
|
+
getDatabaseSize() {
|
|
349
|
+
try {
|
|
350
|
+
const row = this.db.prepare("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()").get();
|
|
351
|
+
return row.size;
|
|
352
|
+
} catch {
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// ── Health Check ────────────────────────────────────────────────────
|
|
357
|
+
healthCheck() {
|
|
358
|
+
try {
|
|
359
|
+
this.db.prepare("SELECT 1").get();
|
|
360
|
+
return true;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
366
|
+
rowToMemory(row) {
|
|
367
|
+
return {
|
|
368
|
+
id: row.id,
|
|
369
|
+
agent_id: row.agent_id,
|
|
370
|
+
scope: row.scope,
|
|
371
|
+
subject_id: row.subject_id,
|
|
372
|
+
content: row.content,
|
|
373
|
+
tags: JSON.parse(row.tags || "[]"),
|
|
374
|
+
entities: JSON.parse(row.entities || "[]"),
|
|
375
|
+
source: row.source,
|
|
376
|
+
created_by: row.created_by,
|
|
377
|
+
created_at: row.created_at,
|
|
378
|
+
updated_at: row.updated_at,
|
|
379
|
+
expires_at: row.expires_at,
|
|
380
|
+
embedding_hash: row.embedding_hash
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
close() {
|
|
384
|
+
this.db.close();
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// src/storage/qdrant.ts
|
|
389
|
+
var QdrantStorage = class {
|
|
390
|
+
client = null;
|
|
391
|
+
collection;
|
|
392
|
+
config;
|
|
393
|
+
ready = false;
|
|
394
|
+
constructor(config) {
|
|
395
|
+
this.config = config;
|
|
396
|
+
this.collection = config.collection;
|
|
397
|
+
}
|
|
398
|
+
// ── Lazy Client Init ────────────────────────────────────────────────
|
|
399
|
+
async getClient() {
|
|
400
|
+
if (this.client) return this.client;
|
|
401
|
+
try {
|
|
402
|
+
const mod = await import("@qdrant/js-client-rest");
|
|
403
|
+
const QdrantClientClass = mod.QdrantClient;
|
|
404
|
+
this.client = new QdrantClientClass({
|
|
405
|
+
url: this.config.url,
|
|
406
|
+
apiKey: this.config.apiKey
|
|
407
|
+
});
|
|
408
|
+
return this.client;
|
|
409
|
+
} catch {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"Qdrant client not available. Install @qdrant/js-client-rest: bun add @qdrant/js-client-rest"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// ── Collection Init ─────────────────────────────────────────────────
|
|
416
|
+
async ensureCollection(vectorSize = 1536) {
|
|
417
|
+
if (this.ready) return;
|
|
418
|
+
const client = await this.getClient();
|
|
419
|
+
try {
|
|
420
|
+
const collections = await client.getCollections();
|
|
421
|
+
const exists = collections.collections?.some(
|
|
422
|
+
(c) => c.name === this.collection
|
|
423
|
+
);
|
|
424
|
+
if (!exists) {
|
|
425
|
+
await client.createCollection(this.collection, {
|
|
426
|
+
vectors: {
|
|
427
|
+
size: vectorSize,
|
|
428
|
+
distance: "Cosine"
|
|
429
|
+
},
|
|
430
|
+
optimizers_config: {
|
|
431
|
+
default_segment_number: 2
|
|
432
|
+
},
|
|
433
|
+
replication_factor: 1
|
|
434
|
+
});
|
|
435
|
+
const indexFields = [
|
|
436
|
+
{ field_name: "agent_id", field_schema: "keyword" },
|
|
437
|
+
{ field_name: "scope", field_schema: "keyword" },
|
|
438
|
+
{ field_name: "subject_id", field_schema: "keyword" },
|
|
439
|
+
{ field_name: "tags", field_schema: "keyword" },
|
|
440
|
+
{ field_name: "entity_types", field_schema: "keyword" },
|
|
441
|
+
{ field_name: "entity_names", field_schema: "keyword" },
|
|
442
|
+
{ field_name: "source", field_schema: "keyword" }
|
|
443
|
+
];
|
|
444
|
+
for (const idx of indexFields) {
|
|
445
|
+
await client.createPayloadIndex(this.collection, idx);
|
|
446
|
+
}
|
|
447
|
+
console.log(`[qdrant] Created collection: ${this.collection}`);
|
|
448
|
+
}
|
|
449
|
+
this.ready = true;
|
|
450
|
+
} catch (error) {
|
|
451
|
+
console.error(`[qdrant] Failed to ensure collection: ${error}`);
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// ── Upsert ──────────────────────────────────────────────────────────
|
|
456
|
+
async upsertMemory(memory, vector) {
|
|
457
|
+
const client = await this.getClient();
|
|
458
|
+
await this.ensureCollection(vector.length);
|
|
459
|
+
const entityTypes = memory.entities.map((e) => e.type);
|
|
460
|
+
const entityNames = memory.entities.map((e) => e.name);
|
|
461
|
+
await client.upsert(this.collection, {
|
|
462
|
+
points: [
|
|
463
|
+
{
|
|
464
|
+
id: memory.id,
|
|
465
|
+
vector,
|
|
466
|
+
payload: {
|
|
467
|
+
agent_id: memory.agent_id,
|
|
468
|
+
scope: memory.scope,
|
|
469
|
+
subject_id: memory.subject_id,
|
|
470
|
+
content: memory.content,
|
|
471
|
+
tags: memory.tags,
|
|
472
|
+
entity_types: entityTypes,
|
|
473
|
+
entity_names: entityNames,
|
|
474
|
+
source: memory.source,
|
|
475
|
+
created_by: memory.created_by,
|
|
476
|
+
created_at: memory.created_at,
|
|
477
|
+
updated_at: memory.updated_at
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
]
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
// ── Delete ──────────────────────────────────────────────────────────
|
|
484
|
+
async deleteMemory(id) {
|
|
485
|
+
const client = await this.getClient();
|
|
486
|
+
if (!this.ready) await this.ensureCollection();
|
|
487
|
+
await client.delete(this.collection, {
|
|
488
|
+
points: [id]
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
// ── Semantic Search ─────────────────────────────────────────────────
|
|
492
|
+
async search(queryVector, agentId, scopes, subjectId, limit = 10, crossAgent = false) {
|
|
493
|
+
const client = await this.getClient();
|
|
494
|
+
await this.ensureCollection(queryVector.length);
|
|
495
|
+
const filter = this.buildFilter(agentId, scopes, subjectId, crossAgent);
|
|
496
|
+
const results = await client.search(this.collection, {
|
|
497
|
+
vector: queryVector,
|
|
498
|
+
limit,
|
|
499
|
+
with_payload: true,
|
|
500
|
+
filter: filter || void 0,
|
|
501
|
+
score_threshold: 0.3
|
|
502
|
+
});
|
|
503
|
+
return results.map((point) => {
|
|
504
|
+
const payload = point.payload || {};
|
|
505
|
+
return {
|
|
506
|
+
memory: this.payloadToMemory(String(point.id), payload),
|
|
507
|
+
score: point.score,
|
|
508
|
+
source_layer: "qdrant"
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
// ── Health Check ────────────────────────────────────────────────────
|
|
513
|
+
async healthCheck() {
|
|
514
|
+
try {
|
|
515
|
+
const client = await this.getClient();
|
|
516
|
+
await client.getCollections();
|
|
517
|
+
return true;
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// ── Collection Info ─────────────────────────────────────────────────
|
|
523
|
+
async getCollectionInfo() {
|
|
524
|
+
try {
|
|
525
|
+
const client = await this.getClient();
|
|
526
|
+
const info = await client.getCollection(this.collection);
|
|
527
|
+
return { vectorCount: info.points_count || 0 };
|
|
528
|
+
} catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
533
|
+
buildFilter(agentId, scopes, subjectId, crossAgent = false) {
|
|
534
|
+
const must = [];
|
|
535
|
+
if (agentId && !crossAgent) {
|
|
536
|
+
must.push({ key: "agent_id", match: { value: agentId } });
|
|
537
|
+
}
|
|
538
|
+
if (scopes && scopes.length > 0) {
|
|
539
|
+
must.push({ key: "scope", match: { any: scopes } });
|
|
540
|
+
}
|
|
541
|
+
if (subjectId !== void 0 && subjectId !== null) {
|
|
542
|
+
must.push({ key: "subject_id", match: { value: subjectId } });
|
|
543
|
+
}
|
|
544
|
+
if (must.length === 0) return null;
|
|
545
|
+
return { must };
|
|
546
|
+
}
|
|
547
|
+
payloadToMemory(id, payload) {
|
|
548
|
+
return {
|
|
549
|
+
id,
|
|
550
|
+
agent_id: payload.agent_id || "",
|
|
551
|
+
scope: payload.scope || "agent",
|
|
552
|
+
subject_id: payload.subject_id ?? null,
|
|
553
|
+
content: payload.content || "",
|
|
554
|
+
tags: payload.tags || [],
|
|
555
|
+
entities: (payload.entity_names || []).map(
|
|
556
|
+
(name, i) => ({
|
|
557
|
+
name,
|
|
558
|
+
type: (payload.entity_types || [])[i] || "Concept",
|
|
559
|
+
properties: {}
|
|
560
|
+
})
|
|
561
|
+
),
|
|
562
|
+
source: payload.source || "explicit",
|
|
563
|
+
created_by: payload.created_by ?? null,
|
|
564
|
+
created_at: payload.created_at || "",
|
|
565
|
+
updated_at: payload.updated_at || "",
|
|
566
|
+
expires_at: null,
|
|
567
|
+
embedding_hash: null
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// src/storage/age.ts
|
|
573
|
+
var MAX_CYPHER_INPUT_LENGTH = 1e3;
|
|
574
|
+
var AgeStorage = class {
|
|
575
|
+
pool = null;
|
|
576
|
+
config;
|
|
577
|
+
graph;
|
|
578
|
+
initialized = false;
|
|
579
|
+
constructor(config) {
|
|
580
|
+
this.config = config;
|
|
581
|
+
this.graph = config.graph;
|
|
582
|
+
}
|
|
583
|
+
// ── Lazy Pool Init ──────────────────────────────────────────────────
|
|
584
|
+
async getPool() {
|
|
585
|
+
if (this.pool) return this.pool;
|
|
586
|
+
try {
|
|
587
|
+
const pg = await import("pg");
|
|
588
|
+
const PoolClass = pg.default?.Pool || pg.Pool;
|
|
589
|
+
this.pool = new PoolClass({
|
|
590
|
+
host: this.config.host,
|
|
591
|
+
port: this.config.port,
|
|
592
|
+
user: this.config.user,
|
|
593
|
+
password: this.config.password,
|
|
594
|
+
database: this.config.database,
|
|
595
|
+
max: 5,
|
|
596
|
+
idleTimeoutMillis: 3e4
|
|
597
|
+
});
|
|
598
|
+
return this.pool;
|
|
599
|
+
} catch {
|
|
600
|
+
throw new Error(
|
|
601
|
+
"pg client not available. Install pg: bun add pg"
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// ── Graph Init ──────────────────────────────────────────────────────
|
|
606
|
+
async ensureGraph() {
|
|
607
|
+
if (this.initialized) return;
|
|
608
|
+
const pool = await this.getPool();
|
|
609
|
+
const client = await pool.connect();
|
|
610
|
+
try {
|
|
611
|
+
await client.query("LOAD 'age';");
|
|
612
|
+
await client.query('SET search_path = ag_catalog, "$user", public;');
|
|
613
|
+
const exists = await client.query(
|
|
614
|
+
"SELECT 1 FROM ag_catalog.ag_graph WHERE name = $1",
|
|
615
|
+
[this.graph]
|
|
616
|
+
);
|
|
617
|
+
if (exists.rowCount === 0) {
|
|
618
|
+
await client.query("SELECT ag_catalog.create_graph($1)", [this.graph]);
|
|
619
|
+
console.log(`[age] Created graph: ${this.graph}`);
|
|
620
|
+
}
|
|
621
|
+
this.initialized = true;
|
|
622
|
+
} catch (error) {
|
|
623
|
+
console.error(`[age] Failed to ensure graph: ${error}`);
|
|
624
|
+
throw error;
|
|
625
|
+
} finally {
|
|
626
|
+
client.release();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// ── Cypher Query Helper ─────────────────────────────────────────────
|
|
630
|
+
async cypherQuery(query, resultColumns = "v agtype") {
|
|
631
|
+
await this.ensureGraph();
|
|
632
|
+
const pool = await this.getPool();
|
|
633
|
+
const client = await pool.connect();
|
|
634
|
+
try {
|
|
635
|
+
await client.query("LOAD 'age';");
|
|
636
|
+
await client.query('SET search_path = ag_catalog, "$user", public;');
|
|
637
|
+
const sql = `SELECT * FROM ag_catalog.cypher('${escGraphName(this.graph)}', $$${query}$$) as (${resultColumns})`;
|
|
638
|
+
const result = await client.query(sql);
|
|
639
|
+
return result.rows.map((row) => {
|
|
640
|
+
const parsed = {};
|
|
641
|
+
for (const key of Object.keys(row)) {
|
|
642
|
+
parsed[key] = this.parseAgtype(row[key]);
|
|
643
|
+
}
|
|
644
|
+
if (Object.keys(parsed).length === 1 && "v" in parsed) {
|
|
645
|
+
return parsed.v;
|
|
646
|
+
}
|
|
647
|
+
return parsed;
|
|
648
|
+
});
|
|
649
|
+
} finally {
|
|
650
|
+
client.release();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async cypherExec(query) {
|
|
654
|
+
await this.ensureGraph();
|
|
655
|
+
const pool = await this.getPool();
|
|
656
|
+
const client = await pool.connect();
|
|
657
|
+
try {
|
|
658
|
+
await client.query("LOAD 'age';");
|
|
659
|
+
await client.query('SET search_path = ag_catalog, "$user", public;');
|
|
660
|
+
const sql = `SELECT * FROM ag_catalog.cypher('${escGraphName(this.graph)}', $$${query}$$) as (v agtype)`;
|
|
661
|
+
await client.query(sql);
|
|
662
|
+
} finally {
|
|
663
|
+
client.release();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// ── Memory Node Operations ──────────────────────────────────────────
|
|
667
|
+
async upsertMemoryNode(memory) {
|
|
668
|
+
const contentTruncated = memory.content.slice(0, 500);
|
|
669
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
670
|
+
try {
|
|
671
|
+
await this.cypherExec(
|
|
672
|
+
`MERGE (m:Memory {id: '${esc(memory.id)}'})
|
|
673
|
+
SET m.agent_id = '${esc(memory.agent_id)}',
|
|
674
|
+
m.scope = '${esc(memory.scope)}',
|
|
675
|
+
m.subject_id = '${esc(memory.subject_id || "")}',
|
|
676
|
+
m.content = '${esc(contentTruncated)}',
|
|
677
|
+
m.source = '${esc(memory.source)}',
|
|
678
|
+
m.created_at = '${esc(memory.created_at)}',
|
|
679
|
+
m.updated_at = '${esc(now)}'
|
|
680
|
+
RETURN m`
|
|
681
|
+
);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
console.error(`[age] Failed to upsert memory node: ${error}`);
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// ── Entity Node Operations ──────────────────────────────────────────
|
|
688
|
+
async upsertEntityNode(entity, agentId) {
|
|
689
|
+
const entityId = slugify(`${entity.type}:${entity.name}`);
|
|
690
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
691
|
+
const propsJson = JSON.stringify(entity.properties || {});
|
|
692
|
+
try {
|
|
693
|
+
await this.cypherExec(
|
|
694
|
+
`MERGE (e:Entity {id: '${esc(entityId)}'})
|
|
695
|
+
SET e.name = '${esc(entity.name)}',
|
|
696
|
+
e.entity_type = '${esc(entity.type)}',
|
|
697
|
+
e.agent_id = '${esc(agentId)}',
|
|
698
|
+
e.properties = '${esc(propsJson)}',
|
|
699
|
+
e.updated_at = '${esc(now)}'
|
|
700
|
+
RETURN e`
|
|
701
|
+
);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
console.error(`[age] Failed to upsert entity node ${entityId}: ${error}`);
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
return entityId;
|
|
707
|
+
}
|
|
708
|
+
// ── Relationship Operations ─────────────────────────────────────────
|
|
709
|
+
async createRelationship(rel, agentId) {
|
|
710
|
+
const fromId = slugify(`${this.guessEntityType(rel.from_entity)}:${rel.from_entity}`);
|
|
711
|
+
const toId = slugify(`${this.guessEntityType(rel.to_entity)}:${rel.to_entity}`);
|
|
712
|
+
const context = rel.properties?.context || "";
|
|
713
|
+
const relType = sanitizeLabel(rel.relationship);
|
|
714
|
+
if (!relType) {
|
|
715
|
+
console.warn(`[age] Invalid relationship type: ${rel.relationship}`);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
await this.cypherExec(
|
|
720
|
+
`MATCH (a:Entity {id: '${esc(fromId)}'}), (b:Entity {id: '${esc(toId)}'})
|
|
721
|
+
MERGE (a)-[r:${relType}]->(b)
|
|
722
|
+
SET r.context = '${esc(context)}',
|
|
723
|
+
r.agent_id = '${esc(agentId)}'
|
|
724
|
+
RETURN r`
|
|
725
|
+
);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
console.warn(`[age] Failed to create relationship ${fromId} -[${relType}]-> ${toId}: ${error}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
async linkMemoryToEntity(memoryId, entityId) {
|
|
731
|
+
try {
|
|
732
|
+
await this.cypherExec(
|
|
733
|
+
`MATCH (m:Memory {id: '${esc(memoryId)}'}), (e:Entity {id: '${esc(entityId)}'})
|
|
734
|
+
MERGE (m)-[r:MENTIONS]->(e)
|
|
735
|
+
RETURN r`
|
|
736
|
+
);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
console.warn(`[age] Failed to link memory ${memoryId} to entity ${entityId}: ${error}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// ── Delete ──────────────────────────────────────────────────────────
|
|
742
|
+
async deleteMemoryNode(memoryId) {
|
|
743
|
+
try {
|
|
744
|
+
try {
|
|
745
|
+
await this.cypherExec(
|
|
746
|
+
`MATCH (m:Memory {id: '${esc(memoryId)}'})-[r]-()
|
|
747
|
+
DELETE r
|
|
748
|
+
RETURN r`
|
|
749
|
+
);
|
|
750
|
+
} catch {
|
|
751
|
+
}
|
|
752
|
+
await this.cypherExec(
|
|
753
|
+
`MATCH (m:Memory {id: '${esc(memoryId)}'})
|
|
754
|
+
DELETE m
|
|
755
|
+
RETURN m`
|
|
756
|
+
);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
console.warn(`[age] Failed to delete memory node ${memoryId}: ${error}`);
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// ── Graph Queries ───────────────────────────────────────────────────
|
|
763
|
+
async getEntityWithRelationships(entityType, entityId) {
|
|
764
|
+
try {
|
|
765
|
+
const entities = await this.cypherQuery(
|
|
766
|
+
`MATCH (e:Entity {id: '${esc(entityId)}'})
|
|
767
|
+
RETURN properties(e) as v`
|
|
768
|
+
);
|
|
769
|
+
if (entities.length === 0) {
|
|
770
|
+
return { entity: null, relationships: [] };
|
|
771
|
+
}
|
|
772
|
+
const relationships = [];
|
|
773
|
+
try {
|
|
774
|
+
const outgoing = await this.cypherQuery(
|
|
775
|
+
`MATCH (e:Entity {id: '${esc(entityId)}'})-[r]->(target)
|
|
776
|
+
RETURN type(r) as rel_type, properties(target) as target_props`,
|
|
777
|
+
"rel_type agtype, target_props agtype"
|
|
778
|
+
);
|
|
779
|
+
for (const r of outgoing) {
|
|
780
|
+
relationships.push({
|
|
781
|
+
type: String(r.rel_type || ""),
|
|
782
|
+
direction: "outgoing",
|
|
783
|
+
target: r.target_props || {}
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
} catch {
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
const incoming = await this.cypherQuery(
|
|
790
|
+
`MATCH (e:Entity {id: '${esc(entityId)}'})<-[r]-(source)
|
|
791
|
+
RETURN type(r) as rel_type, properties(source) as source_props`,
|
|
792
|
+
"rel_type agtype, source_props agtype"
|
|
793
|
+
);
|
|
794
|
+
for (const r of incoming) {
|
|
795
|
+
relationships.push({
|
|
796
|
+
type: String(r.rel_type || ""),
|
|
797
|
+
direction: "incoming",
|
|
798
|
+
target: r.source_props || {}
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
return { entity: entities[0], relationships };
|
|
804
|
+
} catch (error) {
|
|
805
|
+
console.error(`[age] Failed to get entity: ${error}`);
|
|
806
|
+
return { entity: null, relationships: [] };
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async getRelatedEntities(entityId, depth = 2) {
|
|
810
|
+
try {
|
|
811
|
+
const maxDepth = Math.min(depth, 4);
|
|
812
|
+
const results = await this.cypherQuery(
|
|
813
|
+
`MATCH (start:Entity {id: '${esc(entityId)}'})-[*1..${maxDepth}]-(target:Entity)
|
|
814
|
+
WHERE target.id <> '${esc(entityId)}'
|
|
815
|
+
RETURN DISTINCT properties(target) as target_props`,
|
|
816
|
+
"target_props agtype"
|
|
817
|
+
);
|
|
818
|
+
return results.map((r) => ({
|
|
819
|
+
entity: r.target_props || r,
|
|
820
|
+
relationship: "RELATED_TO",
|
|
821
|
+
distance: 1
|
|
822
|
+
}));
|
|
823
|
+
} catch (error) {
|
|
824
|
+
console.error(`[age] Failed to get related entities: ${error}`);
|
|
825
|
+
return [];
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async searchByEntity(entityName, entityType, agentId, limit = 10) {
|
|
829
|
+
if (entityName.length > MAX_CYPHER_INPUT_LENGTH) {
|
|
830
|
+
console.warn("[age] Entity name too long, truncating");
|
|
831
|
+
entityName = entityName.slice(0, MAX_CYPHER_INPUT_LENGTH);
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
const entityId = slugify(`${entityType || "Concept"}:${entityName}`);
|
|
835
|
+
const safeLimit = Math.min(Math.max(1, limit), 100);
|
|
836
|
+
let results = [];
|
|
837
|
+
try {
|
|
838
|
+
results = await this.cypherQuery(
|
|
839
|
+
`MATCH (m:Memory)-[:MENTIONS]->(e:Entity {id: '${esc(entityId)}'})
|
|
840
|
+
${agentId ? `WHERE m.agent_id = '${esc(agentId)}'` : ""}
|
|
841
|
+
RETURN properties(m) as mem_props
|
|
842
|
+
ORDER BY m.created_at DESC
|
|
843
|
+
LIMIT ${safeLimit}`,
|
|
844
|
+
"mem_props agtype"
|
|
845
|
+
);
|
|
846
|
+
} catch {
|
|
847
|
+
}
|
|
848
|
+
if (results.length === 0) {
|
|
849
|
+
return await this.searchByEntityNameFuzzy(entityName, agentId, safeLimit);
|
|
850
|
+
}
|
|
851
|
+
return results.map((r, i) => this.graphResultToScoredMemory(r, entityName, entityType, i));
|
|
852
|
+
} catch (error) {
|
|
853
|
+
console.error(`[age] Graph search failed: ${error}`);
|
|
854
|
+
return [];
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
async searchByEntityNameFuzzy(name, agentId, limit = 10) {
|
|
858
|
+
if (name.length > MAX_CYPHER_INPUT_LENGTH) {
|
|
859
|
+
console.warn("[age] Fuzzy search name too long, truncating");
|
|
860
|
+
name = name.slice(0, MAX_CYPHER_INPUT_LENGTH);
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const escapedName = escRegex(esc(name));
|
|
864
|
+
const safeLimit = Math.min(Math.max(1, limit), 100);
|
|
865
|
+
const results = await this.cypherQuery(
|
|
866
|
+
`MATCH (m:Memory)-[:MENTIONS]->(e:Entity)
|
|
867
|
+
WHERE e.name =~ '(?i).*${escapedName}.*'
|
|
868
|
+
${agentId ? `AND m.agent_id = '${esc(agentId)}'` : ""}
|
|
869
|
+
RETURN properties(m) as mem_props, e.name as entity_name, e.entity_type as entity_type
|
|
870
|
+
ORDER BY m.created_at DESC
|
|
871
|
+
LIMIT ${safeLimit}`,
|
|
872
|
+
"mem_props agtype, entity_name agtype, entity_type agtype"
|
|
873
|
+
);
|
|
874
|
+
return results.map((r, i) => {
|
|
875
|
+
const props = r.mem_props || {};
|
|
876
|
+
return {
|
|
877
|
+
memory: this.propsToMemory(props),
|
|
878
|
+
score: 0.8 / (1 + i * 0.1),
|
|
879
|
+
source_layer: "age",
|
|
880
|
+
graph_context: {
|
|
881
|
+
related_entities: [
|
|
882
|
+
{
|
|
883
|
+
type: String(r.entity_type) || "Concept",
|
|
884
|
+
name: String(r.entity_name || name),
|
|
885
|
+
relationship: "MENTIONED_IN"
|
|
886
|
+
}
|
|
887
|
+
]
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
});
|
|
891
|
+
} catch (error) {
|
|
892
|
+
console.error(`[age] Fuzzy entity search failed: ${error}`);
|
|
893
|
+
return [];
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async listEntities(entityType, agentId, limit = 50) {
|
|
897
|
+
try {
|
|
898
|
+
const conditions = [];
|
|
899
|
+
if (entityType) conditions.push(`e.entity_type = '${esc(entityType)}'`);
|
|
900
|
+
if (agentId) conditions.push(`e.agent_id = '${esc(agentId)}'`);
|
|
901
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
902
|
+
const safeLimit = Math.min(Math.max(1, limit), 200);
|
|
903
|
+
const results = await this.cypherQuery(
|
|
904
|
+
`MATCH (e:Entity)
|
|
905
|
+
${where}
|
|
906
|
+
RETURN properties(e) as props
|
|
907
|
+
ORDER BY e.updated_at DESC
|
|
908
|
+
LIMIT ${safeLimit}`,
|
|
909
|
+
"props agtype"
|
|
910
|
+
);
|
|
911
|
+
return results.map((r) => r.props || r);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
console.error(`[age] Failed to list entities: ${error}`);
|
|
914
|
+
return [];
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// ── Agent Node ──────────────────────────────────────────────────────
|
|
918
|
+
async ensureAgentNode(agentId, name, role) {
|
|
919
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
920
|
+
try {
|
|
921
|
+
await this.cypherExec(
|
|
922
|
+
`MERGE (a:Agent {id: '${esc(agentId)}'})
|
|
923
|
+
SET a.name = '${esc(name)}',
|
|
924
|
+
a.role = '${esc(role)}',
|
|
925
|
+
a.created_at = '${esc(now)}'
|
|
926
|
+
RETURN a`
|
|
927
|
+
);
|
|
928
|
+
} catch (error) {
|
|
929
|
+
console.warn(`[age] Failed to ensure agent node: ${error}`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
// ── Stats ───────────────────────────────────────────────────────────
|
|
933
|
+
async getStats() {
|
|
934
|
+
try {
|
|
935
|
+
const entities = await this.cypherQuery(
|
|
936
|
+
`MATCH (e:Entity) RETURN count(e) as cnt`,
|
|
937
|
+
"cnt agtype"
|
|
938
|
+
);
|
|
939
|
+
const rels = await this.cypherQuery(
|
|
940
|
+
`MATCH ()-[r]->() RETURN count(r) as cnt`,
|
|
941
|
+
"cnt agtype"
|
|
942
|
+
);
|
|
943
|
+
return {
|
|
944
|
+
entityCount: Number(entities[0] || 0),
|
|
945
|
+
relationshipCount: Number(rels[0] || 0)
|
|
946
|
+
};
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// ── Health Check ────────────────────────────────────────────────────
|
|
952
|
+
async healthCheck() {
|
|
953
|
+
try {
|
|
954
|
+
const pool = await this.getPool();
|
|
955
|
+
const client = await pool.connect();
|
|
956
|
+
try {
|
|
957
|
+
await client.query("SELECT 1");
|
|
958
|
+
return true;
|
|
959
|
+
} finally {
|
|
960
|
+
client.release();
|
|
961
|
+
}
|
|
962
|
+
} catch {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
// ── Cleanup ─────────────────────────────────────────────────────────
|
|
967
|
+
async close() {
|
|
968
|
+
if (this.pool) {
|
|
969
|
+
await this.pool.end();
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
973
|
+
parseAgtype(value) {
|
|
974
|
+
if (value === null || value === void 0) return null;
|
|
975
|
+
if (typeof value === "string") {
|
|
976
|
+
try {
|
|
977
|
+
return JSON.parse(value);
|
|
978
|
+
} catch {
|
|
979
|
+
const cleaned = value.replace(/::(?:vertex|edge|path|agtype)$/g, "").trim();
|
|
980
|
+
try {
|
|
981
|
+
return JSON.parse(cleaned);
|
|
982
|
+
} catch {
|
|
983
|
+
return cleaned;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return value;
|
|
988
|
+
}
|
|
989
|
+
propsToMemory(props) {
|
|
990
|
+
return {
|
|
991
|
+
id: String(props.id || ""),
|
|
992
|
+
agent_id: String(props.agent_id || ""),
|
|
993
|
+
scope: String(props.scope || "agent"),
|
|
994
|
+
subject_id: props.subject_id || null,
|
|
995
|
+
content: String(props.content || ""),
|
|
996
|
+
tags: [],
|
|
997
|
+
entities: [],
|
|
998
|
+
source: String(props.source || "explicit"),
|
|
999
|
+
created_by: null,
|
|
1000
|
+
created_at: String(props.created_at || ""),
|
|
1001
|
+
updated_at: String(props.updated_at || ""),
|
|
1002
|
+
expires_at: null,
|
|
1003
|
+
embedding_hash: null
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
graphResultToScoredMemory(r, entityName, entityType, index) {
|
|
1007
|
+
const props = r.mem_props || {};
|
|
1008
|
+
return {
|
|
1009
|
+
memory: this.propsToMemory(props),
|
|
1010
|
+
score: 1 / (1 + index * 0.1),
|
|
1011
|
+
source_layer: "age",
|
|
1012
|
+
graph_context: {
|
|
1013
|
+
related_entities: [
|
|
1014
|
+
{
|
|
1015
|
+
type: entityType || "Concept",
|
|
1016
|
+
name: entityName,
|
|
1017
|
+
relationship: "MENTIONED_IN"
|
|
1018
|
+
}
|
|
1019
|
+
]
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
guessEntityType(_name) {
|
|
1024
|
+
return "Concept";
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
function esc(value) {
|
|
1028
|
+
if (!value) return "";
|
|
1029
|
+
const truncated = value.slice(0, MAX_CYPHER_INPUT_LENGTH);
|
|
1030
|
+
return truncated.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\$/g, "").replace(/\0/g, "");
|
|
1031
|
+
}
|
|
1032
|
+
function escGraphName(name) {
|
|
1033
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "");
|
|
1034
|
+
}
|
|
1035
|
+
function escRegex(value) {
|
|
1036
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1037
|
+
}
|
|
1038
|
+
function sanitizeLabel(label) {
|
|
1039
|
+
const sanitized = label.replace(/[^a-zA-Z0-9_]/g, "");
|
|
1040
|
+
return sanitized.length > 0 ? sanitized : null;
|
|
1041
|
+
}
|
|
1042
|
+
function slugify(input) {
|
|
1043
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/storage/sync-queue.ts
|
|
1047
|
+
var SyncQueueProcessor = class {
|
|
1048
|
+
sqlite;
|
|
1049
|
+
qdrant;
|
|
1050
|
+
age;
|
|
1051
|
+
embeddings;
|
|
1052
|
+
interval = null;
|
|
1053
|
+
processing = false;
|
|
1054
|
+
constructor(sqlite, qdrant, age, embeddings) {
|
|
1055
|
+
this.sqlite = sqlite;
|
|
1056
|
+
this.qdrant = qdrant;
|
|
1057
|
+
this.age = age;
|
|
1058
|
+
this.embeddings = embeddings;
|
|
1059
|
+
}
|
|
1060
|
+
start(intervalMs = 6e4) {
|
|
1061
|
+
if (this.interval) return;
|
|
1062
|
+
console.log(`[sync-queue] Starting processor (every ${intervalMs / 1e3}s)`);
|
|
1063
|
+
this.interval = setInterval(() => {
|
|
1064
|
+
void this.processQueue();
|
|
1065
|
+
}, intervalMs);
|
|
1066
|
+
void this.processQueue();
|
|
1067
|
+
}
|
|
1068
|
+
stop() {
|
|
1069
|
+
if (this.interval) {
|
|
1070
|
+
clearInterval(this.interval);
|
|
1071
|
+
this.interval = null;
|
|
1072
|
+
console.log("[sync-queue] Stopped");
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
async processQueue() {
|
|
1076
|
+
if (this.processing) return { processed: 0, succeeded: 0, failed: 0 };
|
|
1077
|
+
this.processing = true;
|
|
1078
|
+
let processed = 0;
|
|
1079
|
+
let succeeded = 0;
|
|
1080
|
+
let failed = 0;
|
|
1081
|
+
try {
|
|
1082
|
+
const items = this.sqlite.getSyncQueue(50);
|
|
1083
|
+
if (items.length === 0) {
|
|
1084
|
+
return { processed: 0, succeeded: 0, failed: 0 };
|
|
1085
|
+
}
|
|
1086
|
+
console.log(`[sync-queue] Processing ${items.length} items`);
|
|
1087
|
+
for (const item of items) {
|
|
1088
|
+
processed++;
|
|
1089
|
+
try {
|
|
1090
|
+
await this.processItem(item);
|
|
1091
|
+
this.sqlite.removeSyncQueueItem(item.id);
|
|
1092
|
+
succeeded++;
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1095
|
+
this.sqlite.updateSyncQueueItem(item.id, item.attempts + 1, errorMsg);
|
|
1096
|
+
failed++;
|
|
1097
|
+
console.warn(
|
|
1098
|
+
`[sync-queue] Failed item ${item.id} (${item.layer}/${item.operation}/${item.memory_id}): ${errorMsg}`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const cleared = this.sqlite.clearCompletedSyncItems();
|
|
1103
|
+
if (cleared > 0) {
|
|
1104
|
+
console.log(`[sync-queue] Cleared ${cleared} items that exceeded max retries`);
|
|
1105
|
+
}
|
|
1106
|
+
} finally {
|
|
1107
|
+
this.processing = false;
|
|
1108
|
+
}
|
|
1109
|
+
if (processed > 0) {
|
|
1110
|
+
console.log(`[sync-queue] Done: ${succeeded} ok, ${failed} failed out of ${processed}`);
|
|
1111
|
+
}
|
|
1112
|
+
return { processed, succeeded, failed };
|
|
1113
|
+
}
|
|
1114
|
+
async processItem(item) {
|
|
1115
|
+
if (item.layer === "qdrant") {
|
|
1116
|
+
await this.processQdrantItem(item);
|
|
1117
|
+
} else if (item.layer === "age") {
|
|
1118
|
+
await this.processAgeItem(item);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
async processQdrantItem(item) {
|
|
1122
|
+
if (!this.qdrant) throw new Error("Qdrant layer not configured");
|
|
1123
|
+
if (item.operation === "delete") {
|
|
1124
|
+
await this.qdrant.deleteMemory(item.memory_id);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const memory = this.sqlite.getMemory(item.memory_id);
|
|
1128
|
+
if (!memory) return;
|
|
1129
|
+
if (!this.embeddings) throw new Error("Embedding service not configured");
|
|
1130
|
+
const vector = await this.embeddings.embed(memory.content);
|
|
1131
|
+
if (!vector) throw new Error("Failed to generate embedding");
|
|
1132
|
+
await this.qdrant.upsertMemory(memory, vector);
|
|
1133
|
+
}
|
|
1134
|
+
async processAgeItem(item) {
|
|
1135
|
+
if (!this.age) throw new Error("AGE layer not configured");
|
|
1136
|
+
if (item.operation === "delete") {
|
|
1137
|
+
await this.age.deleteMemoryNode(item.memory_id);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const memory = this.sqlite.getMemory(item.memory_id);
|
|
1141
|
+
if (!memory) return;
|
|
1142
|
+
await this.age.upsertMemoryNode(memory);
|
|
1143
|
+
for (const entity of memory.entities) {
|
|
1144
|
+
const entityId = await this.age.upsertEntityNode(entity, memory.agent_id);
|
|
1145
|
+
await this.age.linkMemoryToEntity(memory.id, entityId);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
// src/extraction/embeddings.ts
|
|
1151
|
+
import OpenAI from "openai";
|
|
1152
|
+
var EmbeddingService = class {
|
|
1153
|
+
client;
|
|
1154
|
+
model;
|
|
1155
|
+
dimensions;
|
|
1156
|
+
constructor(config) {
|
|
1157
|
+
this.client = new OpenAI({
|
|
1158
|
+
apiKey: config.apiKey,
|
|
1159
|
+
baseURL: config.baseUrl
|
|
1160
|
+
});
|
|
1161
|
+
this.model = config.model;
|
|
1162
|
+
this.dimensions = config.dimensions;
|
|
1163
|
+
}
|
|
1164
|
+
async embed(text) {
|
|
1165
|
+
if (!text || text.trim().length === 0) return null;
|
|
1166
|
+
try {
|
|
1167
|
+
const response = await this.client.embeddings.create({
|
|
1168
|
+
model: this.model,
|
|
1169
|
+
input: text.slice(0, 8e3)
|
|
1170
|
+
});
|
|
1171
|
+
const embedding = response.data[0]?.embedding;
|
|
1172
|
+
if (!embedding || embedding.length === 0) {
|
|
1173
|
+
console.warn("[embeddings] Empty embedding returned");
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
return embedding;
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
console.error(`[embeddings] Failed to generate embedding: ${error}`);
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
async embedBatch(texts) {
|
|
1183
|
+
if (texts.length === 0) return [];
|
|
1184
|
+
try {
|
|
1185
|
+
const cleanTexts = texts.map((t) => (t || "").slice(0, 8e3));
|
|
1186
|
+
const response = await this.client.embeddings.create({
|
|
1187
|
+
model: this.model,
|
|
1188
|
+
input: cleanTexts
|
|
1189
|
+
});
|
|
1190
|
+
return response.data.map(
|
|
1191
|
+
(item) => item.embedding && item.embedding.length > 0 ? item.embedding : null
|
|
1192
|
+
);
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
console.error(`[embeddings] Batch embedding failed: ${error}`);
|
|
1195
|
+
return texts.map(() => null);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
getDimensions() {
|
|
1199
|
+
return this.dimensions;
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
// src/extraction/entity-extractor.ts
|
|
1204
|
+
import OpenAI2 from "openai";
|
|
1205
|
+
var ENTITY_EXTRACTION_PROMPT = `Extract entities and relationships from this memory text.
|
|
1206
|
+
|
|
1207
|
+
Return JSON with this exact structure:
|
|
1208
|
+
{
|
|
1209
|
+
"entities": [
|
|
1210
|
+
{
|
|
1211
|
+
"name": "exact name as mentioned",
|
|
1212
|
+
"type": "Person|Project|Organization|Decision|Preference|Event|Tool|Location|Concept",
|
|
1213
|
+
"properties": { "key": "value" }
|
|
1214
|
+
}
|
|
1215
|
+
],
|
|
1216
|
+
"relationships": [
|
|
1217
|
+
{
|
|
1218
|
+
"from_entity": "entity name",
|
|
1219
|
+
"to_entity": "entity name",
|
|
1220
|
+
"relationship": "WORKS_ON|DECIDED|PREFERS|KNOWS|USES|LOCATED_AT|BELONGS_TO|RELATED_TO|CREATED_BY|DEPENDS_ON",
|
|
1221
|
+
"properties": { "context": "brief context" }
|
|
1222
|
+
}
|
|
1223
|
+
]
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
Rules:
|
|
1227
|
+
- Only extract clearly stated entities, don't infer
|
|
1228
|
+
- Use the most specific entity type possible
|
|
1229
|
+
- Normalize person names to their full form when possible
|
|
1230
|
+
- For preferences, use key/value format (key = category, value = preference)
|
|
1231
|
+
- Keep properties minimal \u2014 only include what's explicitly stated
|
|
1232
|
+
- If no entities are found, return {"entities": [], "relationships": []}`;
|
|
1233
|
+
var EntityExtractor = class {
|
|
1234
|
+
client;
|
|
1235
|
+
model;
|
|
1236
|
+
constructor(config) {
|
|
1237
|
+
this.client = new OpenAI2({
|
|
1238
|
+
apiKey: config.apiKey,
|
|
1239
|
+
baseURL: config.baseUrl
|
|
1240
|
+
});
|
|
1241
|
+
this.model = config.model;
|
|
1242
|
+
}
|
|
1243
|
+
async extract(text) {
|
|
1244
|
+
if (!text || text.trim().length < 20) {
|
|
1245
|
+
return { entities: [], relationships: [] };
|
|
1246
|
+
}
|
|
1247
|
+
try {
|
|
1248
|
+
const response = await this.client.chat.completions.create({
|
|
1249
|
+
model: this.model,
|
|
1250
|
+
messages: [
|
|
1251
|
+
{ role: "system", content: ENTITY_EXTRACTION_PROMPT },
|
|
1252
|
+
{ role: "user", content: text.slice(0, 4e3) }
|
|
1253
|
+
],
|
|
1254
|
+
temperature: 0.1,
|
|
1255
|
+
max_tokens: 1500,
|
|
1256
|
+
response_format: { type: "json_object" }
|
|
1257
|
+
});
|
|
1258
|
+
const content = response.choices[0]?.message?.content;
|
|
1259
|
+
if (!content) {
|
|
1260
|
+
return { entities: [], relationships: [] };
|
|
1261
|
+
}
|
|
1262
|
+
const parsed = JSON.parse(content);
|
|
1263
|
+
return this.validateExtractionResult(parsed);
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
console.error(`[entity-extractor] Extraction failed: ${error}`);
|
|
1266
|
+
return { entities: [], relationships: [] };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
validateExtractionResult(data) {
|
|
1270
|
+
if (!data || typeof data !== "object") {
|
|
1271
|
+
return { entities: [], relationships: [] };
|
|
1272
|
+
}
|
|
1273
|
+
const raw = data;
|
|
1274
|
+
const entities = [];
|
|
1275
|
+
const relationships = [];
|
|
1276
|
+
const validTypes = [
|
|
1277
|
+
"Person",
|
|
1278
|
+
"Project",
|
|
1279
|
+
"Organization",
|
|
1280
|
+
"Decision",
|
|
1281
|
+
"Preference",
|
|
1282
|
+
"Event",
|
|
1283
|
+
"Tool",
|
|
1284
|
+
"Location",
|
|
1285
|
+
"Concept"
|
|
1286
|
+
];
|
|
1287
|
+
const validRels = [
|
|
1288
|
+
"WORKS_ON",
|
|
1289
|
+
"DECIDED",
|
|
1290
|
+
"PREFERS",
|
|
1291
|
+
"KNOWS",
|
|
1292
|
+
"USES",
|
|
1293
|
+
"LOCATED_AT",
|
|
1294
|
+
"BELONGS_TO",
|
|
1295
|
+
"RELATED_TO",
|
|
1296
|
+
"CREATED_BY",
|
|
1297
|
+
"DEPENDS_ON"
|
|
1298
|
+
];
|
|
1299
|
+
if (Array.isArray(raw.entities)) {
|
|
1300
|
+
for (const e of raw.entities) {
|
|
1301
|
+
if (e && typeof e === "object" && typeof e.name === "string" && typeof e.type === "string") {
|
|
1302
|
+
entities.push({
|
|
1303
|
+
name: e.name,
|
|
1304
|
+
type: validTypes.includes(e.type) ? e.type : "Concept",
|
|
1305
|
+
properties: typeof e.properties === "object" && e.properties !== null ? Object.fromEntries(
|
|
1306
|
+
Object.entries(e.properties).map(([k, v]) => [k, String(v)])
|
|
1307
|
+
) : {}
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
if (Array.isArray(raw.relationships)) {
|
|
1313
|
+
for (const r of raw.relationships) {
|
|
1314
|
+
if (r && typeof r === "object" && typeof r.from_entity === "string" && typeof r.to_entity === "string" && typeof r.relationship === "string") {
|
|
1315
|
+
relationships.push({
|
|
1316
|
+
from_entity: r.from_entity,
|
|
1317
|
+
to_entity: r.to_entity,
|
|
1318
|
+
relationship: validRels.includes(r.relationship) ? r.relationship : "RELATED_TO",
|
|
1319
|
+
properties: typeof r.properties === "object" && r.properties !== null ? Object.fromEntries(
|
|
1320
|
+
Object.entries(r.properties).map(([k, v]) => [k, String(v)])
|
|
1321
|
+
) : {}
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return { entities, relationships };
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
// src/storage/orchestrator.ts
|
|
1331
|
+
import { v7 as uuidv7 } from "uuid";
|
|
1332
|
+
var StorageOrchestrator = class {
|
|
1333
|
+
tier;
|
|
1334
|
+
sqlite;
|
|
1335
|
+
qdrant;
|
|
1336
|
+
age;
|
|
1337
|
+
embeddings;
|
|
1338
|
+
entityExtractor;
|
|
1339
|
+
syncProcessor;
|
|
1340
|
+
startTime;
|
|
1341
|
+
constructor(config) {
|
|
1342
|
+
this.tier = config.tier;
|
|
1343
|
+
this.sqlite = new SqliteStorage(config.sqlite.path);
|
|
1344
|
+
this.startTime = Date.now();
|
|
1345
|
+
if (config.qdrant) {
|
|
1346
|
+
this.qdrant = new QdrantStorage(config.qdrant);
|
|
1347
|
+
} else {
|
|
1348
|
+
this.qdrant = null;
|
|
1349
|
+
}
|
|
1350
|
+
if (config.age) {
|
|
1351
|
+
this.age = new AgeStorage(config.age);
|
|
1352
|
+
} else {
|
|
1353
|
+
this.age = null;
|
|
1354
|
+
}
|
|
1355
|
+
if (config.embedding) {
|
|
1356
|
+
this.embeddings = new EmbeddingService(config.embedding);
|
|
1357
|
+
} else {
|
|
1358
|
+
this.embeddings = null;
|
|
1359
|
+
}
|
|
1360
|
+
if (config.extraction && config.extraction.enabled) {
|
|
1361
|
+
this.entityExtractor = new EntityExtractor(config.extraction);
|
|
1362
|
+
} else {
|
|
1363
|
+
this.entityExtractor = null;
|
|
1364
|
+
}
|
|
1365
|
+
this.syncProcessor = new SyncQueueProcessor(
|
|
1366
|
+
this.sqlite,
|
|
1367
|
+
this.qdrant,
|
|
1368
|
+
this.age,
|
|
1369
|
+
this.embeddings
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
async init() {
|
|
1373
|
+
if (this.qdrant) {
|
|
1374
|
+
try {
|
|
1375
|
+
const dimensions = this.embeddings?.getDimensions() || 1536;
|
|
1376
|
+
await this.qdrant.ensureCollection(dimensions);
|
|
1377
|
+
console.log("[orchestrator] Qdrant collection ready");
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
console.warn(`[orchestrator] Qdrant init failed (will retry): ${error}`);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (this.age) {
|
|
1383
|
+
try {
|
|
1384
|
+
await this.age.ensureGraph();
|
|
1385
|
+
console.log("[orchestrator] AGE graph ready");
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
console.warn(`[orchestrator] AGE init failed (will retry): ${error}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (this.qdrant || this.age) {
|
|
1391
|
+
this.syncProcessor.start(6e4);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
// ── Create Memory ───────────────────────────────────────────────────
|
|
1395
|
+
async createMemory(req) {
|
|
1396
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1397
|
+
const id = uuidv7();
|
|
1398
|
+
let entities = [];
|
|
1399
|
+
let relationships = [];
|
|
1400
|
+
const shouldExtract = this.entityExtractor && req.extract_entities !== false && req.content.length >= 20 && req.source !== "entity_extraction";
|
|
1401
|
+
if (shouldExtract) {
|
|
1402
|
+
try {
|
|
1403
|
+
const extraction = await this.entityExtractor.extract(req.content);
|
|
1404
|
+
entities = extraction.entities;
|
|
1405
|
+
relationships = extraction.relationships;
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
console.warn(`[orchestrator] Entity extraction failed: ${error}`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
const embeddingHash = contentHash(req.content);
|
|
1411
|
+
const memory = {
|
|
1412
|
+
id,
|
|
1413
|
+
agent_id: req.agent_id,
|
|
1414
|
+
scope: req.scope,
|
|
1415
|
+
subject_id: req.subject_id ?? null,
|
|
1416
|
+
content: req.content,
|
|
1417
|
+
tags: req.tags || [],
|
|
1418
|
+
entities,
|
|
1419
|
+
source: req.source || "explicit",
|
|
1420
|
+
created_by: req.created_by ?? null,
|
|
1421
|
+
created_at: now,
|
|
1422
|
+
updated_at: now,
|
|
1423
|
+
expires_at: req.expires_at ?? null,
|
|
1424
|
+
embedding_hash: embeddingHash
|
|
1425
|
+
};
|
|
1426
|
+
this.sqlite.createMemory(memory);
|
|
1427
|
+
const qdrantStatus = await this.asyncL2Upsert(memory);
|
|
1428
|
+
const ageStatus = await this.asyncL3Upsert(memory, entities, relationships);
|
|
1429
|
+
return {
|
|
1430
|
+
id: memory.id,
|
|
1431
|
+
agent_id: memory.agent_id,
|
|
1432
|
+
scope: memory.scope,
|
|
1433
|
+
content: memory.content,
|
|
1434
|
+
entities: memory.entities,
|
|
1435
|
+
created_at: memory.created_at,
|
|
1436
|
+
sync_status: {
|
|
1437
|
+
sqlite: "ok",
|
|
1438
|
+
qdrant: qdrantStatus,
|
|
1439
|
+
age: ageStatus
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
// ── Update Memory ───────────────────────────────────────────────────
|
|
1444
|
+
async updateMemory(id, req) {
|
|
1445
|
+
const existing = this.sqlite.getMemory(id);
|
|
1446
|
+
if (!existing) return null;
|
|
1447
|
+
let entities = existing.entities;
|
|
1448
|
+
let relationships = [];
|
|
1449
|
+
if (req.content && req.content !== existing.content) {
|
|
1450
|
+
const shouldExtract = this.entityExtractor && req.extract_entities !== false && req.content.length >= 20;
|
|
1451
|
+
if (shouldExtract) {
|
|
1452
|
+
try {
|
|
1453
|
+
const extraction = await this.entityExtractor.extract(req.content);
|
|
1454
|
+
entities = extraction.entities;
|
|
1455
|
+
relationships = extraction.relationships;
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
console.warn(`[orchestrator] Entity extraction failed on update: ${error}`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
const embeddingHash = req.content ? contentHash(req.content) : existing.embedding_hash;
|
|
1462
|
+
const updates = {
|
|
1463
|
+
...req.content !== void 0 && { content: req.content },
|
|
1464
|
+
...req.tags !== void 0 && { tags: req.tags },
|
|
1465
|
+
...req.scope !== void 0 && { scope: req.scope },
|
|
1466
|
+
...req.subject_id !== void 0 && { subject_id: req.subject_id },
|
|
1467
|
+
...req.expires_at !== void 0 && { expires_at: req.expires_at },
|
|
1468
|
+
entities,
|
|
1469
|
+
embedding_hash: embeddingHash
|
|
1470
|
+
};
|
|
1471
|
+
const updated = this.sqlite.updateMemory(id, updates);
|
|
1472
|
+
if (!updated) return null;
|
|
1473
|
+
const qdrantStatus = await this.asyncL2Upsert(updated);
|
|
1474
|
+
const ageStatus = await this.asyncL3Upsert(updated, entities, relationships);
|
|
1475
|
+
return {
|
|
1476
|
+
id: updated.id,
|
|
1477
|
+
agent_id: updated.agent_id,
|
|
1478
|
+
scope: updated.scope,
|
|
1479
|
+
content: updated.content,
|
|
1480
|
+
tags: updated.tags,
|
|
1481
|
+
entities: updated.entities,
|
|
1482
|
+
created_at: updated.created_at,
|
|
1483
|
+
updated_at: updated.updated_at,
|
|
1484
|
+
sync_status: {
|
|
1485
|
+
sqlite: "ok",
|
|
1486
|
+
qdrant: qdrantStatus,
|
|
1487
|
+
age: ageStatus
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
// ── Delete Memory ───────────────────────────────────────────────────
|
|
1492
|
+
async deleteMemory(id) {
|
|
1493
|
+
const deleted = this.sqlite.deleteMemory(id);
|
|
1494
|
+
if (!deleted) return false;
|
|
1495
|
+
if (this.qdrant) {
|
|
1496
|
+
try {
|
|
1497
|
+
await this.qdrant.deleteMemory(id);
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
console.warn(`[orchestrator] Qdrant delete failed, queuing: ${error}`);
|
|
1500
|
+
this.sqlite.addToSyncQueue(id, "qdrant", "delete");
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (this.age) {
|
|
1504
|
+
try {
|
|
1505
|
+
await this.age.deleteMemoryNode(id);
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
console.warn(`[orchestrator] AGE delete failed, queuing: ${error}`);
|
|
1508
|
+
this.sqlite.addToSyncQueue(id, "age", "delete");
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
// ── Health Check ────────────────────────────────────────────────────
|
|
1514
|
+
async healthCheck() {
|
|
1515
|
+
const details = {};
|
|
1516
|
+
const sqliteOk = this.sqlite.healthCheck();
|
|
1517
|
+
if (!sqliteOk) details.sqlite = "SQLite health check failed";
|
|
1518
|
+
let qdrantStatus = "disabled";
|
|
1519
|
+
if (this.qdrant) {
|
|
1520
|
+
try {
|
|
1521
|
+
qdrantStatus = await this.qdrant.healthCheck() ? "ok" : "error";
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
qdrantStatus = "error";
|
|
1524
|
+
details.qdrant = String(error);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
let ageStatus = "disabled";
|
|
1528
|
+
if (this.age) {
|
|
1529
|
+
try {
|
|
1530
|
+
ageStatus = await this.age.healthCheck() ? "ok" : "error";
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
ageStatus = "error";
|
|
1533
|
+
details.age = String(error);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
return {
|
|
1537
|
+
sqlite: sqliteOk ? "ok" : "error",
|
|
1538
|
+
qdrant: qdrantStatus,
|
|
1539
|
+
age: ageStatus,
|
|
1540
|
+
tier: this.tier,
|
|
1541
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1e3),
|
|
1542
|
+
...Object.keys(details).length > 0 && { details }
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
// ── Retry Sync ──────────────────────────────────────────────────────
|
|
1546
|
+
async retrySyncQueue() {
|
|
1547
|
+
return this.syncProcessor.processQueue();
|
|
1548
|
+
}
|
|
1549
|
+
// ── Cleanup ─────────────────────────────────────────────────────────
|
|
1550
|
+
async close() {
|
|
1551
|
+
this.syncProcessor.stop();
|
|
1552
|
+
this.sqlite.close();
|
|
1553
|
+
if (this.age) await this.age.close();
|
|
1554
|
+
}
|
|
1555
|
+
// ── Private Helpers ─────────────────────────────────────────────────
|
|
1556
|
+
async asyncL2Upsert(memory) {
|
|
1557
|
+
if (!this.qdrant || !this.embeddings) return "disabled";
|
|
1558
|
+
try {
|
|
1559
|
+
const vector = await this.embeddings.embed(memory.content);
|
|
1560
|
+
if (!vector) {
|
|
1561
|
+
this.sqlite.addToSyncQueue(memory.id, "qdrant", "upsert");
|
|
1562
|
+
return "queued";
|
|
1563
|
+
}
|
|
1564
|
+
await this.qdrant.upsertMemory(memory, vector);
|
|
1565
|
+
this.sqlite.updateMemory(memory.id, {
|
|
1566
|
+
embedding_hash: contentHash(memory.content)
|
|
1567
|
+
});
|
|
1568
|
+
return "ok";
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
console.warn(`[orchestrator] Qdrant upsert failed, queuing: ${error}`);
|
|
1571
|
+
this.sqlite.addToSyncQueue(memory.id, "qdrant", "upsert");
|
|
1572
|
+
return "queued";
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
async asyncL3Upsert(memory, entities, relationships) {
|
|
1576
|
+
if (!this.age) return "disabled";
|
|
1577
|
+
try {
|
|
1578
|
+
await this.age.upsertMemoryNode(memory);
|
|
1579
|
+
for (const entity of entities) {
|
|
1580
|
+
const entityId = await this.age.upsertEntityNode(entity, memory.agent_id);
|
|
1581
|
+
await this.age.linkMemoryToEntity(memory.id, entityId);
|
|
1582
|
+
}
|
|
1583
|
+
for (const rel of relationships) {
|
|
1584
|
+
await this.age.createRelationship(rel, memory.agent_id);
|
|
1585
|
+
}
|
|
1586
|
+
return "ok";
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
console.warn(`[orchestrator] AGE upsert failed, queuing: ${error}`);
|
|
1589
|
+
this.sqlite.addToSyncQueue(memory.id, "age", "upsert");
|
|
1590
|
+
return "queued";
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
function contentHash(content) {
|
|
1595
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// src/search/strategy.ts
|
|
1599
|
+
var KEY_LOOKUP_PATTERNS = [
|
|
1600
|
+
/what is .+'s/i,
|
|
1601
|
+
/what are .+'s/i,
|
|
1602
|
+
/.+'s (email|phone|address|preference|setting)/i,
|
|
1603
|
+
/^(get|find|show|tell me) .+'s/i,
|
|
1604
|
+
/^what (does|did) .+ (like|prefer|use|want)/i
|
|
1605
|
+
];
|
|
1606
|
+
var RELATIONSHIP_PATTERNS = [
|
|
1607
|
+
/who (works on|knows|created|manages|uses)/i,
|
|
1608
|
+
/what.+(connected|related|linked|associated) (to|with)/i,
|
|
1609
|
+
/how (is|are) .+ (related|connected)/i,
|
|
1610
|
+
/relationship between/i,
|
|
1611
|
+
/(works on|belongs to|depends on|uses)/i,
|
|
1612
|
+
/what projects does/i,
|
|
1613
|
+
/who is involved (in|with)/i
|
|
1614
|
+
];
|
|
1615
|
+
function selectStrategy(request) {
|
|
1616
|
+
if (request.strategy && request.strategy !== "auto") {
|
|
1617
|
+
switch (request.strategy) {
|
|
1618
|
+
case "semantic":
|
|
1619
|
+
return "semantic";
|
|
1620
|
+
case "fulltext":
|
|
1621
|
+
return "fulltext";
|
|
1622
|
+
case "graph":
|
|
1623
|
+
return "graph";
|
|
1624
|
+
case "all":
|
|
1625
|
+
return "all";
|
|
1626
|
+
default:
|
|
1627
|
+
break;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
const query = request.query.toLowerCase();
|
|
1631
|
+
if (isKeyLookup(query)) return "fulltext+graph";
|
|
1632
|
+
if (isRelationshipQuery(query)) return "graph+semantic";
|
|
1633
|
+
return "semantic+graph";
|
|
1634
|
+
}
|
|
1635
|
+
function isKeyLookup(query) {
|
|
1636
|
+
return KEY_LOOKUP_PATTERNS.some((pattern) => pattern.test(query));
|
|
1637
|
+
}
|
|
1638
|
+
function isRelationshipQuery(query) {
|
|
1639
|
+
return RELATIONSHIP_PATTERNS.some((pattern) => pattern.test(query));
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// src/search/ranker.ts
|
|
1643
|
+
function normalizeFtsScore(rank) {
|
|
1644
|
+
const normalized = Math.min(1, Math.max(0, -rank / 20));
|
|
1645
|
+
return normalized;
|
|
1646
|
+
}
|
|
1647
|
+
function recencyBoost(createdAt) {
|
|
1648
|
+
const created = new Date(createdAt).getTime();
|
|
1649
|
+
const now = Date.now();
|
|
1650
|
+
const daysOld = (now - created) / (1e3 * 60 * 60 * 24);
|
|
1651
|
+
return Math.max(0.5, Math.pow(0.95, daysOld));
|
|
1652
|
+
}
|
|
1653
|
+
function multiLayerBoost(layerCount) {
|
|
1654
|
+
return (layerCount - 1) * 0.1;
|
|
1655
|
+
}
|
|
1656
|
+
function applyBoosts(result, layerAppearances) {
|
|
1657
|
+
let score = result.score;
|
|
1658
|
+
if (result.memory.created_at) {
|
|
1659
|
+
score *= recencyBoost(result.memory.created_at);
|
|
1660
|
+
}
|
|
1661
|
+
score += multiLayerBoost(layerAppearances);
|
|
1662
|
+
score = Math.min(1, Math.max(0, score));
|
|
1663
|
+
return { ...result, score };
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/search/engine.ts
|
|
1667
|
+
var SearchEngine = class {
|
|
1668
|
+
orchestrator;
|
|
1669
|
+
constructor(orchestrator) {
|
|
1670
|
+
this.orchestrator = orchestrator;
|
|
1671
|
+
}
|
|
1672
|
+
async search(request) {
|
|
1673
|
+
const strategy = selectStrategy(request);
|
|
1674
|
+
const limit = request.limit || 10;
|
|
1675
|
+
const scopes = request.scopes || ["user", "agent", "global"];
|
|
1676
|
+
const includeGraph = request.include_graph !== false;
|
|
1677
|
+
const layerStats = {
|
|
1678
|
+
sqlite: { count: 0, ms: 0 },
|
|
1679
|
+
qdrant: { count: 0, ms: 0 },
|
|
1680
|
+
age: { count: 0, ms: 0 }
|
|
1681
|
+
};
|
|
1682
|
+
const allResults = [];
|
|
1683
|
+
const searches = [];
|
|
1684
|
+
if (shouldSearchFulltext(strategy)) {
|
|
1685
|
+
searches.push(
|
|
1686
|
+
this.searchFulltext(request, scopes, limit).then((results) => {
|
|
1687
|
+
layerStats.sqlite.count = results.length;
|
|
1688
|
+
allResults.push(...results);
|
|
1689
|
+
})
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
if (shouldSearchSemantic(strategy) && this.orchestrator.qdrant && this.orchestrator.embeddings) {
|
|
1693
|
+
searches.push(
|
|
1694
|
+
this.searchSemantic(request, scopes, limit).then((results) => {
|
|
1695
|
+
layerStats.qdrant.count = results.length;
|
|
1696
|
+
allResults.push(...results);
|
|
1697
|
+
})
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
if (shouldSearchGraph(strategy) && includeGraph && this.orchestrator.age) {
|
|
1701
|
+
searches.push(
|
|
1702
|
+
this.searchGraph(request, limit).then((results) => {
|
|
1703
|
+
layerStats.age.count = results.length;
|
|
1704
|
+
allResults.push(...results);
|
|
1705
|
+
})
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
const startTime = Date.now();
|
|
1709
|
+
await Promise.allSettled(searches);
|
|
1710
|
+
const elapsed = Date.now() - startTime;
|
|
1711
|
+
if (layerStats.sqlite.count > 0) layerStats.sqlite.ms = elapsed;
|
|
1712
|
+
if (layerStats.qdrant.count > 0) layerStats.qdrant.ms = elapsed;
|
|
1713
|
+
if (layerStats.age.count > 0) layerStats.age.ms = elapsed;
|
|
1714
|
+
const merged = this.mergeResults(allResults, limit);
|
|
1715
|
+
return {
|
|
1716
|
+
results: merged,
|
|
1717
|
+
strategy_used: strategy,
|
|
1718
|
+
layer_stats: layerStats
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
// ── Layer-Specific Searches ─────────────────────────────────────────
|
|
1722
|
+
async searchFulltext(request, scopes, limit) {
|
|
1723
|
+
try {
|
|
1724
|
+
const results = this.orchestrator.sqlite.searchFullText(
|
|
1725
|
+
request.query,
|
|
1726
|
+
request.cross_agent ? void 0 : request.agent_id,
|
|
1727
|
+
scopes,
|
|
1728
|
+
request.subject_id,
|
|
1729
|
+
limit
|
|
1730
|
+
);
|
|
1731
|
+
return results.map((r) => ({
|
|
1732
|
+
memory: r,
|
|
1733
|
+
score: normalizeFtsScore(r.fts_rank),
|
|
1734
|
+
source_layer: "sqlite"
|
|
1735
|
+
}));
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
console.warn(`[search] Fulltext search failed: ${error}`);
|
|
1738
|
+
return [];
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
async searchSemantic(request, scopes, limit) {
|
|
1742
|
+
try {
|
|
1743
|
+
if (!this.orchestrator.embeddings || !this.orchestrator.qdrant) return [];
|
|
1744
|
+
const queryVector = await this.orchestrator.embeddings.embed(request.query);
|
|
1745
|
+
if (!queryVector) return [];
|
|
1746
|
+
return await this.orchestrator.qdrant.search(
|
|
1747
|
+
queryVector,
|
|
1748
|
+
request.cross_agent ? void 0 : request.agent_id,
|
|
1749
|
+
scopes,
|
|
1750
|
+
request.subject_id,
|
|
1751
|
+
limit,
|
|
1752
|
+
request.cross_agent
|
|
1753
|
+
);
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
console.warn(`[search] Semantic search failed: ${error}`);
|
|
1756
|
+
return [];
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
async searchGraph(request, limit) {
|
|
1760
|
+
try {
|
|
1761
|
+
if (!this.orchestrator.age) return [];
|
|
1762
|
+
const entityName = extractEntityFromQuery(request.query);
|
|
1763
|
+
if (!entityName) return [];
|
|
1764
|
+
return await this.orchestrator.age.searchByEntity(
|
|
1765
|
+
entityName,
|
|
1766
|
+
void 0,
|
|
1767
|
+
request.cross_agent ? void 0 : request.agent_id,
|
|
1768
|
+
limit
|
|
1769
|
+
);
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
console.warn(`[search] Graph search failed: ${error}`);
|
|
1772
|
+
return [];
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
// ── Result Merging ──────────────────────────────────────────────────
|
|
1776
|
+
mergeResults(allResults, limit) {
|
|
1777
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1778
|
+
for (const result of allResults) {
|
|
1779
|
+
const existing = byId.get(result.memory.id) || [];
|
|
1780
|
+
existing.push(result);
|
|
1781
|
+
byId.set(result.memory.id, existing);
|
|
1782
|
+
}
|
|
1783
|
+
const merged = [];
|
|
1784
|
+
for (const [_id, results] of byId) {
|
|
1785
|
+
results.sort((a, b) => b.score - a.score);
|
|
1786
|
+
const best = results[0];
|
|
1787
|
+
const graphContext = results.filter((r) => r.graph_context).flatMap((r) => r.graph_context.related_entities);
|
|
1788
|
+
const boosted = applyBoosts(best, results.length);
|
|
1789
|
+
if (graphContext.length > 0) {
|
|
1790
|
+
boosted.graph_context = { related_entities: graphContext };
|
|
1791
|
+
}
|
|
1792
|
+
merged.push(boosted);
|
|
1793
|
+
}
|
|
1794
|
+
merged.sort((a, b) => b.score - a.score);
|
|
1795
|
+
return merged.slice(0, limit);
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
function shouldSearchFulltext(strategy) {
|
|
1799
|
+
return ["fulltext", "fulltext+graph", "all"].includes(strategy);
|
|
1800
|
+
}
|
|
1801
|
+
function shouldSearchSemantic(strategy) {
|
|
1802
|
+
return ["semantic", "semantic+graph", "graph+semantic", "all"].includes(strategy);
|
|
1803
|
+
}
|
|
1804
|
+
function shouldSearchGraph(strategy) {
|
|
1805
|
+
return ["graph", "fulltext+graph", "graph+semantic", "semantic+graph", "all"].includes(strategy);
|
|
1806
|
+
}
|
|
1807
|
+
function extractEntityFromQuery(query) {
|
|
1808
|
+
const quoted = query.match(/["']([^"']+)["']/);
|
|
1809
|
+
if (quoted) return quoted[1];
|
|
1810
|
+
const aboutMatch = query.match(
|
|
1811
|
+
/(?:about|on|for|regarding|related to|connected to)\s+([A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)*)/i
|
|
1812
|
+
);
|
|
1813
|
+
if (aboutMatch) return aboutMatch[1];
|
|
1814
|
+
const capitalWords = query.match(/\b[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*/g);
|
|
1815
|
+
if (capitalWords && capitalWords.length > 0) {
|
|
1816
|
+
return capitalWords.sort((a, b) => b.length - a.length)[0];
|
|
1817
|
+
}
|
|
1818
|
+
const whoPattern = query.match(
|
|
1819
|
+
/who\s+(?:works on|knows|created|uses|manages)\s+(.+)/i
|
|
1820
|
+
);
|
|
1821
|
+
if (whoPattern) return whoPattern[1].trim();
|
|
1822
|
+
if (query.split(/\s+/).length <= 3) {
|
|
1823
|
+
return query.trim();
|
|
1824
|
+
}
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// src/extraction/summarizer.ts
|
|
1829
|
+
import OpenAI3 from "openai";
|
|
1830
|
+
var SUMMARIZE_PROMPT = `Summarize this conversation into 5-10 concise bullet points.
|
|
1831
|
+
Focus on:
|
|
1832
|
+
- Decisions made
|
|
1833
|
+
- Tasks discussed or assigned
|
|
1834
|
+
- Preferences expressed
|
|
1835
|
+
- Important facts learned
|
|
1836
|
+
- Action items or next steps
|
|
1837
|
+
|
|
1838
|
+
Be specific. Use names and details. Skip pleasantries and meta-conversation.
|
|
1839
|
+
Return the summary as a plain text bulleted list.`;
|
|
1840
|
+
var ConversationSummarizer = class {
|
|
1841
|
+
client;
|
|
1842
|
+
model;
|
|
1843
|
+
constructor(config) {
|
|
1844
|
+
this.client = new OpenAI3({
|
|
1845
|
+
apiKey: config.apiKey,
|
|
1846
|
+
baseURL: config.baseUrl
|
|
1847
|
+
});
|
|
1848
|
+
this.model = config.model;
|
|
1849
|
+
}
|
|
1850
|
+
async summarize(messages) {
|
|
1851
|
+
if (messages.length === 0) return null;
|
|
1852
|
+
const transcript = messages.map((m) => {
|
|
1853
|
+
const prefix = m.role === "user" ? "User" : m.role === "assistant" ? "Assistant" : "System";
|
|
1854
|
+
return `${prefix}: ${m.content}`;
|
|
1855
|
+
}).join("\n");
|
|
1856
|
+
const truncated = transcript.slice(-6e3);
|
|
1857
|
+
try {
|
|
1858
|
+
const response = await this.client.chat.completions.create({
|
|
1859
|
+
model: this.model,
|
|
1860
|
+
messages: [
|
|
1861
|
+
{ role: "system", content: SUMMARIZE_PROMPT },
|
|
1862
|
+
{ role: "user", content: truncated }
|
|
1863
|
+
],
|
|
1864
|
+
temperature: 0.2,
|
|
1865
|
+
max_tokens: 500
|
|
1866
|
+
});
|
|
1867
|
+
const content = response.choices[0]?.message?.content?.trim();
|
|
1868
|
+
return content || null;
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
console.error(`[summarizer] Summarization failed: ${error}`);
|
|
1871
|
+
return null;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
|
|
1876
|
+
export {
|
|
1877
|
+
StorageOrchestrator,
|
|
1878
|
+
SearchEngine,
|
|
1879
|
+
ConversationSummarizer
|
|
1880
|
+
};
|
|
1881
|
+
//# sourceMappingURL=chunk-JSQBXYDM.js.map
|