@jungjaehoon/mama-core 1.5.0 → 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 (78) hide show
  1. package/README.md +48 -10
  2. package/db/migrations/032-add-memory-provenance-columns.sql +21 -0
  3. package/db/migrations/033-create-model-runs-and-tool-traces.sql +53 -0
  4. package/db/migrations/034-add-connector-event-scope-columns.sql +14 -0
  5. package/db/migrations/035-create-twin-edges.sql +63 -0
  6. package/db/migrations/036-create-agent-situation-packets.sql +61 -0
  7. package/dist/agent-graph/alias-write.d.ts +3 -0
  8. package/dist/agent-graph/alias-write.js +231 -0
  9. package/dist/agent-graph/entity-resolve.d.ts +3 -0
  10. package/dist/agent-graph/entity-resolve.js +275 -0
  11. package/dist/agent-graph/errors.d.ts +4 -0
  12. package/dist/agent-graph/errors.js +11 -0
  13. package/dist/agent-graph/graph-query.d.ts +5 -0
  14. package/dist/agent-graph/graph-query.js +359 -0
  15. package/dist/agent-graph/index.d.ts +6 -0
  16. package/dist/agent-graph/index.js +22 -0
  17. package/dist/agent-graph/types.d.ts +162 -0
  18. package/dist/agent-graph/types.js +3 -0
  19. package/dist/agent-situation/builder.d.ts +8 -0
  20. package/dist/agent-situation/builder.js +239 -0
  21. package/dist/agent-situation/cache-key.d.ts +4 -0
  22. package/dist/agent-situation/cache-key.js +64 -0
  23. package/dist/agent-situation/index.d.ts +7 -0
  24. package/dist/agent-situation/index.js +28 -0
  25. package/dist/agent-situation/packet-store.d.ts +27 -0
  26. package/dist/agent-situation/packet-store.js +228 -0
  27. package/dist/agent-situation/ranking-policy.d.ts +18 -0
  28. package/dist/agent-situation/ranking-policy.js +75 -0
  29. package/dist/agent-situation/source-readers.d.ts +52 -0
  30. package/dist/agent-situation/source-readers.js +300 -0
  31. package/dist/agent-situation/types.d.ts +195 -0
  32. package/dist/agent-situation/types.js +3 -0
  33. package/dist/cases/search-rollup.d.ts +4 -0
  34. package/dist/cases/search-rollup.js +84 -0
  35. package/dist/connectors/event-index.d.ts +1 -0
  36. package/dist/connectors/event-index.js +19 -4
  37. package/dist/connectors/raw-query.d.ts +18 -0
  38. package/dist/connectors/raw-query.js +305 -0
  39. package/dist/connectors/types.d.ts +40 -0
  40. package/dist/db-adapter/node-sqlite-adapter.d.ts +6 -0
  41. package/dist/db-adapter/node-sqlite-adapter.js +131 -0
  42. package/dist/db-manager.d.ts +8 -1
  43. package/dist/db-manager.js +53 -5
  44. package/dist/edges/ref-validation.d.ts +8 -0
  45. package/dist/edges/ref-validation.js +262 -0
  46. package/dist/edges/store.d.ts +10 -0
  47. package/dist/edges/store.js +343 -0
  48. package/dist/edges/types.d.ts +86 -0
  49. package/dist/edges/types.js +18 -0
  50. package/dist/embeddings.js +13 -0
  51. package/dist/index.d.ts +17 -3
  52. package/dist/index.js +53 -4
  53. package/dist/mama-api.d.ts +32 -35
  54. package/dist/mama-api.js +173 -22
  55. package/dist/memory/api.d.ts +17 -56
  56. package/dist/memory/api.js +494 -52
  57. package/dist/memory/event-store.d.ts +3 -0
  58. package/dist/memory/event-store.js +21 -2
  59. package/dist/memory/provenance-audit.d.ts +22 -0
  60. package/dist/memory/provenance-audit.js +112 -0
  61. package/dist/memory/provenance-query.d.ts +11 -0
  62. package/dist/memory/provenance-query.js +193 -0
  63. package/dist/memory/provenance.d.ts +29 -0
  64. package/dist/memory/provenance.js +136 -0
  65. package/dist/memory/scope-backfill.d.ts +17 -0
  66. package/dist/memory/scope-backfill.js +77 -0
  67. package/dist/memory/types.d.ts +86 -14
  68. package/dist/model-runs/store.d.ts +13 -0
  69. package/dist/model-runs/store.js +457 -0
  70. package/dist/model-runs/tool-trace-store.d.ts +4 -0
  71. package/dist/model-runs/tool-trace-store.js +114 -0
  72. package/dist/model-runs/types.d.ts +69 -0
  73. package/dist/model-runs/types.js +5 -0
  74. package/dist/runtime/trusted-provenance.d.ts +3 -0
  75. package/dist/runtime/trusted-provenance.js +6 -0
  76. package/dist/search/search-quality.d.ts +36 -0
  77. package/dist/search/search-quality.js +29 -0
  78. package/package.json +9 -2
