@jungjaehoon/mama-core 1.4.2 → 1.6.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 (181) hide show
  1. package/README.md +48 -10
  2. package/db/migrations/026-create-canonical-entity-tables.sql +99 -0
  3. package/db/migrations/027-create-entity-review-support.sql +44 -0
  4. package/db/migrations/028-create-entity-audit-tables.sql +45 -0
  5. package/db/migrations/029-extend-entity-observations-observation-type.sql +60 -0
  6. package/db/migrations/030-case-first-memory-substrate.sql +901 -0
  7. package/db/migrations/031-wiki-page-index-provenance-columns.sql +2 -0
  8. package/db/migrations/032-add-memory-provenance-columns.sql +21 -0
  9. package/db/migrations/033-create-model-runs-and-tool-traces.sql +53 -0
  10. package/db/migrations/034-add-connector-event-scope-columns.sql +14 -0
  11. package/db/migrations/035-create-twin-edges.sql +63 -0
  12. package/db/migrations/036-create-agent-situation-packets.sql +61 -0
  13. package/dist/agent-graph/alias-write.d.ts +3 -0
  14. package/dist/agent-graph/alias-write.js +231 -0
  15. package/dist/agent-graph/entity-resolve.d.ts +3 -0
  16. package/dist/agent-graph/entity-resolve.js +275 -0
  17. package/dist/agent-graph/errors.d.ts +4 -0
  18. package/dist/agent-graph/errors.js +11 -0
  19. package/dist/agent-graph/graph-query.d.ts +5 -0
  20. package/dist/agent-graph/graph-query.js +359 -0
  21. package/dist/agent-graph/index.d.ts +6 -0
  22. package/dist/agent-graph/index.js +22 -0
  23. package/dist/agent-graph/types.d.ts +162 -0
  24. package/dist/agent-graph/types.js +3 -0
  25. package/dist/agent-situation/builder.d.ts +8 -0
  26. package/dist/agent-situation/builder.js +239 -0
  27. package/dist/agent-situation/cache-key.d.ts +4 -0
  28. package/dist/agent-situation/cache-key.js +64 -0
  29. package/dist/agent-situation/index.d.ts +7 -0
  30. package/dist/agent-situation/index.js +28 -0
  31. package/dist/agent-situation/packet-store.d.ts +27 -0
  32. package/dist/agent-situation/packet-store.js +228 -0
  33. package/dist/agent-situation/ranking-policy.d.ts +18 -0
  34. package/dist/agent-situation/ranking-policy.js +75 -0
  35. package/dist/agent-situation/source-readers.d.ts +52 -0
  36. package/dist/agent-situation/source-readers.js +300 -0
  37. package/dist/agent-situation/types.d.ts +195 -0
  38. package/dist/agent-situation/types.js +3 -0
  39. package/dist/canonicalize.d.ts +38 -0
  40. package/dist/canonicalize.js +106 -0
  41. package/dist/cases/case-links.d.ts +116 -0
  42. package/dist/cases/case-links.js +794 -0
  43. package/dist/cases/composition-overrides.d.ts +90 -0
  44. package/dist/cases/composition-overrides.js +724 -0
  45. package/dist/cases/corrections.d.ts +156 -0
  46. package/dist/cases/corrections.js +1045 -0
  47. package/dist/cases/freshness.d.ts +57 -0
  48. package/dist/cases/freshness.js +283 -0
  49. package/dist/cases/live-state.d.ts +48 -0
  50. package/dist/cases/live-state.js +256 -0
  51. package/dist/cases/membership-explain.d.ts +56 -0
  52. package/dist/cases/membership-explain.js +140 -0
  53. package/dist/cases/membership-matcher.d.ts +23 -0
  54. package/dist/cases/membership-matcher.js +193 -0
  55. package/dist/cases/merge-split.d.ts +61 -0
  56. package/dist/cases/merge-split.js +368 -0
  57. package/dist/cases/role-inference.d.ts +10 -0
  58. package/dist/cases/role-inference.js +85 -0
  59. package/dist/cases/search-rollup.d.ts +27 -0
  60. package/dist/cases/search-rollup.js +309 -0
  61. package/dist/cases/sqlite-transaction.d.ts +10 -0
  62. package/dist/cases/sqlite-transaction.js +62 -0
  63. package/dist/cases/store.d.ts +74 -0
  64. package/dist/cases/store.js +816 -0
  65. package/dist/cases/target-ref.d.ts +25 -0
  66. package/dist/cases/target-ref.js +22 -0
  67. package/dist/cases/timeline-range.d.ts +6 -0
  68. package/dist/cases/timeline-range.js +322 -0
  69. package/dist/cases/tombstone-sweeper.d.ts +13 -0
  70. package/dist/cases/tombstone-sweeper.js +128 -0
  71. package/dist/cases/types.d.ts +244 -0
  72. package/dist/cases/types.js +47 -0
  73. package/dist/cases/wiki-page-index.d.ts +50 -0
  74. package/dist/cases/wiki-page-index.js +350 -0
  75. package/dist/connectors/event-index.d.ts +45 -0
  76. package/dist/connectors/event-index.js +381 -0
  77. package/dist/connectors/raw-query.d.ts +18 -0
  78. package/dist/connectors/raw-query.js +305 -0
  79. package/dist/connectors/types.d.ts +109 -0
  80. package/dist/connectors/types.js +3 -0
  81. package/dist/db-adapter/node-sqlite-adapter.d.ts +6 -0
  82. package/dist/db-adapter/node-sqlite-adapter.js +131 -0
  83. package/dist/db-manager.d.ts +12 -1
  84. package/dist/db-manager.js +60 -5
  85. package/dist/decision-formatter.d.ts +2 -0
  86. package/dist/decision-formatter.js +11 -8
  87. package/dist/edges/ref-validation.d.ts +8 -0
  88. package/dist/edges/ref-validation.js +262 -0
  89. package/dist/edges/store.d.ts +10 -0
  90. package/dist/edges/store.js +343 -0
  91. package/dist/edges/types.d.ts +86 -0
  92. package/dist/edges/types.js +18 -0
  93. package/dist/embedding-server/mobile/daemon.d.ts +1 -0
  94. package/dist/embedding-server/mobile/daemon.js +11 -1
  95. package/dist/embeddings.js +13 -0
  96. package/dist/entities/audit-metrics.d.ts +73 -0
  97. package/dist/entities/audit-metrics.js +128 -0
  98. package/dist/entities/candidate-generator.d.ts +9 -0
  99. package/dist/entities/candidate-generator.js +436 -0
  100. package/dist/entities/entity-impact.d.ts +41 -0
  101. package/dist/entities/entity-impact.js +140 -0
  102. package/dist/entities/entity-linked-decision-counts.d.ts +2 -0
  103. package/dist/entities/entity-linked-decision-counts.js +29 -0
  104. package/dist/entities/entity-list.d.ts +23 -0
  105. package/dist/entities/entity-list.js +99 -0
  106. package/dist/entities/entity-orphan-list.d.ts +26 -0
  107. package/dist/entities/entity-orphan-list.js +91 -0
  108. package/dist/entities/entity-search.d.ts +21 -0
  109. package/dist/entities/entity-search.js +150 -0
  110. package/dist/entities/errors.d.ts +65 -0
  111. package/dist/entities/errors.js +124 -0
  112. package/dist/entities/exact-merge-backfill.d.ts +11 -0
  113. package/dist/entities/exact-merge-backfill.js +161 -0
  114. package/dist/entities/lineage-backfill.d.ts +12 -0
  115. package/dist/entities/lineage-backfill.js +101 -0
  116. package/dist/entities/lineage-store.d.ts +51 -0
  117. package/dist/entities/lineage-store.js +308 -0
  118. package/dist/entities/normalization.d.ts +14 -0
  119. package/dist/entities/normalization.js +63 -0
  120. package/dist/entities/policy-store.d.ts +17 -0
  121. package/dist/entities/policy-store.js +263 -0
  122. package/dist/entities/policy-types.d.ts +72 -0
  123. package/dist/entities/policy-types.js +59 -0
  124. package/dist/entities/projection.d.ts +8 -0
  125. package/dist/entities/projection.js +82 -0
  126. package/dist/entities/provenance-query.d.ts +33 -0
  127. package/dist/entities/provenance-query.js +167 -0
  128. package/dist/entities/read-identity.d.ts +31 -0
  129. package/dist/entities/read-identity.js +264 -0
  130. package/dist/entities/recall-bridge.d.ts +5 -0
  131. package/dist/entities/recall-bridge.js +171 -0
  132. package/dist/entities/resolution-engine.d.ts +15 -0
  133. package/dist/entities/resolution-engine.js +69 -0
  134. package/dist/entities/rollback-preview.d.ts +39 -0
  135. package/dist/entities/rollback-preview.js +235 -0
  136. package/dist/entities/source-locator.d.ts +3 -0
  137. package/dist/entities/source-locator.js +32 -0
  138. package/dist/entities/store.d.ts +75 -0
  139. package/dist/entities/store.js +465 -0
  140. package/dist/entities/types.d.ts +162 -0
  141. package/dist/entities/types.js +30 -0
  142. package/dist/index.d.ts +62 -3
  143. package/dist/index.js +116 -26
  144. package/dist/mama-api.d.ts +53 -35
  145. package/dist/mama-api.js +437 -78
  146. package/dist/memory/api.d.ts +26 -38
  147. package/dist/memory/api.js +772 -68
  148. package/dist/memory/event-store.d.ts +3 -0
  149. package/dist/memory/event-store.js +21 -2
  150. package/dist/memory/provenance-audit.d.ts +22 -0
  151. package/dist/memory/provenance-audit.js +112 -0
  152. package/dist/memory/provenance-query.d.ts +11 -0
  153. package/dist/memory/provenance-query.js +193 -0
  154. package/dist/memory/provenance.d.ts +29 -0
  155. package/dist/memory/provenance.js +136 -0
  156. package/dist/memory/scope-backfill.d.ts +17 -0
  157. package/dist/memory/scope-backfill.js +77 -0
  158. package/dist/memory/types.d.ts +97 -16
  159. package/dist/model-runs/store.d.ts +13 -0
  160. package/dist/model-runs/store.js +457 -0
  161. package/dist/model-runs/tool-trace-store.d.ts +4 -0
  162. package/dist/model-runs/tool-trace-store.js +114 -0
  163. package/dist/model-runs/types.d.ts +69 -0
  164. package/dist/model-runs/types.js +5 -0
  165. package/dist/runtime/trusted-provenance.d.ts +3 -0
  166. package/dist/runtime/trusted-provenance.js +6 -0
  167. package/dist/search/feedback-store.d.ts +59 -0
  168. package/dist/search/feedback-store.js +211 -0
  169. package/dist/search/question-type.d.ts +5 -0
  170. package/dist/search/question-type.js +41 -0
  171. package/dist/search/ranker-features.d.ts +23 -0
  172. package/dist/search/ranker-features.js +104 -0
  173. package/dist/search/ranker-rescore.d.ts +31 -0
  174. package/dist/search/ranker-rescore.js +226 -0
  175. package/dist/search/ranker-trainer.d.ts +94 -0
  176. package/dist/search/ranker-trainer.js +860 -0
  177. package/dist/search/search-quality.d.ts +36 -0
  178. package/dist/search/search-quality.js +29 -0
  179. package/dist/test-utils.d.ts +14 -0
  180. package/dist/test-utils.js +52 -0
  181. package/package.json +28 -3
