@jungjaehoon/mama-core 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -19
- package/db/migrations/001-initial-decision-graph.sql +5 -0
- package/db/migrations/014-add-is-static-column.sql +5 -0
- package/db/migrations/015-add-fts5-search.sql +34 -0
- package/db/migrations/016-add-memory-v2-columns.sql +14 -0
- package/db/migrations/017-create-memory-scopes.sql +10 -0
- package/db/migrations/018-create-memory-scope-bindings.sql +15 -0
- package/db/migrations/019-create-memory-events.sql +18 -0
- package/db/migrations/020-create-audit-findings.sql +18 -0
- package/db/migrations/021-create-memory-truth.sql +20 -0
- package/db/migrations/022-create-channel-summaries.sql +9 -0
- package/db/migrations/023-create-channel-summary-state.sql +9 -0
- package/db/migrations/024-add-event-date.sql +9 -0
- package/dist/db-adapter/base-adapter.d.ts +1 -1
- package/dist/db-adapter/index.js +2 -8
- package/dist/db-adapter/node-sqlite-adapter.d.ts +6 -1
- package/dist/db-adapter/node-sqlite-adapter.js +99 -45
- package/dist/db-manager.d.ts +30 -2
- package/dist/db-manager.js +131 -6
- package/dist/decision-formatter.d.ts +2 -0
- package/dist/embedding-server/mobile/session-manager.js +22 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +31 -3
- package/dist/mama-api.d.ts +67 -2
- package/dist/mama-api.js +295 -80
- package/dist/memory/api.d.ts +81 -0
- package/dist/memory/api.js +972 -0
- package/dist/memory/bootstrap-builder.d.ts +10 -0
- package/dist/memory/bootstrap-builder.js +66 -0
- package/dist/memory/channel-summary-state-store.d.ts +19 -0
- package/dist/memory/channel-summary-state-store.js +223 -0
- package/dist/memory/channel-summary-store.d.ts +8 -0
- package/dist/memory/channel-summary-store.js +41 -0
- package/dist/memory/event-store.d.ts +5 -0
- package/dist/memory/event-store.js +72 -0
- package/dist/memory/evolution-engine.d.ts +22 -0
- package/dist/memory/evolution-engine.js +133 -0
- package/dist/memory/extraction-prompt.d.ts +4 -0
- package/dist/memory/extraction-prompt.js +89 -0
- package/dist/memory/finding-store.d.ts +5 -0
- package/dist/memory/finding-store.js +75 -0
- package/dist/memory/profile-builder.d.ts +3 -0
- package/dist/memory/profile-builder.js +23 -0
- package/dist/memory/scope-store.d.ts +5 -0
- package/dist/memory/scope-store.js +13 -0
- package/dist/memory/truth-store.d.ts +11 -0
- package/dist/memory/truth-store.js +102 -0
- package/dist/memory/types.d.ts +267 -0
- package/dist/memory/types.js +74 -0
- package/package.json +4 -1
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getChannelSummary = exports.upsertChannelSummary = exports.LEXICAL_STOPWORDS = void 0;
|
|
7
|
+
exports.getLexicalQueryTokens = getLexicalQueryTokens;
|
|
8
|
+
exports.loadEdgesForIds = loadEdgesForIds;
|
|
9
|
+
exports.saveMemory = saveMemory;
|
|
10
|
+
exports.buildProfile = buildProfile;
|
|
11
|
+
exports.recallMemory = recallMemory;
|
|
12
|
+
exports.ingestMemory = ingestMemory;
|
|
13
|
+
exports.evolveMemory = evolveMemory;
|
|
14
|
+
exports.buildMemoryBootstrap = buildMemoryBootstrap;
|
|
15
|
+
exports.createAuditAck = createAuditAck;
|
|
16
|
+
exports.recordMemoryAudit = recordMemoryAudit;
|
|
17
|
+
exports.setExtractionFn = setExtractionFn;
|
|
18
|
+
exports.ingestConversation = ingestConversation;
|
|
19
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
20
|
+
const db_manager_js_1 = require("../db-manager.js");
|
|
21
|
+
const embeddings_js_1 = require("../embeddings.js");
|
|
22
|
+
const profile_builder_js_1 = require("./profile-builder.js");
|
|
23
|
+
const bootstrap_builder_js_1 = require("./bootstrap-builder.js");
|
|
24
|
+
const evolution_engine_js_1 = require("./evolution-engine.js");
|
|
25
|
+
const channel_summary_state_store_js_1 = require("./channel-summary-state-store.js");
|
|
26
|
+
const debug_logger_js_1 = require("../debug-logger.js");
|
|
27
|
+
const truth_store_js_1 = require("./truth-store.js");
|
|
28
|
+
const extraction_prompt_js_1 = require("./extraction-prompt.js");
|
|
29
|
+
const types_js_1 = require("./types.js");
|
|
30
|
+
const channel_summary_store_js_1 = require("./channel-summary-store.js");
|
|
31
|
+
Object.defineProperty(exports, "getChannelSummary", { enumerable: true, get: function () { return channel_summary_store_js_1.getChannelSummary; } });
|
|
32
|
+
Object.defineProperty(exports, "upsertChannelSummary", { enumerable: true, get: function () { return channel_summary_store_js_1.upsertChannelSummary; } });
|
|
33
|
+
function buildDecisionId(topic) {
|
|
34
|
+
const safeTopic = topic.replace(/[^a-z0-9_]+/gi, '_').toLowerCase();
|
|
35
|
+
return `decision_${safeTopic}_${Date.now()}_${node_crypto_1.default.randomUUID().slice(0, 8)}`;
|
|
36
|
+
}
|
|
37
|
+
function toMemoryRecord(row, scopes, fallbackSource) {
|
|
38
|
+
let trustContext = null;
|
|
39
|
+
if (typeof row.trust_context === 'string') {
|
|
40
|
+
try {
|
|
41
|
+
trustContext = JSON.parse(row.trust_context);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* malformed */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const savedSource = trustContext?.source;
|
|
48
|
+
return {
|
|
49
|
+
id: String(row.id),
|
|
50
|
+
topic: String(row.topic),
|
|
51
|
+
kind: row.kind ?? 'decision',
|
|
52
|
+
summary: String(row.summary ?? row.decision ?? ''),
|
|
53
|
+
details: String(row.reasoning ?? row.decision ?? ''),
|
|
54
|
+
confidence: Number(row.confidence ?? 0.5),
|
|
55
|
+
status: row.status ?? 'active',
|
|
56
|
+
scopes,
|
|
57
|
+
source: savedSource ?? fallbackSource,
|
|
58
|
+
created_at: row.created_at,
|
|
59
|
+
updated_at: row.updated_at ?? row.created_at,
|
|
60
|
+
event_date: row.event_date ?? null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function batchLoadScopes(adapter, memoryIds) {
|
|
64
|
+
const scopeMap = new Map();
|
|
65
|
+
if (memoryIds.length === 0)
|
|
66
|
+
return scopeMap;
|
|
67
|
+
const placeholders = memoryIds.map(() => '?').join(', ');
|
|
68
|
+
const rows = adapter
|
|
69
|
+
.prepare(`
|
|
70
|
+
SELECT msb.memory_id, ms.kind, ms.external_id
|
|
71
|
+
FROM memory_scope_bindings msb
|
|
72
|
+
JOIN memory_scopes ms ON ms.id = msb.scope_id
|
|
73
|
+
WHERE msb.memory_id IN (${placeholders})
|
|
74
|
+
ORDER BY msb.is_primary DESC
|
|
75
|
+
`)
|
|
76
|
+
.all(...memoryIds);
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
const existing = scopeMap.get(row.memory_id) ?? [];
|
|
79
|
+
existing.push({ kind: row.kind, id: row.external_id });
|
|
80
|
+
scopeMap.set(row.memory_id, existing);
|
|
81
|
+
}
|
|
82
|
+
return scopeMap;
|
|
83
|
+
}
|
|
84
|
+
async function loadScopedMemories(scopes) {
|
|
85
|
+
await (0, db_manager_js_1.initDB)();
|
|
86
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
87
|
+
const fallbackSource = { package: 'mama-core', source_type: 'db' };
|
|
88
|
+
let rows;
|
|
89
|
+
if (scopes.length === 0) {
|
|
90
|
+
rows = adapter
|
|
91
|
+
.prepare(`
|
|
92
|
+
SELECT id, topic, decision, reasoning, confidence, created_at, updated_at, trust_context,
|
|
93
|
+
kind, status, summary, event_date
|
|
94
|
+
FROM decisions
|
|
95
|
+
ORDER BY created_at DESC
|
|
96
|
+
`)
|
|
97
|
+
.all();
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const scopeIds = await Promise.all(scopes.map((scope) => (0, db_manager_js_1.ensureMemoryScope)(scope.kind, scope.id)));
|
|
101
|
+
const placeholders = scopeIds.map(() => '?').join(', ');
|
|
102
|
+
rows = adapter
|
|
103
|
+
.prepare(`
|
|
104
|
+
SELECT DISTINCT d.id, d.topic, d.decision, d.reasoning, d.confidence, d.created_at,
|
|
105
|
+
d.updated_at, d.trust_context, d.kind, d.status, d.summary, d.event_date
|
|
106
|
+
FROM decisions d
|
|
107
|
+
JOIN memory_scope_bindings msb ON msb.memory_id = d.id
|
|
108
|
+
WHERE msb.scope_id IN (${placeholders})
|
|
109
|
+
ORDER BY d.created_at DESC
|
|
110
|
+
`)
|
|
111
|
+
.all(...scopeIds);
|
|
112
|
+
}
|
|
113
|
+
const memoryIds = rows.map((row) => String(row.id));
|
|
114
|
+
const scopeMap = batchLoadScopes(adapter, memoryIds);
|
|
115
|
+
return rows.map((row) => toMemoryRecord(row, scopeMap.get(String(row.id)) ?? [], fallbackSource));
|
|
116
|
+
}
|
|
117
|
+
exports.LEXICAL_STOPWORDS = new Set([
|
|
118
|
+
'a',
|
|
119
|
+
'an',
|
|
120
|
+
'and',
|
|
121
|
+
'are',
|
|
122
|
+
'as',
|
|
123
|
+
'at',
|
|
124
|
+
'be',
|
|
125
|
+
'by',
|
|
126
|
+
'did',
|
|
127
|
+
'do',
|
|
128
|
+
'does',
|
|
129
|
+
'for',
|
|
130
|
+
'from',
|
|
131
|
+
'had',
|
|
132
|
+
'has',
|
|
133
|
+
'have',
|
|
134
|
+
'how',
|
|
135
|
+
'i',
|
|
136
|
+
'in',
|
|
137
|
+
'is',
|
|
138
|
+
'it',
|
|
139
|
+
'my',
|
|
140
|
+
'of',
|
|
141
|
+
'on',
|
|
142
|
+
'or',
|
|
143
|
+
'that',
|
|
144
|
+
'the',
|
|
145
|
+
'their',
|
|
146
|
+
'to',
|
|
147
|
+
'was',
|
|
148
|
+
'were',
|
|
149
|
+
'what',
|
|
150
|
+
'when',
|
|
151
|
+
'where',
|
|
152
|
+
'which',
|
|
153
|
+
'who',
|
|
154
|
+
'why',
|
|
155
|
+
'with',
|
|
156
|
+
'you',
|
|
157
|
+
'your',
|
|
158
|
+
]);
|
|
159
|
+
/**
|
|
160
|
+
* Intentionally minimal English suffix stemmer for fuzzy lexical matching.
|
|
161
|
+
* Not a full Porter/Snowball implementation — imperfect stems (e.g., "running" → "runn")
|
|
162
|
+
* are acceptable for scoring/recall. Avoids pulling in a heavier stemming dependency.
|
|
163
|
+
*/
|
|
164
|
+
function stemToken(token) {
|
|
165
|
+
if (token.length <= 4)
|
|
166
|
+
return token;
|
|
167
|
+
// Order matters: try longest suffix first
|
|
168
|
+
if (token.endsWith('ies') && token.length > 4)
|
|
169
|
+
return token.slice(0, -3) + 'y';
|
|
170
|
+
if (token.endsWith('ing') && token.length > 5)
|
|
171
|
+
return token.slice(0, -3);
|
|
172
|
+
if (token.endsWith('ed') && token.length > 4)
|
|
173
|
+
return token.slice(0, -2);
|
|
174
|
+
if (token.endsWith('s') && !token.endsWith('ss'))
|
|
175
|
+
return token.slice(0, -1);
|
|
176
|
+
return token;
|
|
177
|
+
}
|
|
178
|
+
function getLexicalQueryTokens(query) {
|
|
179
|
+
return query
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
.split(/[\s,.!?;:()[\]{}"']+/)
|
|
182
|
+
.filter((token) => token.length > 2 && !exports.LEXICAL_STOPWORDS.has(token));
|
|
183
|
+
}
|
|
184
|
+
function buildLexicalCandidates(records, query) {
|
|
185
|
+
const tokens = getLexicalQueryTokens(query);
|
|
186
|
+
const normalizedQuery = query.toLowerCase();
|
|
187
|
+
return records
|
|
188
|
+
.map((record) => {
|
|
189
|
+
const haystack = [record.topic, record.summary, record.details].join(' ').toLowerCase();
|
|
190
|
+
const tokenMatches = tokens.reduce((count, token) => {
|
|
191
|
+
const stem = stemToken(token);
|
|
192
|
+
if (!haystack.includes(token) && !haystack.includes(stem)) {
|
|
193
|
+
return count;
|
|
194
|
+
}
|
|
195
|
+
if (token.length >= 8) {
|
|
196
|
+
return count + 3;
|
|
197
|
+
}
|
|
198
|
+
if (token.length >= 5) {
|
|
199
|
+
return count + 2;
|
|
200
|
+
}
|
|
201
|
+
return count + 1;
|
|
202
|
+
}, 0);
|
|
203
|
+
const phraseBoost = haystack.includes(normalizedQuery) ? 2 : 0;
|
|
204
|
+
const score = tokenMatches + phraseBoost;
|
|
205
|
+
return { memory: record, score };
|
|
206
|
+
})
|
|
207
|
+
.filter((candidate) => candidate.score > 0)
|
|
208
|
+
.sort((left, right) => {
|
|
209
|
+
if (right.score !== left.score) {
|
|
210
|
+
return right.score - left.score;
|
|
211
|
+
}
|
|
212
|
+
return Number(right.memory.created_at) - Number(left.memory.created_at);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function lexicalScoreToConfidence(score) {
|
|
216
|
+
return Math.min(0.95, 0.45 + score * 0.05);
|
|
217
|
+
}
|
|
218
|
+
// Retained for potential future use in non-RRF recall paths
|
|
219
|
+
function _mergeRecallCandidates(primary, lexical) {
|
|
220
|
+
const merged = new Map();
|
|
221
|
+
for (const memory of primary) {
|
|
222
|
+
merged.set(memory.id, {
|
|
223
|
+
memory,
|
|
224
|
+
sortScore: memory.confidence ?? 0.5,
|
|
225
|
+
lexicalScore: 0,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// Determine the lowest vector confidence to cap lexical-only candidates below it
|
|
229
|
+
const minVectorConfidence = primary.length > 0 ? Math.min(...primary.map((m) => m.confidence ?? 0.5)) : 1.0;
|
|
230
|
+
const LEXICAL_ONLY_CAP = Math.max(0, minVectorConfidence - 0.01);
|
|
231
|
+
for (const candidate of lexical) {
|
|
232
|
+
const lexicalConfidence = lexicalScoreToConfidence(candidate.score);
|
|
233
|
+
const existing = merged.get(candidate.memory.id);
|
|
234
|
+
if (existing) {
|
|
235
|
+
// Already has a vector hit — safe to boost with lexical score
|
|
236
|
+
existing.sortScore = Math.max(existing.sortScore, lexicalConfidence);
|
|
237
|
+
existing.lexicalScore = Math.max(existing.lexicalScore, candidate.score);
|
|
238
|
+
existing.memory = {
|
|
239
|
+
...existing.memory,
|
|
240
|
+
confidence: Math.max(existing.memory.confidence ?? 0.5, lexicalConfidence),
|
|
241
|
+
};
|
|
242
|
+
merged.set(candidate.memory.id, existing);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Lexical-only candidate — cap below the lowest vector hit
|
|
246
|
+
const cappedConfidence = Math.min(lexicalConfidence, LEXICAL_ONLY_CAP);
|
|
247
|
+
merged.set(candidate.memory.id, {
|
|
248
|
+
memory: {
|
|
249
|
+
...candidate.memory,
|
|
250
|
+
confidence: Math.max(candidate.memory.confidence ?? 0.5, cappedConfidence),
|
|
251
|
+
},
|
|
252
|
+
sortScore: cappedConfidence,
|
|
253
|
+
lexicalScore: candidate.score,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return Array.from(merged.values())
|
|
257
|
+
.sort((left, right) => {
|
|
258
|
+
if (right.sortScore !== left.sortScore) {
|
|
259
|
+
return right.sortScore - left.sortScore;
|
|
260
|
+
}
|
|
261
|
+
if (right.lexicalScore !== left.lexicalScore) {
|
|
262
|
+
return right.lexicalScore - left.lexicalScore;
|
|
263
|
+
}
|
|
264
|
+
return Number(right.memory.created_at) - Number(left.memory.created_at);
|
|
265
|
+
})
|
|
266
|
+
.map((candidate) => candidate.memory);
|
|
267
|
+
}
|
|
268
|
+
async function loadEdgesForIds(ids) {
|
|
269
|
+
if (ids.length === 0)
|
|
270
|
+
return [];
|
|
271
|
+
await (0, db_manager_js_1.initDB)();
|
|
272
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
273
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
274
|
+
const rows = adapter
|
|
275
|
+
.prepare(`SELECT from_id, to_id, relationship AS type, reason
|
|
276
|
+
FROM decision_edges
|
|
277
|
+
WHERE (from_id IN (${placeholders}) OR to_id IN (${placeholders}))
|
|
278
|
+
AND (approved_by_user != 0 OR approved_by_user IS NULL)`)
|
|
279
|
+
.all(...ids, ...ids);
|
|
280
|
+
return rows.map((row) => ({
|
|
281
|
+
from_id: row.from_id,
|
|
282
|
+
to_id: row.to_id,
|
|
283
|
+
type: row.type,
|
|
284
|
+
reason: row.reason ?? undefined,
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
async function saveMemory(input) {
|
|
288
|
+
await (0, db_manager_js_1.initDB)();
|
|
289
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
290
|
+
const id = buildDecisionId(input.topic);
|
|
291
|
+
const now = Date.now();
|
|
292
|
+
// Find evolution candidates: exact topic match first, then semantic search fallback
|
|
293
|
+
const primaryScope = input.scopes.length > 0 ? input.scopes[0] : null;
|
|
294
|
+
const excludeIds = new Set(input.excludeIds ?? []);
|
|
295
|
+
let existingCandidates;
|
|
296
|
+
if (primaryScope) {
|
|
297
|
+
const scopeId = await (0, db_manager_js_1.ensureMemoryScope)(primaryScope.kind, primaryScope.id);
|
|
298
|
+
existingCandidates = adapter
|
|
299
|
+
.prepare(`
|
|
300
|
+
SELECT d.id, d.topic, d.summary, d.kind
|
|
301
|
+
FROM decisions d
|
|
302
|
+
JOIN memory_scope_bindings msb ON msb.memory_id = d.id
|
|
303
|
+
WHERE d.topic = ? AND msb.scope_id = ?
|
|
304
|
+
AND (d.status = 'active' OR d.status IS NULL)
|
|
305
|
+
AND d.superseded_by IS NULL
|
|
306
|
+
ORDER BY d.created_at DESC
|
|
307
|
+
LIMIT 5
|
|
308
|
+
`)
|
|
309
|
+
.all(input.topic, scopeId).filter((c) => !excludeIds.has(c.id));
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
existingCandidates = adapter
|
|
313
|
+
.prepare(`
|
|
314
|
+
SELECT id, topic, summary, kind
|
|
315
|
+
FROM decisions
|
|
316
|
+
WHERE topic = ?
|
|
317
|
+
AND (status = 'active' OR status IS NULL)
|
|
318
|
+
AND superseded_by IS NULL
|
|
319
|
+
ORDER BY created_at DESC
|
|
320
|
+
LIMIT 5
|
|
321
|
+
`)
|
|
322
|
+
.all(input.topic).filter((c) => !excludeIds.has(c.id));
|
|
323
|
+
}
|
|
324
|
+
// Semantic fallback: if no exact topic match, find similar memories via vector search
|
|
325
|
+
if (existingCandidates.length === 0) {
|
|
326
|
+
try {
|
|
327
|
+
const queryText = `${input.topic} ${input.summary}`;
|
|
328
|
+
const embedding = await (0, embeddings_js_1.generateEmbedding)(queryText);
|
|
329
|
+
const semanticResults = await (0, db_manager_js_1.vectorSearch)(embedding, 3, 0.82);
|
|
330
|
+
// Scope-filter semantic candidates when a primary scope is available
|
|
331
|
+
let scopeFiltered = semanticResults;
|
|
332
|
+
if (primaryScope) {
|
|
333
|
+
const semIds = semanticResults.map((r) => String(r.id));
|
|
334
|
+
const semScopeMap = batchLoadScopes(adapter, semIds);
|
|
335
|
+
const scopeKey = `${primaryScope.kind}:${primaryScope.id}`;
|
|
336
|
+
scopeFiltered = semanticResults.filter((r) => {
|
|
337
|
+
const scopes = semScopeMap.get(String(r.id)) ?? [];
|
|
338
|
+
return scopes.length === 0 || scopes.some((s) => `${s.kind}:${s.id}` === scopeKey);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
existingCandidates = scopeFiltered
|
|
342
|
+
.filter((r) => {
|
|
343
|
+
const status = String(r.status || '');
|
|
344
|
+
return !status || status === 'active' || status === '';
|
|
345
|
+
})
|
|
346
|
+
.filter((c) => !excludeIds.has(String(c.id)))
|
|
347
|
+
.map((r) => ({
|
|
348
|
+
id: String(r.id),
|
|
349
|
+
topic: String(r.topic || ''),
|
|
350
|
+
summary: String(r.decision || ''),
|
|
351
|
+
kind: 'fact',
|
|
352
|
+
_semanticMatch: true,
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Semantic search unavailable — proceed with empty candidates
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const evolution = (0, evolution_engine_js_1.resolveMemoryEvolution)({
|
|
360
|
+
incoming: { topic: input.topic, summary: input.summary, kind: input.kind },
|
|
361
|
+
existing: existingCandidates.map((c) => ({
|
|
362
|
+
...c,
|
|
363
|
+
kind: (c.kind || 'fact'),
|
|
364
|
+
})),
|
|
365
|
+
});
|
|
366
|
+
const supersedesTarget = evolution.edges.find((edge) => edge.type === 'supersedes')?.to_id ?? null;
|
|
367
|
+
await (0, db_manager_js_1.insertDecisionWithEmbedding)({
|
|
368
|
+
id,
|
|
369
|
+
topic: input.topic,
|
|
370
|
+
decision: input.summary,
|
|
371
|
+
reasoning: input.details,
|
|
372
|
+
confidence: input.confidence ?? 0.5,
|
|
373
|
+
supersedes: supersedesTarget,
|
|
374
|
+
created_at: now,
|
|
375
|
+
updated_at: now,
|
|
376
|
+
trust_context: JSON.stringify({ source: input.source }),
|
|
377
|
+
event_date: input.eventDate ?? null,
|
|
378
|
+
});
|
|
379
|
+
// Pre-resolve scope IDs before the synchronous transaction
|
|
380
|
+
const resolvedScopeIds = [];
|
|
381
|
+
for (const [index, scope] of input.scopes.entries()) {
|
|
382
|
+
const scopeId = await (0, db_manager_js_1.ensureMemoryScope)(scope.kind, scope.id);
|
|
383
|
+
resolvedScopeIds.push({ scopeId, isPrimary: index === 0 });
|
|
384
|
+
}
|
|
385
|
+
// Wrap all post-insert mutations in a transaction for atomicity
|
|
386
|
+
adapter.transaction(() => {
|
|
387
|
+
adapter
|
|
388
|
+
.prepare(`
|
|
389
|
+
UPDATE decisions
|
|
390
|
+
SET kind = ?, status = ?, summary = ?, is_static = ?, trust_context = ?, updated_at = ?
|
|
391
|
+
WHERE id = ?
|
|
392
|
+
`)
|
|
393
|
+
.run(input.kind, input.status ?? 'active', input.summary, input.kind === 'preference' || input.kind === 'constraint' ? 1 : 0, JSON.stringify({ source: input.source }), now, id);
|
|
394
|
+
for (const { scopeId, isPrimary } of resolvedScopeIds) {
|
|
395
|
+
adapter
|
|
396
|
+
.prepare(`
|
|
397
|
+
INSERT OR REPLACE INTO memory_scope_bindings (memory_id, scope_id, is_primary)
|
|
398
|
+
VALUES (?, ?, ?)
|
|
399
|
+
`)
|
|
400
|
+
.run(id, scopeId, isPrimary ? 1 : 0);
|
|
401
|
+
}
|
|
402
|
+
for (const edge of evolution.edges) {
|
|
403
|
+
if (edge.type === 'supersedes') {
|
|
404
|
+
adapter
|
|
405
|
+
.prepare(`UPDATE decisions SET superseded_by = ?, status = 'superseded', updated_at = ? WHERE id = ?`)
|
|
406
|
+
.run(id, now, edge.to_id);
|
|
407
|
+
}
|
|
408
|
+
adapter
|
|
409
|
+
.prepare(`
|
|
410
|
+
INSERT OR REPLACE INTO decision_edges (from_id, to_id, relationship, reason, weight, created_at)
|
|
411
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
412
|
+
`)
|
|
413
|
+
.run(id, edge.to_id, edge.type, edge.reason ?? null, 1.0, now);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
// Project to memory_truth table (best-effort; failure should not break save)
|
|
417
|
+
try {
|
|
418
|
+
await (0, truth_store_js_1.projectMemoryTruth)({
|
|
419
|
+
memory_id: id,
|
|
420
|
+
topic: input.topic,
|
|
421
|
+
truth_status: (input.status ?? 'active'),
|
|
422
|
+
effective_summary: input.summary,
|
|
423
|
+
effective_details: input.details,
|
|
424
|
+
trust_score: input.confidence ?? 0.5,
|
|
425
|
+
scope_refs: input.scopes,
|
|
426
|
+
supporting_event_ids: [],
|
|
427
|
+
superseded_by: undefined,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Truth projection is best-effort; do not fail the save
|
|
432
|
+
}
|
|
433
|
+
return { success: true, id };
|
|
434
|
+
}
|
|
435
|
+
async function buildProfile(scopes) {
|
|
436
|
+
const records = await loadScopedMemories(scopes);
|
|
437
|
+
return (0, profile_builder_js_1.classifyProfileEntries)(records);
|
|
438
|
+
}
|
|
439
|
+
const EXCLUDED_STATUSES = new Set([
|
|
440
|
+
'superseded',
|
|
441
|
+
'quarantined',
|
|
442
|
+
'contradicted',
|
|
443
|
+
'stale',
|
|
444
|
+
]);
|
|
445
|
+
async function recallMemory(query, options = {}) {
|
|
446
|
+
const bundle = (0, types_js_1.createEmptyRecallBundle)(query);
|
|
447
|
+
let matched = [];
|
|
448
|
+
let retrievalSource = 'none';
|
|
449
|
+
let _lexicalRecords = null;
|
|
450
|
+
const loadLexical = async () => {
|
|
451
|
+
if (_lexicalRecords === null) {
|
|
452
|
+
_lexicalRecords = await loadScopedMemories(options.scopes ?? []);
|
|
453
|
+
}
|
|
454
|
+
return _lexicalRecords;
|
|
455
|
+
};
|
|
456
|
+
// Query analysis: detect aggregation patterns and extract sub-queries
|
|
457
|
+
const lowerQuery = query.toLowerCase();
|
|
458
|
+
const isAggregation = /\b(how many|how much|total|all|every|each|count|number of)\b/.test(lowerQuery);
|
|
459
|
+
const hasMultipleEntities = /\b(and|or|vs|versus|compared|between)\b/.test(lowerQuery);
|
|
460
|
+
const vectorLimit = isAggregation ? 50 : 20;
|
|
461
|
+
// Multi-query decomposition: extract sub-queries for complex questions
|
|
462
|
+
const subQueries = [query];
|
|
463
|
+
if (hasMultipleEntities) {
|
|
464
|
+
// Split on "or"/"and" to create focused sub-queries
|
|
465
|
+
const parts = query
|
|
466
|
+
.split(/\b(?:or|vs|versus|and|,)\b/i)
|
|
467
|
+
.map((p) => p.trim())
|
|
468
|
+
.filter((p) => p.length > 10);
|
|
469
|
+
if (parts.length > 1) {
|
|
470
|
+
subQueries.push(...parts);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Hybrid search: vector + BM25/lexical in parallel, fused with RRF
|
|
474
|
+
await (0, db_manager_js_1.initDB)();
|
|
475
|
+
// Channel 1: Vector search (semantic similarity) — run all sub-queries
|
|
476
|
+
const vectorMatched = [];
|
|
477
|
+
try {
|
|
478
|
+
for (const sq of subQueries) {
|
|
479
|
+
const queryEmbedding = await (0, embeddings_js_1.generateEmbedding)(sq);
|
|
480
|
+
const vectorResults = await (0, db_manager_js_1.vectorSearch)(queryEmbedding, vectorLimit, 0.3, options.topicPrefix);
|
|
481
|
+
let filtered = vectorResults;
|
|
482
|
+
if (options.scopes && options.scopes.length > 0) {
|
|
483
|
+
const vectorIds = vectorResults.map((r) => String(r.id));
|
|
484
|
+
const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), vectorIds);
|
|
485
|
+
const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
|
|
486
|
+
filtered = vectorResults.filter((r) => {
|
|
487
|
+
const scopes = scopeMap.get(String(r.id)) ?? [];
|
|
488
|
+
// When scopes are requested, zero-binding results must NOT pass through
|
|
489
|
+
if (scopes.length === 0)
|
|
490
|
+
return false;
|
|
491
|
+
return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
for (const result of filtered) {
|
|
495
|
+
const effectiveStatus = result.status || result.outcome || '';
|
|
496
|
+
if (!options.includeHistory && effectiveStatus && EXCLUDED_STATUSES.has(effectiveStatus)) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
vectorMatched.push({
|
|
500
|
+
id: String(result.id),
|
|
501
|
+
topic: String(result.topic || ''),
|
|
502
|
+
kind: 'decision',
|
|
503
|
+
summary: String(result.decision || ''),
|
|
504
|
+
details: String(result.reasoning || ''),
|
|
505
|
+
confidence: result.similarity ?? 0.5,
|
|
506
|
+
status: effectiveStatus || 'active',
|
|
507
|
+
scopes: [],
|
|
508
|
+
source: { package: 'mama-core', source_type: 'vector_search' },
|
|
509
|
+
created_at: result.created_at ?? Date.now(),
|
|
510
|
+
updated_at: result.created_at ?? Date.now(),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
} // end sub-query loop
|
|
514
|
+
}
|
|
515
|
+
catch (vectorErr) {
|
|
516
|
+
(0, debug_logger_js_1.warn)(`[recallMemory] Vector search failed: ${vectorErr instanceof Error ? vectorErr.message : String(vectorErr)}`);
|
|
517
|
+
}
|
|
518
|
+
// Deduplicate vector results from multiple sub-queries
|
|
519
|
+
const seenIds = new Set();
|
|
520
|
+
const dedupedVector = [];
|
|
521
|
+
for (const r of vectorMatched) {
|
|
522
|
+
if (!seenIds.has(r.id)) {
|
|
523
|
+
seenIds.add(r.id);
|
|
524
|
+
dedupedVector.push(r);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
vectorMatched.length = 0;
|
|
528
|
+
vectorMatched.push(...dedupedVector);
|
|
529
|
+
// Channel 2: FTS5 BM25 search (preferred) with in-memory lexical fallback
|
|
530
|
+
// Lazy lexical: skip expensive FTS5/lexical when vector already returned enough results,
|
|
531
|
+
// unless this is an aggregation query that benefits from broader coverage.
|
|
532
|
+
const VECTOR_SUFFICIENT_THRESHOLD = 5;
|
|
533
|
+
const needsLexical = isAggregation || vectorMatched.length < VECTOR_SUFFICIENT_THRESHOLD;
|
|
534
|
+
let lexicalCandidates = [];
|
|
535
|
+
const lexicalLimit = isAggregation ? 100 : 50;
|
|
536
|
+
if (needsLexical) {
|
|
537
|
+
try {
|
|
538
|
+
// Try FTS5 first — proper BM25 ranking, much better than in-memory .includes()
|
|
539
|
+
// FTS5 MATCH treats spaces as AND; convert to OR so partial matches still surface.
|
|
540
|
+
// Use all non-stopword tokens for FTS5 (stopwords already removed by getLexicalQueryTokens).
|
|
541
|
+
// Additional high-frequency words that cause too many FTS5 matches are filtered separately.
|
|
542
|
+
const FTS5_NOISE_WORDS = new Set([
|
|
543
|
+
'this',
|
|
544
|
+
'that',
|
|
545
|
+
'also',
|
|
546
|
+
'just',
|
|
547
|
+
'like',
|
|
548
|
+
'some',
|
|
549
|
+
'many',
|
|
550
|
+
'much',
|
|
551
|
+
'very',
|
|
552
|
+
'more',
|
|
553
|
+
'most',
|
|
554
|
+
'such',
|
|
555
|
+
'each',
|
|
556
|
+
'every',
|
|
557
|
+
'been',
|
|
558
|
+
'being',
|
|
559
|
+
'about',
|
|
560
|
+
'would',
|
|
561
|
+
'could',
|
|
562
|
+
'should',
|
|
563
|
+
'will',
|
|
564
|
+
'year',
|
|
565
|
+
'years',
|
|
566
|
+
'time',
|
|
567
|
+
'know',
|
|
568
|
+
'think',
|
|
569
|
+
'want',
|
|
570
|
+
'need',
|
|
571
|
+
'make',
|
|
572
|
+
'made',
|
|
573
|
+
]);
|
|
574
|
+
const ftsTokens = getLexicalQueryTokens(query)
|
|
575
|
+
.map((t) => stemToken(t))
|
|
576
|
+
.filter((t) => !FTS5_NOISE_WORDS.has(t));
|
|
577
|
+
const ftsQuery = ftsTokens.length > 0 ? ftsTokens.join(' OR ') : query;
|
|
578
|
+
const ftsResults = await (0, db_manager_js_1.fts5Search)(ftsQuery, lexicalLimit);
|
|
579
|
+
if (ftsResults.length > 0) {
|
|
580
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
581
|
+
const fallbackSource = {
|
|
582
|
+
package: 'mama-core',
|
|
583
|
+
source_type: 'fts5',
|
|
584
|
+
};
|
|
585
|
+
// Normalize BM25 ranks (negative values, closer to 0 = better match)
|
|
586
|
+
const maxRank = Math.max(...ftsResults.map((r) => Math.abs(r.rank)));
|
|
587
|
+
for (const ftsRow of ftsResults) {
|
|
588
|
+
const row = adapter
|
|
589
|
+
.prepare(`SELECT id, topic, decision, reasoning, confidence, created_at, updated_at,
|
|
590
|
+
trust_context, kind, status, summary, event_date
|
|
591
|
+
FROM decisions WHERE id = ?`)
|
|
592
|
+
.get(ftsRow.id);
|
|
593
|
+
if (!row)
|
|
594
|
+
continue;
|
|
595
|
+
const effectiveStatus = row.status || '';
|
|
596
|
+
if (!options.includeHistory &&
|
|
597
|
+
effectiveStatus &&
|
|
598
|
+
EXCLUDED_STATUSES.has(effectiveStatus)) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const memoryIds = [String(row.id)];
|
|
602
|
+
const scopeMap = batchLoadScopes(adapter, memoryIds);
|
|
603
|
+
const record = toMemoryRecord(row, scopeMap.get(String(row.id)) ?? [], fallbackSource);
|
|
604
|
+
// Topic prefix filtering (matches vectorSearch behavior)
|
|
605
|
+
if (options.topicPrefix && !record.topic.startsWith(options.topicPrefix))
|
|
606
|
+
continue;
|
|
607
|
+
// Scope filtering
|
|
608
|
+
if (options.scopes && options.scopes.length > 0) {
|
|
609
|
+
const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
|
|
610
|
+
const scopes = scopeMap.get(record.id) ?? [];
|
|
611
|
+
if (scopes.length === 0)
|
|
612
|
+
continue;
|
|
613
|
+
if (!scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`)))
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const bm25Score = maxRank > 0 ? 1 - Math.abs(ftsRow.rank) / maxRank : 0.5;
|
|
617
|
+
lexicalCandidates.push({ memory: record, score: bm25Score });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// FTS5 not available — fall through to in-memory lexical
|
|
623
|
+
}
|
|
624
|
+
// Fallback: in-memory lexical if FTS5 returned nothing
|
|
625
|
+
if (lexicalCandidates.length === 0) {
|
|
626
|
+
let lexicalRecords = await loadLexical();
|
|
627
|
+
if (options.scopes && options.scopes.length > 0) {
|
|
628
|
+
const lexicalIds = lexicalRecords.map((r) => r.id);
|
|
629
|
+
const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), lexicalIds);
|
|
630
|
+
const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
|
|
631
|
+
lexicalRecords = lexicalRecords.filter((r) => {
|
|
632
|
+
const scopes = scopeMap.get(r.id) ?? [];
|
|
633
|
+
if (scopes.length === 0)
|
|
634
|
+
return false;
|
|
635
|
+
return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
// Topic prefix filtering for in-memory lexical (matches vectorSearch behavior)
|
|
639
|
+
if (options.topicPrefix) {
|
|
640
|
+
lexicalRecords = lexicalRecords.filter((r) => r.topic.startsWith(options.topicPrefix));
|
|
641
|
+
}
|
|
642
|
+
lexicalCandidates = buildLexicalCandidates(lexicalRecords, query);
|
|
643
|
+
if (subQueries.length > 1) {
|
|
644
|
+
for (const sq of subQueries.slice(1)) {
|
|
645
|
+
const subCandidates = buildLexicalCandidates(lexicalRecords, sq);
|
|
646
|
+
for (const c of subCandidates) {
|
|
647
|
+
if (!lexicalCandidates.some((existing) => existing.memory.id === c.memory.id)) {
|
|
648
|
+
lexicalCandidates.push(c);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
lexicalCandidates.sort((a, b) => b.score - a.score);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
} // end needsLexical
|
|
656
|
+
// RRF Fusion: combine vector and lexical/FTS5 results by reciprocal rank
|
|
657
|
+
// FTS5 BM25 gets 2x weight — it naturally demotes records where query terms
|
|
658
|
+
// appear only in passing (e.g. "wedding" mentioned once in a dating record),
|
|
659
|
+
// so weighting it higher filters topical noise better than equal weighting.
|
|
660
|
+
// Additionally, vector-only results (no lexical support) are penalized to
|
|
661
|
+
// reduce semantic-but-off-topic noise.
|
|
662
|
+
// Lexical-first fusion: FTS5 BM25 provides the primary ranking (topic relevance),
|
|
663
|
+
// vector similarity acts as a secondary boost (semantic depth).
|
|
664
|
+
// This prevents off-topic records that are semantically similar from ranking high.
|
|
665
|
+
const RRF_K = 60;
|
|
666
|
+
const rrfScores = new Map();
|
|
667
|
+
// Vector rank map for boosting lexical results
|
|
668
|
+
const vectorRankMap = new Map();
|
|
669
|
+
for (let i = 0; i < vectorMatched.length; i++) {
|
|
670
|
+
vectorRankMap.set(vectorMatched[i].id, i);
|
|
671
|
+
}
|
|
672
|
+
// Primary: lexical/FTS5 results with vector boost
|
|
673
|
+
for (let i = 0; i < lexicalCandidates.length; i++) {
|
|
674
|
+
const r = lexicalCandidates[i];
|
|
675
|
+
if (!options.includeHistory && EXCLUDED_STATUSES.has(r.memory.status))
|
|
676
|
+
continue;
|
|
677
|
+
const lexScore = 1 / (RRF_K + i + 1);
|
|
678
|
+
const vecRank = vectorRankMap.get(r.memory.id);
|
|
679
|
+
const vecBoost = vecRank !== undefined ? 0.2 * (1 / (RRF_K + vecRank + 1)) : 0;
|
|
680
|
+
rrfScores.set(r.memory.id, { record: r.memory, score: lexScore + vecBoost });
|
|
681
|
+
}
|
|
682
|
+
// Secondary: vector-only results (no lexical backing) get heavily discounted
|
|
683
|
+
for (let i = 0; i < vectorMatched.length; i++) {
|
|
684
|
+
const r = vectorMatched[i];
|
|
685
|
+
if (rrfScores.has(r.id))
|
|
686
|
+
continue; // Already included via lexical
|
|
687
|
+
const rrfScore = 0.15 * (1 / (RRF_K + i + 1));
|
|
688
|
+
rrfScores.set(r.id, { record: r, score: rrfScore });
|
|
689
|
+
}
|
|
690
|
+
const sortedRrf = Array.from(rrfScores.values()).sort((a, b) => b.score - a.score);
|
|
691
|
+
// Normalize RRF scores to 0-1 range so downstream consumers (threshold filters,
|
|
692
|
+
// similarity displays) get meaningful values. The raw RRF score for K=60 tops out
|
|
693
|
+
// around 0.033, which is misleading when compared against similarity thresholds.
|
|
694
|
+
const maxRrf = sortedRrf.length > 0 ? sortedRrf[0].score : 1;
|
|
695
|
+
matched = sortedRrf.map((entry) => ({
|
|
696
|
+
...entry.record,
|
|
697
|
+
confidence: maxRrf > 0 ? entry.score / maxRrf : 0,
|
|
698
|
+
}));
|
|
699
|
+
if (vectorMatched.length > 0 && lexicalCandidates.length > 0) {
|
|
700
|
+
retrievalSource = 'hybrid_rrf';
|
|
701
|
+
}
|
|
702
|
+
else if (vectorMatched.length > 0) {
|
|
703
|
+
retrievalSource = 'vector_search';
|
|
704
|
+
}
|
|
705
|
+
else if (lexicalCandidates.length > 0) {
|
|
706
|
+
retrievalSource = 'lexical_search';
|
|
707
|
+
}
|
|
708
|
+
// Enrich active records with summaries from their superseded predecessors.
|
|
709
|
+
// When ingestConversation extracts multiple facts under the same topic, only
|
|
710
|
+
// the last survives as "active" — the earlier ones become superseded and are
|
|
711
|
+
// excluded from search. This recovers their key information so it is not lost.
|
|
712
|
+
if (matched.length > 0) {
|
|
713
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
714
|
+
const stmtChain = adapter.prepare(`SELECT id, summary, decision FROM decisions WHERE superseded_by = ?`);
|
|
715
|
+
for (const record of matched) {
|
|
716
|
+
const predecessors = stmtChain.all(record.id);
|
|
717
|
+
if (predecessors.length > 0) {
|
|
718
|
+
const extra = predecessors
|
|
719
|
+
.map((p) => String(p.summary ?? p.decision ?? ''))
|
|
720
|
+
.filter(Boolean)
|
|
721
|
+
.join(' | ');
|
|
722
|
+
if (extra) {
|
|
723
|
+
record.details = record.details
|
|
724
|
+
? `${record.details}\n[Prior context] ${extra}`
|
|
725
|
+
: `[Prior context] ${extra}`;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
bundle.memories = matched;
|
|
731
|
+
bundle.graph_context.primary = matched;
|
|
732
|
+
bundle.graph_context.expanded = [];
|
|
733
|
+
bundle.graph_context.edges = [];
|
|
734
|
+
if (matched.length > 0 && !options.skipGraphExpansion) {
|
|
735
|
+
try {
|
|
736
|
+
const candidates = matched.map((m) => ({
|
|
737
|
+
id: m.id,
|
|
738
|
+
topic: m.topic,
|
|
739
|
+
decision: m.summary,
|
|
740
|
+
confidence: m.confidence,
|
|
741
|
+
created_at: m.created_at,
|
|
742
|
+
similarity: m.confidence ?? 0.5,
|
|
743
|
+
}));
|
|
744
|
+
const mamaApiModule = await import('../mama-api.js');
|
|
745
|
+
const mamaDefault = mamaApiModule.default;
|
|
746
|
+
const expanded = await mamaDefault.expandWithGraph(candidates);
|
|
747
|
+
const primaryIds = new Set(matched.map((m) => m.id));
|
|
748
|
+
let expandedOnly = expanded.filter((e) => !primaryIds.has(e.id));
|
|
749
|
+
// Re-filter expanded results: apply status and scope checks
|
|
750
|
+
if (!options.includeHistory) {
|
|
751
|
+
expandedOnly = expandedOnly.filter((e) => {
|
|
752
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
753
|
+
const row = adapter.prepare(`SELECT status FROM decisions WHERE id = ?`).get(e.id);
|
|
754
|
+
const status = row?.status || '';
|
|
755
|
+
return !status || !EXCLUDED_STATUSES.has(status);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
if (options.scopes && options.scopes.length > 0) {
|
|
759
|
+
const expandedIds = expandedOnly.map((e) => e.id);
|
|
760
|
+
const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), expandedIds);
|
|
761
|
+
const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
|
|
762
|
+
expandedOnly = expandedOnly.filter((e) => {
|
|
763
|
+
const scopes = scopeMap.get(e.id) ?? [];
|
|
764
|
+
if (scopes.length === 0)
|
|
765
|
+
return false;
|
|
766
|
+
return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
bundle.graph_context.expanded = expandedOnly.map((e) => ({
|
|
770
|
+
id: String(e.id),
|
|
771
|
+
topic: String(e.topic || ''),
|
|
772
|
+
kind: 'decision',
|
|
773
|
+
summary: String(e.decision || ''),
|
|
774
|
+
details: '',
|
|
775
|
+
confidence: e.graph_rank ?? 0.5,
|
|
776
|
+
status: 'active',
|
|
777
|
+
scopes: [],
|
|
778
|
+
source: {
|
|
779
|
+
package: 'mama-core',
|
|
780
|
+
source_type: String(e.graph_source || 'graph_expansion'),
|
|
781
|
+
},
|
|
782
|
+
created_at: e.created_at ?? Date.now(),
|
|
783
|
+
updated_at: e.created_at ?? Date.now(),
|
|
784
|
+
}));
|
|
785
|
+
const allIds = [...matched.map((m) => m.id), ...expandedOnly.map((e) => e.id)];
|
|
786
|
+
const allEdges = await loadEdgesForIds(allIds);
|
|
787
|
+
// Filter out edges pointing to decisions with excluded statuses
|
|
788
|
+
const activeIds = new Set(allIds);
|
|
789
|
+
const edgesToCheck = allEdges.filter((e) => !activeIds.has(e.to_id) || !activeIds.has(e.from_id));
|
|
790
|
+
if (edgesToCheck.length > 0) {
|
|
791
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
792
|
+
const checkIds = [
|
|
793
|
+
...new Set(edgesToCheck.flatMap((e) => [e.from_id, e.to_id]).filter((id) => !activeIds.has(id))),
|
|
794
|
+
];
|
|
795
|
+
const placeholders = checkIds.map(() => '?').join(', ');
|
|
796
|
+
const statusRows = adapter
|
|
797
|
+
.prepare(`SELECT id, status FROM decisions WHERE id IN (${placeholders})`)
|
|
798
|
+
.all(...checkIds);
|
|
799
|
+
const excludedIds = new Set(statusRows.filter((r) => r.status && EXCLUDED_STATUSES.has(r.status)).map((r) => r.id));
|
|
800
|
+
bundle.graph_context.edges = allEdges.filter((e) => !excludedIds.has(e.from_id) && !excludedIds.has(e.to_id));
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
bundle.graph_context.edges = allEdges;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
// Graph expansion is best-effort; do not fail recall
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
bundle.search_meta.scope_order = (options.scopes ?? []).map((scope) => scope.kind);
|
|
811
|
+
bundle.search_meta.retrieval_sources = [retrievalSource];
|
|
812
|
+
if (options.includeProfile) {
|
|
813
|
+
bundle.profile = await buildProfile(options.scopes ?? []);
|
|
814
|
+
}
|
|
815
|
+
return bundle;
|
|
816
|
+
}
|
|
817
|
+
async function ingestMemory(input) {
|
|
818
|
+
const normalized = input.content.trim();
|
|
819
|
+
return saveMemory({
|
|
820
|
+
topic: normalized
|
|
821
|
+
.slice(0, 40)
|
|
822
|
+
.toLowerCase()
|
|
823
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
824
|
+
.replace(/^_+|_+$/g, '') || 'ingested_memory',
|
|
825
|
+
kind: 'fact',
|
|
826
|
+
summary: normalized.slice(0, 200),
|
|
827
|
+
details: normalized,
|
|
828
|
+
scopes: input.scopes ?? [],
|
|
829
|
+
source: input.source,
|
|
830
|
+
eventDate: input.eventDate,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
async function evolveMemory(input) {
|
|
834
|
+
return (0, evolution_engine_js_1.resolveMemoryEvolution)(input);
|
|
835
|
+
}
|
|
836
|
+
async function buildMemoryBootstrap(params) {
|
|
837
|
+
return (0, bootstrap_builder_js_1.buildMemoryAgentBootstrap)(params);
|
|
838
|
+
}
|
|
839
|
+
function createAuditAck(input) {
|
|
840
|
+
return (0, types_js_1.createMemoryAuditAck)(input);
|
|
841
|
+
}
|
|
842
|
+
async function recordMemoryAudit(input) {
|
|
843
|
+
return (0, channel_summary_state_store_js_1.recordChannelAudit)(input);
|
|
844
|
+
}
|
|
845
|
+
async function callExtractionLLM(prompt, options) {
|
|
846
|
+
const model = options.model ?? 'claude-sonnet-4-6';
|
|
847
|
+
const baseUrl = options.baseUrl ?? 'https://api.anthropic.com';
|
|
848
|
+
// Security: only send ANTHROPIC_API_KEY to Anthropic's own domain
|
|
849
|
+
const isAnthropicDomain = /^https?:\/\/([^/]*\.)?anthropic\.com(\/|$)/i.test(baseUrl);
|
|
850
|
+
const apiKey = isAnthropicDomain
|
|
851
|
+
? (options.apiKey ?? process.env.ANTHROPIC_API_KEY)
|
|
852
|
+
: options.apiKey; // custom baseUrl must supply its own key explicitly
|
|
853
|
+
if (!apiKey) {
|
|
854
|
+
throw new Error(isAnthropicDomain
|
|
855
|
+
? 'ANTHROPIC_API_KEY is required for extraction'
|
|
856
|
+
: 'apiKey must be provided explicitly when using a custom baseUrl');
|
|
857
|
+
}
|
|
858
|
+
const res = await fetch(`${baseUrl}/v1/messages`, {
|
|
859
|
+
method: 'POST',
|
|
860
|
+
headers: {
|
|
861
|
+
'Content-Type': 'application/json',
|
|
862
|
+
'x-api-key': apiKey,
|
|
863
|
+
'anthropic-version': '2023-06-01',
|
|
864
|
+
},
|
|
865
|
+
body: JSON.stringify({
|
|
866
|
+
model,
|
|
867
|
+
max_tokens: 4096,
|
|
868
|
+
messages: [{ role: 'user', content: prompt }],
|
|
869
|
+
}),
|
|
870
|
+
});
|
|
871
|
+
if (!res.ok) {
|
|
872
|
+
const errorBody = await res.text().catch(() => 'unknown');
|
|
873
|
+
throw new Error(`Anthropic API error ${res.status}: ${errorBody}`);
|
|
874
|
+
}
|
|
875
|
+
const data = (await res.json());
|
|
876
|
+
const text = data.content?.find((c) => c.type === 'text')?.text ?? '';
|
|
877
|
+
return (0, extraction_prompt_js_1.parseExtractionResponse)(text);
|
|
878
|
+
}
|
|
879
|
+
let extractionFn = callExtractionLLM;
|
|
880
|
+
function setExtractionFn(fn) {
|
|
881
|
+
extractionFn = fn ?? callExtractionLLM;
|
|
882
|
+
}
|
|
883
|
+
async function ingestConversation(input) {
|
|
884
|
+
if (!input.messages || input.messages.length === 0) {
|
|
885
|
+
throw new Error('messages array must not be empty');
|
|
886
|
+
}
|
|
887
|
+
const conversationText = input.messages.map((m) => `${m.role}: ${m.content}`).join('\n');
|
|
888
|
+
const topicPrefix = input.topicPrefix || '';
|
|
889
|
+
const rawResult = await ingestMemory({
|
|
890
|
+
content: topicPrefix ? `${topicPrefix}${conversationText}` : conversationText,
|
|
891
|
+
scopes: input.scopes,
|
|
892
|
+
source: input.source,
|
|
893
|
+
eventDate: input.sessionDate,
|
|
894
|
+
});
|
|
895
|
+
const result = {
|
|
896
|
+
rawId: rawResult.id,
|
|
897
|
+
extractedMemories: [],
|
|
898
|
+
};
|
|
899
|
+
if (!input.extract?.enabled) {
|
|
900
|
+
return result;
|
|
901
|
+
}
|
|
902
|
+
let units;
|
|
903
|
+
try {
|
|
904
|
+
// Fetch existing topics so LLM can reuse them (enables supersedes edges)
|
|
905
|
+
await (0, db_manager_js_1.initDB)();
|
|
906
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
907
|
+
let existingTopics;
|
|
908
|
+
if (input.scopes && input.scopes.length > 0) {
|
|
909
|
+
const scopeIds = await Promise.all(input.scopes.map((scope) => (0, db_manager_js_1.ensureMemoryScope)(scope.kind, scope.id)));
|
|
910
|
+
const placeholders = scopeIds.map(() => '?').join(', ');
|
|
911
|
+
existingTopics = adapter
|
|
912
|
+
.prepare(`SELECT DISTINCT d.topic FROM decisions d
|
|
913
|
+
JOIN memory_scope_bindings msb ON msb.memory_id = d.id
|
|
914
|
+
WHERE msb.scope_id IN (${placeholders})
|
|
915
|
+
AND (d.status = 'active' OR d.status IS NULL)
|
|
916
|
+
ORDER BY d.created_at DESC LIMIT 200`)
|
|
917
|
+
.all(...scopeIds);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
existingTopics = adapter
|
|
921
|
+
.prepare(`SELECT DISTINCT topic FROM decisions
|
|
922
|
+
WHERE (status = 'active' OR status IS NULL)
|
|
923
|
+
ORDER BY created_at DESC LIMIT 200`)
|
|
924
|
+
.all();
|
|
925
|
+
}
|
|
926
|
+
const topicList = existingTopics.map((r) => r.topic);
|
|
927
|
+
const prompt = (0, extraction_prompt_js_1.buildExtractionPrompt)(input.messages, topicList);
|
|
928
|
+
units = await extractionFn(prompt, input.extract);
|
|
929
|
+
}
|
|
930
|
+
catch (err) {
|
|
931
|
+
(0, debug_logger_js_1.warn)(`[memory] extraction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
932
|
+
return result;
|
|
933
|
+
}
|
|
934
|
+
const adapter = (0, db_manager_js_1.getAdapter)();
|
|
935
|
+
const EXTRACTION_EDGE_RELATIONSHIP = 'builds_on';
|
|
936
|
+
const EXTRACTION_EDGE_REASON = 'Extracted from conversation';
|
|
937
|
+
// Track IDs saved in this batch so sibling facts don't supersede each other.
|
|
938
|
+
// Without this, when ingestConversation extracts multiple facts under the same topic,
|
|
939
|
+
// each subsequent save supersedes the previous one, losing independent information.
|
|
940
|
+
const batchSavedIds = [];
|
|
941
|
+
for (const unit of units) {
|
|
942
|
+
try {
|
|
943
|
+
const saved = await saveMemory({
|
|
944
|
+
topic: topicPrefix ? `${topicPrefix}${unit.topic}` : unit.topic,
|
|
945
|
+
kind: unit.kind,
|
|
946
|
+
summary: unit.summary,
|
|
947
|
+
details: unit.details,
|
|
948
|
+
confidence: unit.confidence,
|
|
949
|
+
scopes: input.scopes,
|
|
950
|
+
source: input.source,
|
|
951
|
+
excludeIds: batchSavedIds,
|
|
952
|
+
eventDate: input.sessionDate,
|
|
953
|
+
});
|
|
954
|
+
const now = Date.now();
|
|
955
|
+
adapter
|
|
956
|
+
.prepare(`INSERT OR REPLACE INTO decision_edges (from_id, to_id, relationship, reason, weight, created_at)
|
|
957
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
958
|
+
.run(saved.id, rawResult.id, EXTRACTION_EDGE_RELATIONSHIP, EXTRACTION_EDGE_REASON, 1.0, now);
|
|
959
|
+
batchSavedIds.push(saved.id);
|
|
960
|
+
result.extractedMemories.push({
|
|
961
|
+
id: saved.id,
|
|
962
|
+
kind: unit.kind,
|
|
963
|
+
topic: unit.topic,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
(0, debug_logger_js_1.warn)(`[memory] failed to save extracted unit topic=${unit.topic} kind=${unit.kind}: ${err instanceof Error ? err.message : String(err)}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return result;
|
|
971
|
+
}
|
|
972
|
+
//# sourceMappingURL=api.js.map
|