@@ -7,15 +7,18 @@ 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");
@@ -33,10 +36,23 @@ Object.defineProperty(exports, "getChannelSummary", { enumerable: true, get: fun
33
36
  Object.defineProperty(exports, "upsertChannelSummary", { enumerable: true, get: function () { return channel_summary_store_js_1.upsertChannelSummary; } });
34
37
  const recall_bridge_js_1 = require("../entities/recall-bridge.js");
35
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");
36
42
  function buildDecisionId(topic) {
37
43
  const safeTopic = topic.replace(/[^a-z0-9_]+/gi, '_').toLowerCase();
38
44
  return `decision_${safeTopic}_${Date.now()}_${node_crypto_1.default.randomUUID().slice(0, 8)}`;
39
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
+ }
40
56
  function toMemoryRecord(row, scopes, fallbackSource) {
41
57
  let trustContext = null;
42
58
  if (typeof row.trust_context === 'string') {
@@ -304,6 +320,19 @@ function getLexicalQueryTokens(query) {
304
320
  .split(/[\s,.!?;:()[\]{}"']+/)
305
321
  .filter((token) => token.length > 2 && !exports.LEXICAL_STOPWORDS.has(token));
306
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
+ }
307
336
  function buildLexicalCandidates(records, query) {
308
337
  const tokens = getLexicalQueryTokens(query);
309
338
  const normalizedQuery = query.toLowerCase();
@@ -407,11 +436,12 @@ async function loadEdgesForIds(ids) {
407
436
  reason: row.reason ?? undefined,
408
437
  }));
409
438
  }
410
- async function saveMemory(input) {
439
+ async function saveMemoryInternal(input, options) {
411
440
  await (0, db_manager_js_1.initDB)();
412
441
  const adapter = (0, db_manager_js_1.getAdapter)();
413
442
  const id = buildDecisionId(input.topic);
414
443
  const now = Date.now();
444
+ const provenance = (0, provenance_js_1.normalizeMemoryWriteProvenance)(options);
415
445
  // Find evolution candidates: exact topic match first, then semantic search fallback
416
446
  const primaryScope = input.scopes.length > 0 ? input.scopes[0] : null;
417
447
  const excludeIds = new Set(input.excludeIds ?? []);
@@ -504,6 +534,12 @@ async function saveMemory(input) {
504
534
  trust_context: JSON.stringify({ source: input.source }),
505
535
  event_date: input.eventDate ?? null,
506
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),
507
543
  });
508
544
  // Pre-resolve scope IDs before the synchronous transaction
509
545
  const resolvedScopeIds = [];
@@ -529,6 +565,17 @@ async function saveMemory(input) {
529
565
  `)
530
566
  .run(id, scopeId, isPrimary ? 1 : 0);
531
567
  }
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
+ });
532
579
  for (const entityObservationId of entityObservationIds) {
533
580
  adapter
534
581
  .prepare(`
@@ -587,6 +634,12 @@ async function saveMemory(input) {
587
634
  timeline_event_ids: timelineEvent ? [timelineEvent.id] : [],
588
635
  };
589
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);
642
+ }
590
643
  async function buildProfile(scopes) {
591
644
  const records = await loadScopedMemories(scopes);
592
645
  return (0, profile_builder_js_1.classifyProfileEntries)(records);
@@ -599,10 +652,26 @@ const EXCLUDED_STATUSES = new Set([
599
652
  ]);
600
653
  async function recallMemory(query, options = {}) {
601
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
+ };
602
668
  let matched = [];
603
669
  let fusedHits = [];
604
670
  let retrievalSource = 'none';
605
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();
606
675
  let _lexicalRecords = null;
607
676
  const loadLexical = async () => {
608
677
  if (_lexicalRecords === null) {
@@ -639,14 +708,15 @@ async function recallMemory(query, options = {}) {
639
708
  if (sq === query && primaryQueryEmbedding === null) {
640
709
  primaryQueryEmbedding = queryEmbedding;
641
710
  }
642
- const vectorResults = await (0, db_manager_js_1.vectorSearch)(queryEmbedding, vectorLimit, 0.3, options.topicPrefix);
711
+ const vectorResults = await (0, db_manager_js_1.vectorSearch)(queryEmbedding, vectorLimit, searchOptions.threshold, searchOptions.topicPrefix);
643
712
  let filtered = vectorResults;
713
+ let vectorScopeMap = new Map();
644
714
  if (options.scopes && options.scopes.length > 0) {
645
715
  const vectorIds = vectorResults.map((r) => String(r.id));
646
- const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), vectorIds);
716
+ vectorScopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), vectorIds);
647
717
  const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
648
718
  filtered = vectorResults.filter((r) => {
649
- const scopes = scopeMap.get(String(r.id)) ?? [];
719
+ const scopes = vectorScopeMap.get(String(r.id)) ?? [];
650
720
  // When scopes are requested, zero-binding results must NOT pass through
651
721
  if (scopes.length === 0)
652
722
  return false;
@@ -666,13 +736,17 @@ async function recallMemory(query, options = {}) {
666
736
  details: String(result.reasoning || ''),
667
737
  confidence: result.similarity ?? 0.5,
668
738
  status: effectiveStatus || 'active',
669
- scopes: [],
739
+ scopes: vectorScopeMap.get(String(result.id)) ?? [],
670
740
  source: { package: 'mama-core', source_type: 'vector_search' },
671
741
  created_at: result.created_at ?? Date.now(),
672
742
  updated_at: result.created_at ?? Date.now(),
673
743
  event_date: result.event_date ?? null,
674
744
  event_datetime: result.event_datetime ?? null,
675
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
+ }
676
750
  }
677
751
  } // end sub-query loop
678
752
  }
@@ -694,7 +768,14 @@ async function recallMemory(query, options = {}) {
694
768
  // Lazy lexical: skip expensive FTS5/lexical when vector already returned enough results,
695
769
  // unless this is an aggregation query that benefits from broader coverage.
696
770
  const VECTOR_SUFFICIENT_THRESHOLD = 5;
697
- 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;
698
779
  let lexicalCandidates = [];
699
780
  const lexicalLimit = isAggregation ? 100 : 50;
700
781
  if (needsLexical) {
@@ -766,7 +847,7 @@ async function recallMemory(query, options = {}) {
766
847
  const scopeMap = batchLoadScopes(adapter, memoryIds);
767
848
  const record = toMemoryRecord(row, scopeMap.get(String(row.id)) ?? [], fallbackSource);
768
849
  // Topic prefix filtering (matches vectorSearch behavior)
769
- if (options.topicPrefix && !record.topic.startsWith(options.topicPrefix))
850
+ if (searchOptions.topicPrefix && !record.topic.startsWith(searchOptions.topicPrefix))
770
851
  continue;
771
852
  // Scope filtering
772
853
  if (options.scopes && options.scopes.length > 0) {
@@ -800,8 +881,8 @@ async function recallMemory(query, options = {}) {
800
881
  });
801
882
  }
802
883
  // Topic prefix filtering for in-memory lexical (matches vectorSearch behavior)
803
- if (options.topicPrefix) {
804
- lexicalRecords = lexicalRecords.filter((r) => r.topic.startsWith(options.topicPrefix));
884
+ if (searchOptions.topicPrefix) {
885
+ lexicalRecords = lexicalRecords.filter((r) => r.topic.startsWith(searchOptions.topicPrefix));
805
886
  }
806
887
  lexicalCandidates = buildLexicalCandidates(lexicalRecords, query);
807
888
  if (subQueries.length > 1) {
@@ -817,6 +898,10 @@ async function recallMemory(query, options = {}) {
817
898
  }
818
899
  }
819
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
+ }
820
905
  // RRF Fusion: combine vector and lexical/FTS5 results by reciprocal rank
821
906
  // FTS5 BM25 gets 2x weight — it naturally demotes records where query terms
822
907
  // appear only in passing (e.g. "wedding" mentioned once in a dating record),
@@ -861,6 +946,7 @@ async function recallMemory(query, options = {}) {
861
946
  let hasWikiHits = false;
862
947
  const wikiScores = new Map();
863
948
  const wikiVectorRankMap = new Map();
949
+ const wikiVectorScoreById = new Map();
864
950
  try {
865
951
  const adapter = (0, db_manager_js_1.getAdapter)();
866
952
  const wikiFtsHits = (0, wiki_page_index_js_1.ftsSearchWikiPages)(adapter, query, requestedLimit * 2);
@@ -875,7 +961,10 @@ async function recallMemory(query, options = {}) {
875
961
  }
876
962
  for (let i = 0; i < wikiVectorHits.length; i++) {
877
963
  wikiVectorRankMap.set(wikiVectorHits[i].record.id, i);
964
+ wikiVectorScoreById.set(wikiVectorHits[i].record.id, wikiVectorHits[i].raw_score);
878
965
  }
966
+ diagnostics.candidate_counts.lexical += wikiFtsHits.length;
967
+ diagnostics.candidate_counts.vector += wikiVectorHits.length;
879
968
  for (let i = 0; i < wikiFtsHits.length; i++) {
880
969
  const hit = wikiFtsHits[i];
881
970
  const lexScore = 1 / (RRF_K + i + 1);
@@ -884,6 +973,8 @@ async function recallMemory(query, options = {}) {
884
973
  wikiScores.set(hit.record.id, {
885
974
  record: hit.record,
886
975
  score: lexScore + vecBoost,
976
+ lexicalSupport: true,
977
+ vectorSimilarity: wikiVectorScoreById.get(hit.record.id) ?? null,
887
978
  });
888
979
  }
889
980
  for (let i = 0; i < wikiVectorHits.length; i++) {
@@ -894,24 +985,15 @@ async function recallMemory(query, options = {}) {
894
985
  wikiScores.set(hit.record.id, {
895
986
  record: hit.record,
896
987
  score: 0.15 * (1 / (RRF_K + i + 1)),
988
+ lexicalSupport: false,
989
+ vectorSimilarity: hit.raw_score,
897
990
  });
898
991
  }
899
- hasWikiHits = wikiScores.size > 0;
900
992
  }
901
993
  catch (wikiFtsErr) {
902
994
  (0, debug_logger_js_1.warn)(`[recallMemory] Wiki FTS search failed: ${wikiFtsErr instanceof Error ? wikiFtsErr.message : String(wikiFtsErr)}`);
903
995
  }
904
- const wikiFusedHits = Array.from(wikiScores.values())
905
- .sort((a, b) => b.score - a.score)
906
- .map((entry) => ({
907
- source_type: 'wiki_page',
908
- source_id: entry.record.source_locator,
909
- record: entry.record,
910
- fused_rank_score: entry.score,
911
- ...(entry.record.page_type === 'case' ? { page_type: 'case' } : {}),
912
- ...(entry.record.case_id ? { case_id: entry.record.case_id } : {}),
913
- }));
914
- fusedHits = [...decisionFusedHits, ...wikiFusedHits].sort((left, right) => right.fused_rank_score - left.fused_rank_score);
996
+ fusedHits = decisionFusedHits;
915
997
  // Normalize RRF scores to 0-1 range so downstream consumers (threshold filters,
916
998
  // similarity displays) get meaningful values. The raw RRF score for K=60 tops out
917
999
  // around 0.033, which is misleading when compared against similarity thresholds.
@@ -929,13 +1011,14 @@ async function recallMemory(query, options = {}) {
929
1011
  else if (lexicalCandidates.length > 0) {
930
1012
  retrievalSource = 'lexical_search';
931
1013
  }
932
- if (hasWikiHits) {
933
- retrievalSource = retrievalSource === 'none' ? 'wiki_page' : `${retrievalSource}+wiki_page`;
934
- }
935
1014
  let canonicalMatched = [];
936
1015
  if (projectionMode !== 'off') {
937
1016
  try {
938
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
+ }
939
1022
  if (projectionMode === 'dual-write' && canonicalMatched.length > 0) {
940
1023
  const seenIds = new Set(matched.map((item) => item.id));
941
1024
  for (const canonical of canonicalMatched) {
@@ -956,6 +1039,318 @@ async function recallMemory(query, options = {}) {
956
1039
  (0, debug_logger_js_1.warn)(`[recallMemory] Canonical entity recall failed: ${canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr)}`);
957
1040
  }
958
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
+ }
959
1354
  if (matched.length > 0) {
960
1355
  const readIdentityIndex = await (0, read_identity_js_1.loadDecisionReadIdentityIndex)(matched.filter((record) => !record.read_identity).map((record) => record.id));
961
1356
  for (const record of matched) {
@@ -991,7 +1386,7 @@ async function recallMemory(query, options = {}) {
991
1386
  bundle.graph_context.primary = matched;
992
1387
  bundle.graph_context.expanded = [];
993
1388
  bundle.graph_context.edges = [];
994
- if (matched.length > 0 && !options.skipGraphExpansion) {
1389
+ if (matched.length > 0 && !options.skipGraphExpansion && searchOptions.includeRelated) {
995
1390
  try {
996
1391
  const candidates = matched.map((m) => ({
997
1392
  id: m.id,
@@ -1006,6 +1401,7 @@ async function recallMemory(query, options = {}) {
1006
1401
  const expanded = await mamaDefault.expandWithGraph(candidates);
1007
1402
  const primaryIds = new Set(matched.map((m) => m.id));
1008
1403
  let expandedOnly = expanded.filter((e) => !primaryIds.has(e.id));
1404
+ let expandedScopeMap = new Map();
1009
1405
  // Re-filter expanded results: apply status and scope checks
1010
1406
  if (!options.includeHistory) {
1011
1407
  expandedOnly = expandedOnly.filter((e) => {
@@ -1017,32 +1413,63 @@ async function recallMemory(query, options = {}) {
1017
1413
  }
1018
1414
  if (options.scopes && options.scopes.length > 0) {
1019
1415
  const expandedIds = expandedOnly.map((e) => e.id);
1020
- const scopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), expandedIds);
1416
+ expandedScopeMap = batchLoadScopes((0, db_manager_js_1.getAdapter)(), expandedIds);
1021
1417
  const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
1022
1418
  expandedOnly = expandedOnly.filter((e) => {
1023
- const scopes = scopeMap.get(e.id) ?? [];
1419
+ const scopes = expandedScopeMap.get(e.id) ?? [];
1024
1420
  if (scopes.length === 0)
1025
1421
  return false;
1026
1422
  return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
1027
1423
  });
1028
1424
  }
1029
- bundle.graph_context.expanded = expandedOnly.map((e) => ({
1030
- id: String(e.id),
1031
- topic: String(e.topic || ''),
1032
- kind: 'decision',
1033
- summary: String(e.decision || ''),
1034
- details: '',
1035
- confidence: e.graph_rank ?? 0.5,
1036
- status: 'active',
1037
- scopes: [],
1038
- source: {
1039
- package: 'mama-core',
1040
- source_type: String(e.graph_source || 'graph_expansion'),
1041
- },
1042
- created_at: e.created_at ?? Date.now(),
1043
- updated_at: e.created_at ?? Date.now(),
1044
- }));
1045
- 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];
1046
1473
  const allEdges = await loadEdgesForIds(allIds);
1047
1474
  // Filter out edges pointing to decisions with excluded statuses
1048
1475
  const activeIds = new Set(allIds);
@@ -1070,14 +1497,17 @@ async function recallMemory(query, options = {}) {
1070
1497
  bundle.fused_hits = fusedHits;
1071
1498
  bundle.search_meta.scope_order = (options.scopes ?? []).map((scope) => scope.kind);
1072
1499
  bundle.search_meta.retrieval_sources = [retrievalSource];
1500
+ if (searchOptions.diagnostics) {
1501
+ bundle.search_meta.diagnostics = diagnostics;
1502
+ }
1073
1503
  if (options.includeProfile) {
1074
1504
  bundle.profile = await buildProfile(options.scopes ?? []);
1075
1505
  }
1076
1506
  return bundle;
1077
1507
  }
1078
- async function ingestMemory(input) {
1508
+ async function ingestMemoryInternal(input, options) {
1079
1509
  const normalized = input.content.trim();
1080
- return saveMemory({
1510
+ return saveMemoryInternal({
1081
1511
  topic: normalized
1082
1512
  .slice(0, 40)
1083
1513
  .toLowerCase()
@@ -1090,7 +1520,13 @@ async function ingestMemory(input) {
1090
1520
  source: input.source,
1091
1521
  eventDate: input.eventDate,
1092
1522
  eventDateTime: input.eventDateTime,
1093
- });
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);
1094
1530
  }
1095
1531
  async function evolveMemory(input) {
1096
1532
  return (0, evolution_engine_js_1.resolveMemoryEvolution)(input);
@@ -1142,18 +1578,18 @@ let extractionFn = callExtractionLLM;
1142
1578
  function setExtractionFn(fn) {
1143
1579
  extractionFn = fn ?? callExtractionLLM;
1144
1580
  }
1145
- async function ingestConversation(input) {
1581
+ async function ingestConversationInternal(input, options) {
1146
1582
  if (!input.messages || input.messages.length === 0) {
1147
1583
  throw new Error('messages array must not be empty');
1148
1584
  }
1149
1585
  const conversationText = input.messages.map((m) => `${m.role}: ${m.content}`).join('\n');
1150
1586
  const topicPrefix = input.topicPrefix || '';
1151
- const rawResult = await ingestMemory({
1587
+ const rawResult = await ingestMemoryInternal({
1152
1588
  content: topicPrefix ? `${topicPrefix}${conversationText}` : conversationText,
1153
1589
  scopes: input.scopes,
1154
1590
  source: input.source,
1155
1591
  eventDate: input.sessionDate,
1156
- });
1592
+ }, options);
1157
1593
  const result = {
1158
1594
  rawId: rawResult.id,
1159
1595
  extractedMemories: [],
@@ -1202,7 +1638,7 @@ async function ingestConversation(input) {
1202
1638
  const batchSavedIds = [];
1203
1639
  for (const unit of units) {
1204
1640
  try {
1205
- const saved = await saveMemory({
1641
+ const saved = await saveMemoryInternal({
1206
1642
  topic: topicPrefix ? `${topicPrefix}${unit.topic}` : unit.topic,
1207
1643
  kind: unit.kind,
1208
1644
  summary: unit.summary,
@@ -1212,7 +1648,7 @@ async function ingestConversation(input) {
1212
1648
  source: input.source,
1213
1649
  excludeIds: batchSavedIds,
1214
1650
  eventDate: input.sessionDate,
1215
- });
1651
+ }, (0, provenance_js_1.appendProvenanceSourceRefs)(options, [`raw_memory:${rawResult.id}`]));
1216
1652
  const now = Date.now();
1217
1653
  adapter
1218
1654
  .prepare(`INSERT OR REPLACE INTO decision_edges (from_id, to_id, relationship, reason, weight, created_at)
@@ -1231,4 +1667,10 @@ async function ingestConversation(input) {
1231
1667
  }
1232
1668
  return result;
1233
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
+ }
1234
1676
  //# sourceMappingURL=api.js.map