@@ -7,18 +7,22 @@ exports.getChannelSummary = exports.upsertChannelSummary = exports.LEXICAL_STOPW
7
7
  exports.getLexicalQueryTokens = getLexicalQueryTokens;
8
8
  exports.loadEdgesForIds = loadEdgesForIds;
9
9
  exports.saveMemory = saveMemory;
10
+ exports.saveMemoryWithTrustedProvenance = saveMemoryWithTrustedProvenance;
10
11
  exports.buildProfile = buildProfile;
11
12
  exports.recallMemory = recallMemory;
12
13
  exports.ingestMemory = ingestMemory;
14
+ exports.ingestWithTrustedProvenance = ingestWithTrustedProvenance;
13
15
  exports.evolveMemory = evolveMemory;
14
16
  exports.buildMemoryBootstrap = buildMemoryBootstrap;
15
17
  exports.createAuditAck = createAuditAck;
16
18
  exports.recordMemoryAudit = recordMemoryAudit;
17
19
  exports.setExtractionFn = setExtractionFn;
18
20
  exports.ingestConversation = ingestConversation;
21
+ exports.ingestConversationWithTrustedProvenance = ingestConversationWithTrustedProvenance;
19
22
  const node_crypto_1 = __importDefault(require("node:crypto"));
20
23
  const db_manager_js_1 = require("../db-manager.js");
21
24
  const embeddings_js_1 = require("../embeddings.js");
25
+ const wiki_page_index_js_1 = require("../cases/wiki-page-index.js");
22
26
  const profile_builder_js_1 = require("./profile-builder.js");
23
27
  const bootstrap_builder_js_1 = require("./bootstrap-builder.js");
24
28
  const evolution_engine_js_1 = require("./evolution-engine.js");
@@ -30,10 +34,25 @@ const types_js_1 = require("./types.js");
30
34
  const channel_summary_store_js_1 = require("./channel-summary-store.js");
31
35
  Object.defineProperty(exports, "getChannelSummary", { enumerable: true, get: function () { return channel_summary_store_js_1.getChannelSummary; } });
32
36
  Object.defineProperty(exports, "upsertChannelSummary", { enumerable: true, get: function () { return channel_summary_store_js_1.upsertChannelSummary; } });
37
+ const recall_bridge_js_1 = require("../entities/recall-bridge.js");
38
+ const read_identity_js_1 = require("../entities/read-identity.js");
39
+ const search_quality_js_1 = require("../search/search-quality.js");
40
+ const event_store_js_1 = require("./event-store.js");
41
+ const provenance_js_1 = require("./provenance.js");
33
42
  function buildDecisionId(topic) {
34
43
  const safeTopic = topic.replace(/[^a-z0-9_]+/gi, '_').toLowerCase();
35
44
  return `decision_${safeTopic}_${Date.now()}_${node_crypto_1.default.randomUUID().slice(0, 8)}`;
36
45
  }
