@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.
Files changed (50) hide show
  1. package/README.md +67 -19
  2. package/db/migrations/001-initial-decision-graph.sql +5 -0
  3. package/db/migrations/014-add-is-static-column.sql +5 -0
  4. package/db/migrations/015-add-fts5-search.sql +34 -0
  5. package/db/migrations/016-add-memory-v2-columns.sql +14 -0
  6. package/db/migrations/017-create-memory-scopes.sql +10 -0
  7. package/db/migrations/018-create-memory-scope-bindings.sql +15 -0
  8. package/db/migrations/019-create-memory-events.sql +18 -0
  9. package/db/migrations/020-create-audit-findings.sql +18 -0
  10. package/db/migrations/021-create-memory-truth.sql +20 -0
  11. package/db/migrations/022-create-channel-summaries.sql +9 -0
  12. package/db/migrations/023-create-channel-summary-state.sql +9 -0
  13. package/db/migrations/024-add-event-date.sql +9 -0
  14. package/dist/db-adapter/base-adapter.d.ts +1 -1
  15. package/dist/db-adapter/index.js +2 -8
  16. package/dist/db-adapter/node-sqlite-adapter.d.ts +6 -1
  17. package/dist/db-adapter/node-sqlite-adapter.js +99 -45
  18. package/dist/db-manager.d.ts +30 -2
  19. package/dist/db-manager.js +131 -6
  20. package/dist/decision-formatter.d.ts +2 -0
  21. package/dist/embedding-server/mobile/session-manager.js +22 -7
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.js +31 -3
  24. package/dist/mama-api.d.ts +67 -2
  25. package/dist/mama-api.js +295 -80
  26. package/dist/memory/api.d.ts +81 -0
  27. package/dist/memory/api.js +972 -0
  28. package/dist/memory/bootstrap-builder.d.ts +10 -0
  29. package/dist/memory/bootstrap-builder.js +66 -0
  30. package/dist/memory/channel-summary-state-store.d.ts +19 -0
  31. package/dist/memory/channel-summary-state-store.js +223 -0
  32. package/dist/memory/channel-summary-store.d.ts +8 -0
  33. package/dist/memory/channel-summary-store.js +41 -0
  34. package/dist/memory/event-store.d.ts +5 -0
  35. package/dist/memory/event-store.js +72 -0
  36. package/dist/memory/evolution-engine.d.ts +22 -0
  37. package/dist/memory/evolution-engine.js +133 -0
  38. package/dist/memory/extraction-prompt.d.ts +4 -0
  39. package/dist/memory/extraction-prompt.js +89 -0
  40. package/dist/memory/finding-store.d.ts +5 -0
  41. package/dist/memory/finding-store.js +75 -0
  42. package/dist/memory/profile-builder.d.ts +3 -0
  43. package/dist/memory/profile-builder.js +23 -0
  44. package/dist/memory/scope-store.d.ts +5 -0
  45. package/dist/memory/scope-store.js +13 -0
  46. package/dist/memory/truth-store.d.ts +11 -0
  47. package/dist/memory/truth-store.js +102 -0
  48. package/dist/memory/types.d.ts +267 -0
  49. package/dist/memory/types.js +74 -0
  50. 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