46
+ function buildSaveEventReason(toolName, gatewayCallId) {
47
+ const parts = ['saved'];
48
+ if (toolName) {
49
+ parts.push(`via ${toolName}`);
50
+ }
51
+ if (gatewayCallId) {
52
+ parts.push(`gateway_call_id=${gatewayCallId}`);
53
+ }
54
+ return parts.join(' ');
55
+ }
37
56
  function toMemoryRecord(row, scopes, fallbackSource) {
38
57
  let trustContext = null;
39
58
  if (typeof row.trust_context === 'string') {
@@ -58,8 +77,128 @@ function toMemoryRecord(row, scopes, fallbackSource) {
58
77
  created_at: row.created_at,
59
78
  updated_at: row.updated_at ?? row.created_at,
60
79
  event_date: row.event_date ?? null,
80
+ event_datetime: typeof row.event_datetime === 'number' && Number.isFinite(row.event_datetime)
81
+ ? row.event_datetime
82
+ : null,
83
+ };
84
+ }
85
+ function loadEventDateTimeForObservations(adapter, observationIds) {
86
+ const uniqueIds = Array.from(new Set(observationIds.filter(Boolean)));
87
+ if (uniqueIds.length === 0) {
88
+ return null;
89
+ }
90
+ const placeholders = uniqueIds.map(() => '?').join(', ');
91
+ const row = adapter
92
+ .prepare(`
93
+ SELECT MAX(COALESCE(timestamp_observed, created_at)) AS event_datetime
94
+ FROM entity_observations
95
+ WHERE id IN (${placeholders})
96
+ `)
97
+ .get(...uniqueIds);
98
+ return typeof row?.event_datetime === 'number' && Number.isFinite(row.event_datetime)
99
+ ? row.event_datetime
100
+ : null;
101
+ }
102
+ function getTimelineEntityKindPriority(kind) {
103
+ switch (kind) {
104
+ case 'work_item':
105
+ return 0;
106
+ case 'project':
107
+ return 1;
108
+ case 'organization':
109
+ return 2;
110
+ case 'person':
111
+ return 3;
112
+ default:
113
+ return 99;
114
+ }
115
+ }
116
+ function resolveTimelineTargetEntityIdFromObservations(adapter, observationIds) {
117
+ const uniqueIds = Array.from(new Set(observationIds.filter(Boolean)));
118
+ if (uniqueIds.length === 0) {
119
+ return null;
120
+ }
121
+ const placeholders = uniqueIds.map(() => '?').join(', ');
122
+ const rows = adapter
123
+ .prepare(`
124
+ SELECT
125
+ n.id AS entity_id,
126
+ n.kind AS entity_kind,
127
+ COUNT(*) AS matched_observations
128
+ FROM entity_lineage_links l
129
+ JOIN entity_nodes n
130
+ ON n.id = l.canonical_entity_id
131
+ WHERE l.entity_observation_id IN (${placeholders})
132
+ AND l.status = 'active'
133
+ AND n.status = 'active'
134
+ AND n.merged_into IS NULL
135
+ GROUP BY n.id, n.kind
136
+ `)
137
+ .all(...uniqueIds);
138
+ rows.sort((left, right) => {
139
+ const kindDelta = getTimelineEntityKindPriority(left.entity_kind) -
140
+ getTimelineEntityKindPriority(right.entity_kind);
141
+ if (kindDelta !== 0) {
142
+ return kindDelta;
143
+ }
144
+ if (right.matched_observations !== left.matched_observations) {
145
+ return right.matched_observations - left.matched_observations;
146
+ }
147
+ return left.entity_id.localeCompare(right.entity_id);
148
+ });
149
+ return rows[0]?.entity_id ?? null;
150
+ }
151
+ function buildTimelineEventForSave(adapter, memoryId, topic, entityObservationIds, timelineEvent) {
152
+ if (!timelineEvent) {
153
+ return null;
154
+ }
155
+ const resolvedEntityId = timelineEvent.entity_id ??
156
+ resolveTimelineTargetEntityIdFromObservations(adapter, entityObservationIds);
157
+ if (!resolvedEntityId) {
158
+ return null;
159
+ }
160
+ return {
161
+ id: timelineEvent.id ?? `et_${node_crypto_1.default.randomUUID()}`,
162
+ entity_id: resolvedEntityId,
163
+ event_type: timelineEvent.event_type,
164
+ role: timelineEvent.role ?? null,
165
+ valid_from: timelineEvent.valid_from ?? null,
166
+ valid_to: timelineEvent.valid_to ?? null,
167
+ observed_at: timelineEvent.observed_at ?? null,
168
+ source_ref: timelineEvent.source_ref ?? `decision:${memoryId}`,
169
+ summary: timelineEvent.summary,
170
+ details: timelineEvent.details ??
171
+ JSON.stringify({
172
+ memory_id: memoryId,
173
+ topic,
174
+ }),
61
175
  };
62
176
  }
177
+ function insertTimelineEventForSave(adapter, event, createdAt) {
178
+ adapter
179
+ .prepare(`
180
+ INSERT INTO entity_timeline_events (
181
+ id, entity_id, event_type, role, valid_from, valid_to, observed_at,
182
+ source_ref, summary, details, created_at
183
+ )
184
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
185
+ `)
186
+ .run(event.id, event.entity_id, event.event_type, event.role, event.valid_from, event.valid_to, event.observed_at, event.source_ref, event.summary, event.details, createdAt);
187
+ }
188
+ function cleanupFailedMemorySave(adapter, memoryId) {
189
+ const row = adapter.prepare(`SELECT rowid FROM decisions WHERE id = ?`).get(memoryId);
190
+ if (!row) {
191
+ return;
192
+ }
193
+ const cleanup = () => {
194
+ adapter.prepare(`DELETE FROM embeddings WHERE rowid = ?`).run(row.rowid);
195
+ adapter.prepare(`DELETE FROM decisions WHERE id = ?`).run(memoryId);
196
+ };
197
+ const txResult = adapter.transaction(cleanup);
198
+ if (typeof txResult === 'function') {
199
+ txResult();
200
+ }
201
+ }
63
202
  function batchLoadScopes(adapter, memoryIds) {
64
203
  const scopeMap = new Map();
65
204
  if (memoryIds.length === 0)
@@ -90,9 +229,9 @@ async function loadScopedMemories(scopes) {
90
229
  rows = adapter
91
230
  .prepare(`
92
231
  SELECT id, topic, decision, reasoning, confidence, created_at, updated_at, trust_context,
93
- kind, status, summary, event_date
232
+ kind, status, summary, event_date, event_datetime
94
233
  FROM decisions
95
- ORDER BY created_at DESC
234
+ ORDER BY COALESCE(event_datetime, created_at) DESC, created_at DESC
96
235
  `)
97
236
  .all();
98
237
  }
@@ -102,11 +241,11 @@ async function loadScopedMemories(scopes) {
102
241
  rows = adapter
103
242
  .prepare(`
104
243
  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
244
+ d.updated_at, d.trust_context, d.kind, d.status, d.summary, d.event_date, d.event_datetime
106
245
  FROM decisions d
107
246
  JOIN memory_scope_bindings msb ON msb.memory_id = d.id
108
247
  WHERE msb.scope_id IN (${placeholders})
109
- ORDER BY d.created_at DESC
248
+ ORDER BY COALESCE(d.event_datetime, d.created_at) DESC, d.created_at DESC
110
249
  `)
111
250
  .all(...scopeIds);
112
251
  }
@@ -181,6 +320,19 @@ function getLexicalQueryTokens(query) {
181
320
  .split(/[\s,.!?;:()[\]{}"']+/)
182
321
  .filter((token) => token.length > 2 && !exports.LEXICAL_STOPWORDS.has(token));
183
322
  }
323
+ function queryTokenCount(query) {
324
+ const lexicalTokens = getLexicalQueryTokens(query);
325
+ if (lexicalTokens.length > 0) {
326
+ return lexicalTokens.length;
327
+ }
328
+ return query.trim().split(/\s+/).filter(Boolean).length;
329
+ }
330
+ function looksMixedKoreanEnglish(query) {
331
+ return /[\uac00-\ud7a3]/.test(query) && /[a-z]/i.test(query);
332
+ }
333
+ function looksEntityLike(query) {
334
+ return /\b[A-Z][A-Za-z0-9_-]{2,}\b/.test(query) || /[#/][A-Za-z0-9_-]{2,}/.test(query);
335
+ }
184
336
  function buildLexicalCandidates(records, query) {
185
337
  const tokens = getLexicalQueryTokens(query);
186
338
  const normalizedQuery = query.toLowerCase();
@@ -284,11 +436,12 @@ async function loadEdgesForIds(ids) {
284
436
  reason: row.reason ?? undefined,
285
437
  }));
286
438
  }
287
- async function saveMemory(input) {
439
+ async function saveMemoryInternal(input, options) {
288
440
  await (0, db_manager_js_1.initDB)();
289
441
  const adapter = (0, db_manager_js_1.getAdapter)();
290
442
  const id = buildDecisionId(input.topic);
291
443
  const now = Date.now();
444
+ const provenance = (0, provenance_js_1.normalizeMemoryWriteProvenance)(options);
292
445
  // Find evolution candidates: exact topic match first, then semantic search fallback
293
446
  const primaryScope = input.scopes.length > 0 ? input.scopes[0] : null;
294
447
  const excludeIds = new Set(input.excludeIds ?? []);
@@ -364,6 +517,11 @@ async function saveMemory(input) {
364
517
  })),
365
518
  });
366
519
  const supersedesTarget = evolution.edges.find((edge) => edge.type === 'supersedes')?.to_id ?? null;
520
+ const entityObservationIds = Array.from(new Set(input.entityObservationIds ?? []));
521
+ const eventDateTime = typeof input.eventDateTime === 'number'
522
+ ? input.eventDateTime
523
+ : loadEventDateTimeForObservations(adapter, entityObservationIds);
524
+ const timelineEvent = buildTimelineEventForSave(adapter, id, input.topic, entityObservationIds, input.timelineEvent);
367
525
  await (0, db_manager_js_1.insertDecisionWithEmbedding)({
368
526
  id,
369
527
  topic: input.topic,
@@ -375,6 +533,13 @@ async function saveMemory(input) {
375
533
  updated_at: now,
376
534
  trust_context: JSON.stringify({ source: input.source }),
377
535
  event_date: input.eventDate ?? null,
536
+ event_datetime: eventDateTime,
537
+ agent_id: provenance.agent_id,
538
+ model_run_id: provenance.model_run_id,
539
+ envelope_hash: provenance.envelope_hash,
540
+ gateway_call_id: provenance.gateway_call_id,
541
+ source_refs_json: JSON.stringify(provenance.source_refs),
542
+ provenance_json: JSON.stringify(provenance.provenance),
378
543
  });
379
544
  // Pre-resolve scope IDs before the synchronous transaction
380
545
  const resolvedScopeIds = [];
@@ -383,36 +548,67 @@ async function saveMemory(input) {
383
548
  resolvedScopeIds.push({ scopeId, isPrimary: index === 0 });
384
549
  }
385
550
  // 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) {
551
+ try {
552
+ adapter.transaction(() => {
395
553
  adapter
396
554
  .prepare(`
397
- INSERT OR REPLACE INTO memory_scope_bindings (memory_id, scope_id, is_primary)
398
- VALUES (?, ?, ?)
555
+ UPDATE decisions
556
+ SET kind = ?, status = ?, summary = ?, is_static = ?, trust_context = ?, updated_at = ?
557
+ WHERE id = ?
399
558
  `)
400
- .run(id, scopeId, isPrimary ? 1 : 0);
401
- }
402
- for (const edge of evolution.edges) {
403
- if (edge.type === 'supersedes') {
559
+ .run(input.kind, input.status ?? 'active', input.summary, input.kind === 'preference' || input.kind === 'constraint' ? 1 : 0, JSON.stringify({ source: input.source }), now, id);
560
+ for (const { scopeId, isPrimary } of resolvedScopeIds) {
404
561
  adapter
405
- .prepare(`UPDATE decisions SET superseded_by = ?, status = 'superseded', updated_at = ? WHERE id = ?`)
406
- .run(id, now, edge.to_id);
562
+ .prepare(`
563
+ INSERT OR REPLACE INTO memory_scope_bindings (memory_id, scope_id, is_primary)
564
+ VALUES (?, ?, ?)
565
+ `)
566
+ .run(id, scopeId, isPrimary ? 1 : 0);
407
567
  }
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
- });
568
+ (0, event_store_js_1.insertMemoryEventInTransaction)(adapter, {
569
+ event_type: 'save',
570
+ actor: provenance.actor,
571
+ source_turn_id: provenance.source_turn_id ?? undefined,
572
+ memory_id: id,
573
+ topic: input.topic,
574
+ scope_refs: input.scopes,
575
+ evidence_refs: provenance.source_refs,
576
+ reason: buildSaveEventReason(provenance.tool_name, provenance.gateway_call_id),
577
+ created_at: now,
578
+ });
579
+ for (const entityObservationId of entityObservationIds) {
580
+ adapter
581
+ .prepare(`
582
+ INSERT OR IGNORE INTO decision_entity_sources (
583
+ decision_id, entity_observation_id, relation_type, created_at
584
+ ) VALUES (?, ?, ?, ?)
585
+ `)
586
+ .run(id, entityObservationId, 'support', now);
587
+ }
588
+ if (timelineEvent) {
589
+ insertTimelineEventForSave(adapter, timelineEvent, now);
590
+ }
591
+ for (const edge of evolution.edges) {
592
+ if (edge.type === 'supersedes') {
593
+ adapter
594
+ .prepare(`UPDATE decisions SET superseded_by = ?, status = 'superseded', updated_at = ? WHERE id = ?`)
595
+ .run(id, now, edge.to_id);
596
+ }
597
+ adapter
598
+ .prepare(`
599
+ INSERT OR REPLACE INTO decision_edges (from_id, to_id, relationship, reason, weight, created_at)
600
+ VALUES (?, ?, ?, ?, ?, ?)
601
+ `)
602
+ .run(id, edge.to_id, edge.type, edge.reason ?? null, 1.0, now);
603
+ }
604
+ });
605
+ }
606
+ catch (error) {
607
+ cleanupFailedMemorySave(adapter, id);
608
+ const rollbackError = (error instanceof Error ? error : new Error(String(error)));
609
+ rollbackError.memoryId = id;
610
+ throw rollbackError;
611
+ }
416
612
  // Project to memory_truth table (best-effort; failure should not break save)
417
613
  try {
418
614
  await (0, truth_store_js_1.projectMemoryTruth)({
@@ -430,7 +626,19 @@ async function saveMemory(input) {
430
626
  catch {
431
627
  // Truth projection is best-effort; do not fail the save
432
628
  }
433
- return { success: true, id };
629
+ return {
630
+ success: true,
631
+ id,
632
+ saved_decision_id: id,
633
+ timeline_event_id: timelineEvent?.id ?? null,
634
+ timeline_event_ids: timelineEvent ? [timelineEvent.id] : [],
635
+ };
636
+ }
637
+ async function saveMemory(input) {
638
+ return saveMemoryInternal((0, provenance_js_1.sanitizePublicSaveMemoryInput)(input));
639
+ }
640
+ async function saveMemoryWithTrustedProvenance(input, options) {
641
+ return saveMemoryInternal((0, provenance_js_1.sanitizePublicSaveMemoryInput)(input), options);
434
642
  }
435
643
  async function buildProfile(scopes) {
436
644
  const records = await loadScopedMemories(scopes);
@@ -444,8 +652,26 @@ const EXCLUDED_STATUSES = new Set([
444
652
  ]);
445
653
  async function recallMemory(query, options = {}) {
446
654
  const bundle = (0, types_js_1.createEmptyRecallBundle)(query);
655
+ const searchOptions = (0, search_quality_js_1.normalizeSearchQualityOptions)(options);
656
+ const diagnostics = {
657
+ candidate_counts: {
658
+ vector: 0,
659
+ lexical: 0,
660
+ entity: 0,
661
+ graph_expanded: 0,
662
+ vector_only: 0,
663
+ rejected_by_strictness: 0,
664
+ },
665
+ threshold: searchOptions.threshold,
666
+ strictness: searchOptions.strictness,
667
+ };
447
668
  let matched = [];
669
+ let fusedHits = [];
448
670
  let retrievalSource = 'none';
671
+ const projectionMode = process.env.MAMA_ENTITY_PROJECTION_MODE ?? 'shadow';
672
+ const vectorSimilarityById = new Map();
673
+ const lexicalScoreById = new Map();
674
+ const entitySupportIds = new Set();
449
675
  let _lexicalRecords = null;
450
676
  const loadLexical = async () => {
451
677
  if (_lexicalRecords === null) {
@@ -458,6 +684,7 @@ async function recallMemory(query, options = {}) {
458
684
  const isAggregation = /\b(how many|how much|total|all|every|each|count|number of)\b/.test(lowerQuery);
459
685
  const hasMultipleEntities = /\b(and|or|vs|versus|compared|between)\b/.test(lowerQuery);
460
686
  const vectorLimit = isAggregation ? 50 : 20;
687
+ const requestedLimit = Math.max(1, Math.floor(options.limit ?? 10));
461
688
  // Multi-query decomposition: extract sub-queries for complex questions
462
689
  const subQueries = [query];
463
690
  if (hasMultipleEntities) {
@@ -474,17 +701,22 @@ async function recallMemory(query, options = {}) {
474
701
  await (0, db_manager_js_1.initDB)();
475
702
  // Channel 1: Vector search (semantic similarity) — run all sub-queries
476
703
  const vectorMatched = [];
704
+ let primaryQueryEmbedding = null;
477
705
  try {
478
706
  for (const sq of subQueries) {
479
707
  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);
708
+ if (sq === query && primaryQueryEmbedding === null) {
709
+ primaryQueryEmbedding = queryEmbedding;
710
+ }
711
+ const vectorResults = await (0, db_manager_js_1.vectorSearch)(queryEmbedding, vectorLimit, searchOptions.threshold, searchOptions.topicPrefix);
481
712
  let filtered = vectorResults;
713
+ let vectorScopeMap = new Map();
482
714
  if (options.scopes && options.scopes.length > 0) {
483
715
  const vectorIds = vectorResults.map((r) => String(r.id));
484
- const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), vectorIds);
716
+ vectorScopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), vectorIds);
485
717
  const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
486
718
  filtered = vectorResults.filter((r) => {
487
- const scopes = scopeMap.get(String(r.id)) ?? [];
719
+ const scopes = vectorScopeMap.get(String(r.id)) ?? [];
488
720
  // When scopes are requested, zero-binding results must NOT pass through
489
721
  if (scopes.length === 0)
490
722
  return false;
@@ -504,11 +736,17 @@ async function recallMemory(query, options = {}) {
504
736
  details: String(result.reasoning || ''),
505
737
  confidence: result.similarity ?? 0.5,
506
738
  status: effectiveStatus || 'active',
507
- scopes: [],
739
+ scopes: vectorScopeMap.get(String(result.id)) ?? [],
508
740
  source: { package: 'mama-core', source_type: 'vector_search' },
509
741
  created_at: result.created_at ?? Date.now(),
510
742
  updated_at: result.created_at ?? Date.now(),
743
+ event_date: result.event_date ?? null,
744
+ event_datetime: result.event_datetime ?? null,
511
745
  });
746
+ diagnostics.candidate_counts.vector += 1;
747
+ if (typeof result.similarity === 'number' && Number.isFinite(result.similarity)) {
748
+ vectorSimilarityById.set(String(result.id), result.similarity);
749
+ }
512
750
  }
513
751
  } // end sub-query loop
514
752
  }
@@ -530,7 +768,14 @@ async function recallMemory(query, options = {}) {
530
768
  // Lazy lexical: skip expensive FTS5/lexical when vector already returned enough results,
531
769
  // unless this is an aggregation query that benefits from broader coverage.
532
770
  const VECTOR_SUFFICIENT_THRESHOLD = 5;
533
- const needsLexical = isAggregation || vectorMatched.length < VECTOR_SUFFICIENT_THRESHOLD;
771
+ const shouldForceLexicalConfirmation = searchOptions.strictness !== 'recall' ||
772
+ searchOptions.minLexicalSupport ||
773
+ queryTokenCount(query) <= 3 ||
774
+ looksMixedKoreanEnglish(query) ||
775
+ looksEntityLike(query);
776
+ const needsLexical = isAggregation ||
777
+ vectorMatched.length < VECTOR_SUFFICIENT_THRESHOLD ||
778
+ shouldForceLexicalConfirmation;
534
779
  let lexicalCandidates = [];
535
780
  const lexicalLimit = isAggregation ? 100 : 50;
536
781
  if (needsLexical) {
@@ -587,7 +832,7 @@ async function recallMemory(query, options = {}) {
587
832
  for (const ftsRow of ftsResults) {
588
833
  const row = adapter
589
834
  .prepare(`SELECT id, topic, decision, reasoning, confidence, created_at, updated_at,
590
- trust_context, kind, status, summary, event_date
835
+ trust_context, kind, status, summary, event_date, event_datetime
591
836
  FROM decisions WHERE id = ?`)
592
837
  .get(ftsRow.id);
593
838
  if (!row)
@@ -602,7 +847,7 @@ async function recallMemory(query, options = {}) {
602
847
  const scopeMap = batchLoadScopes(adapter, memoryIds);
603
848
  const record = toMemoryRecord(row, scopeMap.get(String(row.id)) ?? [], fallbackSource);
604
849
  // Topic prefix filtering (matches vectorSearch behavior)
605
- if (options.topicPrefix && !record.topic.startsWith(options.topicPrefix))
850
+ if (searchOptions.topicPrefix && !record.topic.startsWith(searchOptions.topicPrefix))
606
851
  continue;
607
852
  // Scope filtering
608
853
  if (options.scopes && options.scopes.length > 0) {
@@ -636,8 +881,8 @@ async function recallMemory(query, options = {}) {
636
881
  });
637
882
  }
638
883
  // Topic prefix filtering for in-memory lexical (matches vectorSearch behavior)
639
- if (options.topicPrefix) {
640
- lexicalRecords = lexicalRecords.filter((r) => r.topic.startsWith(options.topicPrefix));
884
+ if (searchOptions.topicPrefix) {
885
+ lexicalRecords = lexicalRecords.filter((r) => r.topic.startsWith(searchOptions.topicPrefix));
641
886
  }
642
887
  lexicalCandidates = buildLexicalCandidates(lexicalRecords, query);
643
888
  if (subQueries.length > 1) {
@@ -653,6 +898,10 @@ async function recallMemory(query, options = {}) {
653
898
  }
654
899
  }
655
900
  } // end needsLexical
901
+ diagnostics.candidate_counts.lexical = lexicalCandidates.length;
902
+ for (const candidate of lexicalCandidates) {
903
+ lexicalScoreById.set(candidate.memory.id, candidate.score);
904
+ }
656
905
  // RRF Fusion: combine vector and lexical/FTS5 results by reciprocal rank
657
906
  // FTS5 BM25 gets 2x weight — it naturally demotes records where query terms
658
907
  // appear only in passing (e.g. "wedding" mentioned once in a dating record),
@@ -688,6 +937,63 @@ async function recallMemory(query, options = {}) {
688
937
  rrfScores.set(r.id, { record: r, score: rrfScore });
689
938
  }
690
939
  const sortedRrf = Array.from(rrfScores.values()).sort((a, b) => b.score - a.score);
940
+ const decisionFusedHits = sortedRrf.map((entry) => ({
941
+ source_type: 'decision',
942
+ source_id: entry.record.id,
943
+ record: entry.record,
944
+ fused_rank_score: entry.score,
945
+ }));
946
+ let hasWikiHits = false;
947
+ const wikiScores = new Map();
948
+ const wikiVectorRankMap = new Map();
949
+ const wikiVectorScoreById = new Map();
950
+ try {
951
+ const adapter = (0, db_manager_js_1.getAdapter)();
952
+ const wikiFtsHits = (0, wiki_page_index_js_1.ftsSearchWikiPages)(adapter, query, requestedLimit * 2);
953
+ let wikiVectorHits = [];
954
+ if (primaryQueryEmbedding) {
955
+ try {
956
+ wikiVectorHits = (0, wiki_page_index_js_1.vectorSearchWikiPages)(adapter, primaryQueryEmbedding, requestedLimit * 2);
957
+ }
958
+ catch (wikiVectorErr) {
959
+ (0, debug_logger_js_1.warn)(`[recallMemory] Wiki vector search failed: ${wikiVectorErr instanceof Error ? wikiVectorErr.message : String(wikiVectorErr)}`);
960
+ }
961
+ }
962
+ for (let i = 0; i < wikiVectorHits.length; i++) {
963
+ wikiVectorRankMap.set(wikiVectorHits[i].record.id, i);
964
+ wikiVectorScoreById.set(wikiVectorHits[i].record.id, wikiVectorHits[i].raw_score);
965
+ }
966
+ diagnostics.candidate_counts.lexical += wikiFtsHits.length;
967
+ diagnostics.candidate_counts.vector += wikiVectorHits.length;
968
+ for (let i = 0; i < wikiFtsHits.length; i++) {
969
+ const hit = wikiFtsHits[i];
970
+ const lexScore = 1 / (RRF_K + i + 1);
971
+ const vecRank = wikiVectorRankMap.get(hit.record.id);
972
+ const vecBoost = vecRank !== undefined ? 0.2 * (1 / (RRF_K + vecRank + 1)) : 0;
973
+ wikiScores.set(hit.record.id, {
974
+ record: hit.record,
975
+ score: lexScore + vecBoost,
976
+ lexicalSupport: true,
977
+ vectorSimilarity: wikiVectorScoreById.get(hit.record.id) ?? null,
978
+ });
979
+ }
980
+ for (let i = 0; i < wikiVectorHits.length; i++) {
981
+ const hit = wikiVectorHits[i];
982
+ if (wikiScores.has(hit.record.id)) {
983
+ continue;
984
+ }
985
+ wikiScores.set(hit.record.id, {
986
+ record: hit.record,
987
+ score: 0.15 * (1 / (RRF_K + i + 1)),
988
+ lexicalSupport: false,
989
+ vectorSimilarity: hit.raw_score,
990
+ });
991
+ }
992
+ }
993
+ catch (wikiFtsErr) {
994
+ (0, debug_logger_js_1.warn)(`[recallMemory] Wiki FTS search failed: ${wikiFtsErr instanceof Error ? wikiFtsErr.message : String(wikiFtsErr)}`);
995
+ }
996
+ fusedHits = decisionFusedHits;
691
997
  // Normalize RRF scores to 0-1 range so downstream consumers (threshold filters,
692
998
  // similarity displays) get meaningful values. The raw RRF score for K=60 tops out
693
999
  // around 0.033, which is misleading when compared against similarity thresholds.
@@ -705,6 +1011,355 @@ async function recallMemory(query, options = {}) {
705
1011
  else if (lexicalCandidates.length > 0) {
706
1012
  retrievalSource = 'lexical_search';
707
1013
  }
1014
+ let canonicalMatched = [];
1015
+ if (projectionMode !== 'off') {
1016
+ try {
1017
+ canonicalMatched = await (0, recall_bridge_js_1.queryCanonicalEntities)(query, options.scopes ?? [], { limit: 10 });
1018
+ diagnostics.candidate_counts.entity = canonicalMatched.length;
1019
+ for (const canonical of canonicalMatched) {
1020
+ entitySupportIds.add(canonical.id);
1021
+ }
1022
+ if (projectionMode === 'dual-write' && canonicalMatched.length > 0) {
1023
+ const seenIds = new Set(matched.map((item) => item.id));
1024
+ for (const canonical of canonicalMatched) {
1025
+ if (!seenIds.has(canonical.id)) {
1026
+ matched.push(canonical);
1027
+ seenIds.add(canonical.id);
1028
+ }
1029
+ }
1030
+ retrievalSource =
1031
+ retrievalSource === 'none' ? 'entity_canonical' : `${retrievalSource}+entity_canonical`;
1032
+ }
1033
+ else if (projectionMode === 'shadow' && canonicalMatched.length > 0) {
1034
+ retrievalSource =
1035
+ retrievalSource === 'none' ? 'shadow_entity_probe' : `${retrievalSource}+shadow_probe`;
1036
+ }
1037
+ }
1038
+ catch (canonicalErr) {
1039
+ (0, debug_logger_js_1.warn)(`[recallMemory] Canonical entity recall failed: ${canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr)}`);
1040
+ }
1041
+ }
1042
+ const requestedScopeKeys = new Set((options.scopes ?? []).map((scope) => `${scope.kind}:${scope.id}`));
1043
+ const hasRequestedScopeSupport = (record) => {
1044
+ if (requestedScopeKeys.size === 0) {
1045
+ return true;
1046
+ }
1047
+ return record.scopes.some((scope) => requestedScopeKeys.has(`${scope.kind}:${scope.id}`));
1048
+ };
1049
+ // exact_topic confirmation is intentionally narrow: short substring matches
1050
+ // would let two-letter queries like "ai" trip on words like "email" and
1051
+ // promote vector-only hits past strict/balanced rejection. Require either
1052
+ // exact equality or whole-token containment, and ignore tiny queries.
1053
+ const EXACT_TOPIC_MIN_QUERY_LENGTH = 3;
1054
+ const tokenizeForExactTopic = (input) => input.split(/[^a-z0-9]+/).filter((token) => token.length > 0);
1055
+ const hasExactTopicSupport = (record) => {
1056
+ const normalizedQuery = query.trim().toLowerCase();
1057
+ const normalizedTopic = record.topic.trim().toLowerCase();
1058
+ if (!normalizedQuery || !normalizedTopic) {
1059
+ return false;
1060
+ }
1061
+ if (normalizedQuery.length < EXACT_TOPIC_MIN_QUERY_LENGTH) {
1062
+ return false;
1063
+ }
1064
+ if (normalizedQuery === normalizedTopic) {
1065
+ return true;
1066
+ }
1067
+ const queryTokens = tokenizeForExactTopic(normalizedQuery);
1068
+ const topicTokens = tokenizeForExactTopic(normalizedTopic);
1069
+ // Whole-token match: a topic word equals the entire query, OR the topic
1070
+ // (when itself a single token) appears as a complete word in the query.
1071
+ return topicTokens.includes(normalizedQuery) || queryTokens.includes(normalizedTopic);
1072
+ };
1073
+ const hasExactWikiSupport = (record) => {
1074
+ const normalizedQuery = query.trim().toLowerCase();
1075
+ if (!normalizedQuery || normalizedQuery.length < EXACT_TOPIC_MIN_QUERY_LENGTH) {
1076
+ return false;
1077
+ }
1078
+ const normalizedTitle = record.title.trim().toLowerCase();
1079
+ if (!normalizedTitle) {
1080
+ return false;
1081
+ }
1082
+ if (normalizedQuery === normalizedTitle) {
1083
+ return true;
1084
+ }
1085
+ const queryTokens = tokenizeForExactTopic(normalizedQuery);
1086
+ const titleTokens = tokenizeForExactTopic(normalizedTitle);
1087
+ // Wiki exact_topic uses TITLE only — page body matches are too lenient
1088
+ // for an "exact" signal and remain available through other diagnostics.
1089
+ return titleTokens.includes(normalizedQuery) || queryTokens.includes(normalizedTitle);
1090
+ };
1091
+ const wikiDecisionIdCache = new Map();
1092
+ const normalizeWikiDecisionSourceId = (sourceId) => {
1093
+ const trimmed = sourceId.trim();
1094
+ if (!trimmed) {
1095
+ return null;
1096
+ }
1097
+ if (trimmed.startsWith('decision://')) {
1098
+ const id = trimmed.slice('decision://'.length).trim();
1099
+ return id || null;
1100
+ }
1101
+ if (trimmed.startsWith('decision:')) {
1102
+ const id = trimmed.slice('decision:'.length).trim();
1103
+ return id || null;
1104
+ }
1105
+ if (/^[A-Za-z][A-Za-z0-9_-]*:/.test(trimmed)) {
1106
+ return null;
1107
+ }
1108
+ return trimmed;
1109
+ };
1110
+ const loadWikiDecisionIds = (record) => {
1111
+ const cached = wikiDecisionIdCache.get(record.id);
1112
+ if (cached) {
1113
+ return cached;
1114
+ }
1115
+ const ids = new Set();
1116
+ for (const sourceId of record.source_ids) {
1117
+ const decisionId = normalizeWikiDecisionSourceId(sourceId);
1118
+ if (decisionId) {
1119
+ ids.add(decisionId);
1120
+ }
1121
+ }
1122
+ if (record.case_id) {
1123
+ const rows = (0, db_manager_js_1.getAdapter)()
1124
+ .prepare(`
1125
+ SELECT source_id
1126
+ FROM case_memberships
1127
+ WHERE case_id = ?
1128
+ AND source_type = 'decision'
1129
+ AND status = 'active'
1130
+ `)
1131
+ .all(record.case_id);
1132
+ for (const row of rows) {
1133
+ if (row.source_id) {
1134
+ ids.add(row.source_id);
1135
+ }
1136
+ }
1137
+ }
1138
+ const decisionIds = Array.from(ids);
1139
+ wikiDecisionIdCache.set(record.id, decisionIds);
1140
+ return decisionIds;
1141
+ };
1142
+ const wikiScopeSupportCache = new Map();
1143
+ const hasRequestedWikiScopeSupport = (record) => {
1144
+ if (requestedScopeKeys.size === 0) {
1145
+ return true;
1146
+ }
1147
+ const cached = wikiScopeSupportCache.get(record.id);
1148
+ if (cached !== undefined) {
1149
+ return cached;
1150
+ }
1151
+ const decisionIds = loadWikiDecisionIds(record);
1152
+ if (decisionIds.length === 0) {
1153
+ wikiScopeSupportCache.set(record.id, false);
1154
+ return false;
1155
+ }
1156
+ const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), decisionIds);
1157
+ const supported = decisionIds.some((id) => (scopeMap.get(id) ?? []).some((scope) => requestedScopeKeys.has(`${scope.kind}:${scope.id}`)));
1158
+ wikiScopeSupportCache.set(record.id, supported);
1159
+ return supported;
1160
+ };
1161
+ // Combined check used for the wiki *filter* path: a wiki entry must have at
1162
+ // least one linked decision that matches BOTH the requested scope AND the
1163
+ // requested topicPrefix. Without this, the page passes when scope matches
1164
+ // decision A and topic matches decision B — i.e., neither single decision
1165
+ // satisfies the caller's filter. Diagnostics reporting (which only signals
1166
+ // scope confirmation) keeps using hasRequestedWikiScopeSupport.
1167
+ const wikiCombinedSupportCache = new Map();
1168
+ const hasWikiDecisionWithScopeAndTopic = (record) => {
1169
+ const requiresScope = requestedScopeKeys.size > 0;
1170
+ const requiresTopic = !!searchOptions.topicPrefix;
1171
+ if (!requiresScope && !requiresTopic) {
1172
+ return true;
1173
+ }
1174
+ const cached = wikiCombinedSupportCache.get(record.id);
1175
+ if (cached !== undefined) {
1176
+ return cached;
1177
+ }
1178
+ const decisionIds = loadWikiDecisionIds(record);
1179
+ if (decisionIds.length === 0) {
1180
+ wikiCombinedSupportCache.set(record.id, false);
1181
+ return false;
1182
+ }
1183
+ const adapter = (0, db_manager_js_1.getAdapter)();
1184
+ const scopeMap = requiresScope ? batchLoadScopes(adapter, decisionIds) : null;
1185
+ const topicByDecisionId = new Map();
1186
+ if (requiresTopic) {
1187
+ const IN_CHUNK_SIZE = 900;
1188
+ for (let i = 0; i < decisionIds.length; i += IN_CHUNK_SIZE) {
1189
+ const chunk = decisionIds.slice(i, i + IN_CHUNK_SIZE);
1190
+ const placeholders = chunk.map(() => '?').join(', ');
1191
+ const rows = adapter
1192
+ .prepare(`SELECT id, topic FROM decisions WHERE id IN (${placeholders})`)
1193
+ .all(...chunk);
1194
+ for (const row of rows) {
1195
+ topicByDecisionId.set(row.id, String(row.topic ?? ''));
1196
+ }
1197
+ }
1198
+ }
1199
+ const supported = decisionIds.some((id) => {
1200
+ if (requiresScope) {
1201
+ const scopes = scopeMap?.get(id) ?? [];
1202
+ const scopeOk = scopes.some((scope) => requestedScopeKeys.has(`${scope.kind}:${scope.id}`));
1203
+ if (!scopeOk) {
1204
+ return false;
1205
+ }
1206
+ }
1207
+ if (requiresTopic) {
1208
+ const topic = topicByDecisionId.get(id) ?? '';
1209
+ if (!topic.startsWith(searchOptions.topicPrefix)) {
1210
+ return false;
1211
+ }
1212
+ }
1213
+ return true;
1214
+ });
1215
+ wikiCombinedSupportCache.set(record.id, supported);
1216
+ return supported;
1217
+ };
1218
+ const buildDiagnostics = (record, graphSource) => {
1219
+ const vectorSimilarity = vectorSimilarityById.get(record.id) ?? null;
1220
+ const lexicalSupport = lexicalScoreById.has(record.id);
1221
+ const entitySupport = entitySupportIds.has(record.id);
1222
+ const exactTopicSupport = hasExactTopicSupport(record);
1223
+ const scopeSupport = hasRequestedScopeSupport(record);
1224
+ const confirmationSignals = [
1225
+ lexicalSupport ? 'lexical' : null,
1226
+ entitySupport ? 'entity' : null,
1227
+ exactTopicSupport ? 'exact_topic' : null,
1228
+ ].filter((signal) => signal !== null);
1229
+ const metadataSignals = [
1230
+ requestedScopeKeys.size > 0 && scopeSupport ? 'scope' : null,
1231
+ graphSource === 'primary' ? 'graph_primary' : null,
1232
+ graphSource === 'expanded' ? 'graph_expanded' : null,
1233
+ ].filter((signal) => signal !== null);
1234
+ const retrievalSourceForRecord = vectorSimilarity !== null && lexicalSupport
1235
+ ? 'hybrid_rrf'
1236
+ : vectorSimilarity !== null
1237
+ ? 'vector_search'
1238
+ : lexicalSupport
1239
+ ? 'lexical_search'
1240
+ : entitySupport
1241
+ ? 'entity_canonical'
1242
+ : String(record.source.source_type || 'unknown');
1243
+ return {
1244
+ retrieval_source: retrievalSourceForRecord,
1245
+ vector_similarity: vectorSimilarity,
1246
+ lexical_support: lexicalSupport,
1247
+ entity_support: entitySupport,
1248
+ scope_support: scopeSupport,
1249
+ graph_source: graphSource,
1250
+ is_vector_only: vectorSimilarity !== null && confirmationSignals.length === 0,
1251
+ confirmation_signals: confirmationSignals,
1252
+ metadata_signals: metadataSignals,
1253
+ candidate_threshold_used: searchOptions.threshold,
1254
+ };
1255
+ };
1256
+ const buildWikiDiagnostics = (entry) => {
1257
+ const lexicalSupport = entry.lexicalSupport;
1258
+ const exactTopicSupport = hasExactWikiSupport(entry.record);
1259
+ const scopeSupport = hasRequestedWikiScopeSupport(entry.record);
1260
+ const confirmationSignals = [
1261
+ lexicalSupport ? 'lexical' : null,
1262
+ exactTopicSupport ? 'exact_topic' : null,
1263
+ ].filter((signal) => signal !== null);
1264
+ // Only attribute 'scope' when the caller actually requested a scope filter.
1265
+ // hasRequestedWikiScopeSupport() returns true by default for unscoped searches,
1266
+ // so without this guard the diagnostics would overstate why the wiki hit passed.
1267
+ const metadataSignals = [
1268
+ requestedScopeKeys.size > 0 && scopeSupport ? 'scope' : null,
1269
+ 'graph_primary',
1270
+ ].filter((signal) => signal !== null);
1271
+ const retrievalSourceForRecord = entry.vectorSimilarity !== null && lexicalSupport
1272
+ ? 'wiki_hybrid_rrf'
1273
+ : entry.vectorSimilarity !== null
1274
+ ? 'wiki_vector_search'
1275
+ : lexicalSupport
1276
+ ? 'wiki_lexical_search'
1277
+ : 'wiki_page';
1278
+ return {
1279
+ retrieval_source: retrievalSourceForRecord,
1280
+ vector_similarity: entry.vectorSimilarity,
1281
+ lexical_support: lexicalSupport,
1282
+ entity_support: false,
1283
+ scope_support: scopeSupport,
1284
+ graph_source: 'primary',
1285
+ is_vector_only: entry.vectorSimilarity !== null && confirmationSignals.length === 0,
1286
+ confirmation_signals: confirmationSignals,
1287
+ metadata_signals: metadataSignals,
1288
+ candidate_threshold_used: searchOptions.threshold,
1289
+ };
1290
+ };
1291
+ const passesStrictness = (hitDiagnostics) => {
1292
+ if (searchOptions.strictness === 'recall') {
1293
+ return true;
1294
+ }
1295
+ if (hitDiagnostics.confirmation_signals.length === 0) {
1296
+ return false;
1297
+ }
1298
+ if (!searchOptions.minLexicalSupport) {
1299
+ return true;
1300
+ }
1301
+ return (hitDiagnostics.lexical_support ||
1302
+ hitDiagnostics.entity_support ||
1303
+ hitDiagnostics.confirmation_signals.includes('exact_topic'));
1304
+ };
1305
+ const acceptedPrimaryIds = new Set();
1306
+ matched = matched.flatMap((record) => {
1307
+ const recordDiagnostics = buildDiagnostics(record, 'primary');
1308
+ if (recordDiagnostics.is_vector_only) {
1309
+ diagnostics.candidate_counts.vector_only += 1;
1310
+ }
1311
+ if (!passesStrictness(recordDiagnostics)) {
1312
+ diagnostics.candidate_counts.rejected_by_strictness += 1;
1313
+ return [];
1314
+ }
1315
+ acceptedPrimaryIds.add(record.id);
1316
+ return [
1317
+ searchOptions.diagnostics ? { ...record, retrieval_diagnostics: recordDiagnostics } : record,
1318
+ ];
1319
+ });
1320
+ fusedHits = fusedHits.filter((hit) => hit.source_type !== 'decision' || acceptedPrimaryIds.has(hit.source_id));
1321
+ const acceptedWikiFusedHits = Array.from(wikiScores.values())
1322
+ .sort((a, b) => b.score - a.score)
1323
+ .flatMap((entry) => {
1324
+ if (!hasWikiDecisionWithScopeAndTopic(entry.record)) {
1325
+ return [];
1326
+ }
1327
+ const wikiDiagnostics = buildWikiDiagnostics(entry);
1328
+ if (wikiDiagnostics.is_vector_only) {
1329
+ diagnostics.candidate_counts.vector_only += 1;
1330
+ }
1331
+ if (!passesStrictness(wikiDiagnostics)) {
1332
+ diagnostics.candidate_counts.rejected_by_strictness += 1;
1333
+ return [];
1334
+ }
1335
+ return [
1336
+ {
1337
+ source_type: 'wiki_page',
1338
+ source_id: entry.record.source_locator,
1339
+ record: searchOptions.diagnostics
1340
+ ? { ...entry.record, retrieval_diagnostics: wikiDiagnostics }
1341
+ : entry.record,
1342
+ fused_rank_score: entry.score,
1343
+ ...(searchOptions.diagnostics ? { retrieval_diagnostics: wikiDiagnostics } : {}),
1344
+ ...(entry.record.page_type === 'case' ? { page_type: 'case' } : {}),
1345
+ ...(entry.record.case_id ? { case_id: entry.record.case_id } : {}),
1346
+ },
1347
+ ];
1348
+ });
1349
+ hasWikiHits = acceptedWikiFusedHits.length > 0;
1350
+ fusedHits = [...fusedHits, ...acceptedWikiFusedHits].sort((left, right) => right.fused_rank_score - left.fused_rank_score);
1351
+ if (hasWikiHits) {
1352
+ retrievalSource = retrievalSource === 'none' ? 'wiki_page' : `${retrievalSource}+wiki_page`;
1353
+ }
1354
+ if (matched.length > 0) {
1355
+ const readIdentityIndex = await (0, read_identity_js_1.loadDecisionReadIdentityIndex)(matched.filter((record) => !record.read_identity).map((record) => record.id));
1356
+ for (const record of matched) {
1357
+ if (record.read_identity) {
1358
+ continue;
1359
+ }
1360
+ record.read_identity = (0, read_identity_js_1.resolveReadIdentity)(record, readIdentityIndex.get(record.id) ?? []);
1361
+ }
1362
+ }
708
1363
  // Enrich active records with summaries from their superseded predecessors.
709
1364
  // When ingestConversation extracts multiple facts under the same topic, only
710
1365
  // the last survives as "active" — the earlier ones become superseded and are
@@ -731,7 +1386,7 @@ async function recallMemory(query, options = {}) {
731
1386
  bundle.graph_context.primary = matched;
732
1387
  bundle.graph_context.expanded = [];
733
1388
  bundle.graph_context.edges = [];
734
- if (matched.length > 0 && !options.skipGraphExpansion) {
1389
+ if (matched.length > 0 && !options.skipGraphExpansion && searchOptions.includeRelated) {
735
1390
  try {
736
1391
  const candidates = matched.map((m) => ({
737
1392
  id: m.id,
@@ -746,6 +1401,7 @@ async function recallMemory(query, options = {}) {
746
1401
  const expanded = await mamaDefault.expandWithGraph(candidates);
747
1402
  const primaryIds = new Set(matched.map((m) => m.id));
748
1403
  let expandedOnly = expanded.filter((e) => !primaryIds.has(e.id));
1404
+ let expandedScopeMap = new Map();
749
1405
  // Re-filter expanded results: apply status and scope checks
750
1406
  if (!options.includeHistory) {
751
1407
  expandedOnly = expandedOnly.filter((e) => {
@@ -757,32 +1413,63 @@ async function recallMemory(query, options = {}) {
757
1413
  }
758
1414
  if (options.scopes && options.scopes.length > 0) {
759
1415
  const expandedIds = expandedOnly.map((e) => e.id);
760
- const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), expandedIds);
1416
+ expandedScopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), expandedIds);
761
1417
  const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
762
1418
  expandedOnly = expandedOnly.filter((e) => {
763
- const scopes = scopeMap.get(e.id) ?? [];
1419
+ const scopes = expandedScopeMap.get(e.id) ?? [];
764
1420
  if (scopes.length === 0)
765
1421
  return false;
766
1422
  return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
767
1423
  });
768
1424
  }
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)];
1425
+ bundle.graph_context.expanded = expandedOnly.flatMap((e) => {
1426
+ const expandedRecord = {
1427
+ id: String(e.id),
1428
+ topic: String(e.topic || ''),
1429
+ kind: 'decision',
1430
+ summary: String(e.decision || ''),
1431
+ details: '',
1432
+ confidence: e.graph_rank ?? 0.5,
1433
+ status: 'active',
1434
+ scopes: expandedScopeMap.get(e.id) ?? [],
1435
+ source: {
1436
+ package: 'mama-core',
1437
+ source_type: String(e.graph_source || 'graph_expansion'),
1438
+ },
1439
+ created_at: e.created_at ?? Date.now(),
1440
+ updated_at: e.created_at ?? Date.now(),
1441
+ };
1442
+ const expandedDiagnostics = buildDiagnostics(expandedRecord, 'expanded');
1443
+ if (!passesStrictness(expandedDiagnostics)) {
1444
+ diagnostics.candidate_counts.rejected_by_strictness += 1;
1445
+ return [];
1446
+ }
1447
+ return [
1448
+ searchOptions.diagnostics
1449
+ ? {
1450
+ ...expandedRecord,
1451
+ retrieval_diagnostics: expandedDiagnostics,
1452
+ }
1453
+ : expandedRecord,
1454
+ ];
1455
+ });
1456
+ diagnostics.candidate_counts.graph_expanded = bundle.graph_context.expanded.length;
1457
+ fusedHits = [
1458
+ ...fusedHits,
1459
+ ...bundle.graph_context.expanded.map((record) => ({
1460
+ source_type: 'decision',
1461
+ source_id: record.id,
1462
+ record,
1463
+ fused_rank_score: (record.confidence ?? 0.5) * 0.1,
1464
+ retrieval_diagnostics: record.retrieval_diagnostics,
1465
+ })),
1466
+ ].sort((left, right) => right.fused_rank_score - left.fused_rank_score);
1467
+ // bundle.graph_context.expanded is the strictness-filtered set of
1468
+ // expanded nodes that will actually be returned. Use those IDs (not
1469
+ // expandedOnly, which still contains rejected candidates) so edges
1470
+ // never point at nodes that never made it into the graph payload.
1471
+ const acceptedExpandedIds = bundle.graph_context.expanded.map((record) => record.id);
1472
+ const allIds = [...matched.map((m) => m.id), ...acceptedExpandedIds];
786
1473
  const allEdges = await loadEdgesForIds(allIds);
787
1474
  // Filter out edges pointing to decisions with excluded statuses
788
1475
  const activeIds = new Set(allIds);
@@ -807,16 +1494,20 @@ async function recallMemory(query, options = {}) {
807
1494
  // Graph expansion is best-effort; do not fail recall
808
1495
  }
809
1496
  }
1497
+ bundle.fused_hits = fusedHits;
810
1498
  bundle.search_meta.scope_order = (options.scopes ?? []).map((scope) => scope.kind);
811
1499
  bundle.search_meta.retrieval_sources = [retrievalSource];
1500
+ if (searchOptions.diagnostics) {
1501
+ bundle.search_meta.diagnostics = diagnostics;
1502
+ }
812
1503
  if (options.includeProfile) {
813
1504
  bundle.profile = await buildProfile(options.scopes ?? []);
814
1505
  }
815
1506
  return bundle;
816
1507
  }
817
- async function ingestMemory(input) {
1508
+ async function ingestMemoryInternal(input, options) {
818
1509
  const normalized = input.content.trim();
819
- return saveMemory({
1510
+ return saveMemoryInternal({
820
1511
  topic: normalized
821
1512
  .slice(0, 40)
822
1513
  .toLowerCase()
@@ -828,7 +1519,14 @@ async function ingestMemory(input) {
828
1519
  scopes: input.scopes ?? [],
829
1520
  source: input.source,
830
1521
  eventDate: input.eventDate,
831
- });
1522
+ eventDateTime: input.eventDateTime,
1523
+ }, options);
1524
+ }
1525
+ async function ingestMemory(input) {
1526
+ return ingestMemoryInternal((0, provenance_js_1.sanitizePublicIngestMemoryInput)(input));
1527
+ }
1528
+ async function ingestWithTrustedProvenance(input, options) {
1529
+ return ingestMemoryInternal((0, provenance_js_1.sanitizePublicIngestMemoryInput)(input), options);
832
1530
  }
833
1531
  async function evolveMemory(input) {
834
1532
  return (0, evolution_engine_js_1.resolveMemoryEvolution)(input);
@@ -880,18 +1578,18 @@ let extractionFn = callExtractionLLM;
880
1578
  function setExtractionFn(fn) {
881
1579
  extractionFn = fn ?? callExtractionLLM;
882
1580
  }
883
- async function ingestConversation(input) {
1581
+ async function ingestConversationInternal(input, options) {
884
1582
  if (!input.messages || input.messages.length === 0) {
885
1583
  throw new Error('messages array must not be empty');
886
1584
  }
887
1585
  const conversationText = input.messages.map((m) => `${m.role}: ${m.content}`).join('\n');
888
1586
  const topicPrefix = input.topicPrefix || '';
889
- const rawResult = await ingestMemory({
1587
+ const rawResult = await ingestMemoryInternal({
890
1588
  content: topicPrefix ? `${topicPrefix}${conversationText}` : conversationText,
891
1589
  scopes: input.scopes,
892
1590
  source: input.source,
893
1591
  eventDate: input.sessionDate,
894
- });
1592
+ }, options);
895
1593
  const result = {
896
1594
  rawId: rawResult.id,
897
1595
  extractedMemories: [],
@@ -940,7 +1638,7 @@ async function ingestConversation(input) {
940
1638
  const batchSavedIds = [];
941
1639
  for (const unit of units) {
942
1640
  try {
943
- const saved = await saveMemory({
1641
+ const saved = await saveMemoryInternal({
944
1642
  topic: topicPrefix ? `${topicPrefix}${unit.topic}` : unit.topic,
945
1643
  kind: unit.kind,
946
1644
  summary: unit.summary,
@@ -950,7 +1648,7 @@ async function ingestConversation(input) {
950
1648
  source: input.source,
951
1649
  excludeIds: batchSavedIds,
952
1650
  eventDate: input.sessionDate,
953
- });
1651
+ }, (0, provenance_js_1.appendProvenanceSourceRefs)(options, [`raw_memory:${rawResult.id}`]));
954
1652
  const now = Date.now();
955
1653
  adapter
956
1654
  .prepare(`INSERT OR REPLACE INTO decision_edges (from_id, to_id, relationship, reason, weight, created_at)
@@ -969,4 +1667,10 @@ async function ingestConversation(input) {
969
1667
  }
970
1668
  return result;
971
1669
  }
1670
+ async function ingestConversation(input) {
1671
+ return ingestConversationInternal((0, provenance_js_1.sanitizePublicIngestConversationInput)(input));
1672
+ }
1673
+ async function ingestConversationWithTrustedProvenance(input, options) {
1674
+ return ingestConversationInternal((0, provenance_js_1.sanitizePublicIngestConversationInput)(input), options);
1675
+ }
972
1676
  //# sourceMappingURL=api.js.map