@jungjaehoon/mama-core 4.1.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +10 -10
  2. package/db/migrations/099-commitment-revision-graph.sql +3 -0
  3. package/dist/api/catalog.js +4 -7
  4. package/dist/db-adapter/node-sqlite-adapter.d.ts +5 -1
  5. package/dist/db-adapter/node-sqlite-adapter.js +27 -24
  6. package/dist/db-manager.d.ts +2 -13
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +3 -6
  9. package/dist/knowledge/access.js +1 -1
  10. package/dist/knowledge/commitments.d.ts +2 -0
  11. package/dist/knowledge/commitments.js +1 -0
  12. package/dist/knowledge/graph-query.d.ts +10 -1
  13. package/dist/knowledge/graph-query.js +118 -25
  14. package/dist/knowledge/index.d.ts +12 -4
  15. package/dist/knowledge/index.js +10 -10
  16. package/dist/knowledge/judgments.d.ts +3 -1
  17. package/dist/knowledge/judgments.js +46 -26
  18. package/dist/knowledge/links.d.ts +50 -0
  19. package/dist/knowledge/links.js +152 -0
  20. package/dist/knowledge/search.d.ts +6 -1
  21. package/dist/knowledge/search.js +17 -11
  22. package/dist/mama-api.d.ts +22 -7
  23. package/dist/mama-api.js +64 -15
  24. package/dist/memory/api.d.ts +17 -12
  25. package/dist/memory/api.js +436 -414
  26. package/dist/memory/decision-links.d.ts +54 -0
  27. package/dist/memory/decision-links.js +195 -0
  28. package/dist/memory/graph-read.d.ts +0 -16
  29. package/dist/memory/graph-read.js +10 -51
  30. package/dist/memory/judgment-types.d.ts +1 -20
  31. package/dist/memory/provenance-live.js +1 -1
  32. package/dist/memory/types.d.ts +14 -1
  33. package/dist/memory/types.js +1 -7
  34. package/dist/memory/write-adapters.d.ts +1 -39
  35. package/dist/memory/write-adapters.js +0 -98
  36. package/package.json +1 -1
  37. package/dist/knowledge/commitment-revision-migration.d.ts +0 -4
  38. package/dist/knowledge/commitment-revision-migration.js +0 -50
  39. package/dist/knowledge/decision-edges.d.ts +0 -70
  40. package/dist/knowledge/decision-edges.js +0 -123
  41. package/dist/memory/evolution-engine.d.ts +0 -22
  42. package/dist/memory/evolution-engine.js +0 -133
@@ -14,12 +14,10 @@ exports.saveMemory = saveMemory;
14
14
  exports.saveJudgmentRecord = saveJudgmentRecord;
15
15
  exports.boundReadScopesFor = boundReadScopesFor;
16
16
  exports.saveLegacyMemory = saveLegacyMemory;
17
- exports.promoteMemoryStatus = promoteMemoryStatus;
18
17
  exports.retireMemoryRecord = retireMemoryRecord;
19
18
  exports.buildProfile = buildProfile;
20
19
  exports.recallMemory = recallMemory;
21
20
  exports.ingestMemory = ingestMemory;
22
- exports.evolveMemory = evolveMemory;
23
21
  exports.buildMemoryBootstrap = buildMemoryBootstrap;
24
22
  exports.createAuditAck = createAuditAck;
25
23
  exports.recordMemoryAudit = recordMemoryAudit;
@@ -39,6 +37,7 @@ const write_adapters_js_1 = require("./write-adapters.js");
39
37
  const decision_formatter_js_1 = require("../decision-formatter.js");
40
38
  const debug_logger_js_1 = require("../debug-logger.js");
41
39
  const graph_query_js_1 = require("../knowledge/graph-query.js");
40
+ const decision_links_js_1 = require("./decision-links.js");
42
41
  const case_search_rollup_js_1 = require("../knowledge/case-search-rollup.js");
43
42
  const ranker_rescore_js_1 = require("../knowledge/ranker-rescore.js");
44
43
  const ranker_features_js_1 = require("../knowledge/ranker-features.js");
@@ -48,7 +47,6 @@ const index_js_1 = require("../knowledge/index.js");
48
47
  const write_adapters_js_2 = require("./write-adapters.js");
49
48
  const profile_builder_js_1 = require("./profile-builder.js");
50
49
  const bootstrap_builder_js_1 = require("./bootstrap-builder.js");
51
- const evolution_engine_js_1 = require("./evolution-engine.js");
52
50
  const channel_summary_state_store_js_1 = require("./channel-summary-state-store.js");
53
51
  const debug_logger_js_2 = require("../debug-logger.js");
54
52
  const secret_filter_js_1 = require("./secret-filter.js");
@@ -200,6 +198,24 @@ async function readMemoryRecordById(adapter, memoryId, scopes) {
200
198
  const recordScopes = batchLoadScopes(adapter, [id]).get(id) ?? [];
201
199
  return toMemoryRecord(row, recordScopes, { package: 'mama-core', source_type: 'db' });
202
200
  }
201
+ /** The records that state these corrections, down their chains. */
202
+ function correctionAuthors(corrections) {
203
+ return corrections.flatMap((correction) => [
204
+ correction.from,
205
+ ...correctionAuthors(correction.correctedBy ?? []),
206
+ ]);
207
+ }
208
+ /** The corrections a reader may see; a hidden correction takes the corrections of it along. */
209
+ function readableCorrections(corrections, readable) {
210
+ const kept = corrections.filter(readable).map((correction) => {
211
+ const further = correction.correctedBy
212
+ ? readableCorrections(correction.correctedBy, readable)
213
+ : undefined;
214
+ const { correctedBy: _drop, ...rest } = correction;
215
+ return further ? { ...rest, correctedBy: further } : rest;
216
+ });
217
+ return kept.length > 0 ? kept : undefined;
218
+ }
203
219
  function batchLoadScopes(adapter, memoryIds) {
204
220
  const scopeMap = new Map();
205
221
  if (memoryIds.length === 0)
@@ -467,7 +483,7 @@ async function loadEdgesForIds(adapter, ids) {
467
483
  const placeholders = ids.map(() => '?').join(', ');
468
484
  const rows = adapter
469
485
  .prepare(`SELECT from_id, to_id, relationship AS type, reason
470
- FROM decision_edges
486
+ FROM ${graph_query_js_1.STATED_DECISION_EDGES} e
471
487
  WHERE (from_id IN (${placeholders}) OR to_id IN (${placeholders}))
472
488
  AND (approved_by_user != 0 OR approved_by_user IS NULL)`)
473
489
  .all(...ids, ...ids);
@@ -514,7 +530,7 @@ function guidancePayloadFor(input) {
514
530
  }
515
531
  return { guidance };
516
532
  }
517
- async function saveMemoryInternal(adapter, input, provenance, access, trustedEnvelope, legacy, commandIdOverride) {
533
+ async function saveMemoryInternal(adapter, input, provenance, access, legacy, commandIdOverride) {
518
534
  const targetStatus = input.status ?? 'active';
519
535
  const requestedScopes = input.scopes ?? [];
520
536
  const payload = guidancePayloadFor(input);
@@ -528,23 +544,10 @@ async function saveMemoryInternal(adapter, input, provenance, access, trustedEnv
528
544
  });
529
545
  const commandId = commandIdOverride ?? `save:${buildDecisionId(input.topic)}`;
530
546
  const recordId = (0, index_js_1.judgmentRecordId)(commandId);
531
- // Relationships are persisted only when the caller names their target ids
532
- // explicitly. Matching topic text or vector similarity is evidence for
533
- // retrieval, not identity.
534
- const explicitRelationships = Array.from(new Map((legacy?.relationships ?? []).flatMap((relationship) => relationship.targetIds.map((targetId) => [
535
- `${relationship.type}:${targetId}`,
536
- { type: relationship.type, targetId },
537
- ]))).values());
538
- (0, write_adapters_js_2.assertRelationshipTargetsVisible)(adapter, explicitRelationships.map((relationship) => relationship.targetId), access.scopes, trustedEnvelope);
539
- const { links, replaces, decisionEdges, supersedeTargets } = (0, write_adapters_js_2.relationshipsToCommandFields)(explicitRelationships, { trusted: trustedEnvelope });
540
- const authoredLinks = [...links, ...(input.links ?? [])];
541
- const authoredReplacements = [...replaces, ...(input.replaces ?? [])];
542
- const allSupersedeTargets = [
543
- ...new Set([
544
- ...supersedeTargets,
545
- ...(input.replaces ?? []).map((replacement) => replacement.id),
546
- ]),
547
- ];
547
+ // A relation is written only when the caller names its target and reason (`links`,
548
+ // `replaces`); matching topic text or vector similarity is evidence for retrieval, not a link.
549
+ const authoredLinks = input.links ?? [];
550
+ const authoredReplacements = input.replaces ?? [];
548
551
  const embeddingDecision = {
549
552
  id: recordId,
550
553
  topic: input.topic,
@@ -591,8 +594,6 @@ async function saveMemoryInternal(adapter, input, provenance, access, trustedEnv
591
594
  reason: buildSaveEventReason(provenance.tool_name, provenance.gateway_call_id),
592
595
  },
593
596
  projections: {
594
- decisionEdges,
595
- ...(allSupersedeTargets.length > 0 ? { supersedeTargets: allSupersedeTargets } : {}),
596
597
  ...(input.itemId !== undefined || input.actors !== undefined
597
598
  ? {
598
599
  recordIdentity: { itemId: input.itemId ?? null, actors: input.actors ?? [] },
@@ -621,8 +622,26 @@ async function saveMemoryInternal(adapter, input, provenance, access, trustedEnv
621
622
  async function saveMemory(adapter, input) {
622
623
  const clean = (0, provenance_js_1.sanitizePublicSaveMemoryInput)(input);
623
624
  const provenance = (0, provenance_js_1.normalizeMemoryWriteProvenance)();
624
- const access = (0, write_adapters_js_2.writeAccessForProvenance)(provenance, clean.scopes ?? []);
625
- return saveMemoryInternal(adapter, clean, provenance, access, false);
625
+ const access = (0, write_adapters_js_2.writeAccessForProvenance)(provenance, uniqueScopes([...(clean.scopes ?? []), ...namedTargetScopes(adapter, clean)]));
626
+ return saveMemoryInternal(adapter, clean, provenance, access);
627
+ }
628
+ /**
629
+ * The scopes of the records a direct save names in `links` and `replaces`. A direct caller has no
630
+ * grant of its own, so naming a record admits its partition for that link, as `mama.link` does;
631
+ * the new record is still bound only to its own scopes.
632
+ */
633
+ function namedTargetScopes(adapter, input) {
634
+ const ids = [
635
+ ...(input.links ?? [])
636
+ .filter((link) => link.target.kind === 'memory')
637
+ .map((link) => link.target.id),
638
+ ...(input.replaces ?? []).map((replacement) => replacement.id),
639
+ ];
640
+ return ids.flatMap((id) => (0, write_adapters_js_2.boundScopesOf)(adapter, id));
641
+ }
642
+ /** Access scopes must be unique; the save's own scope and a target's are often the same. */
643
+ function uniqueScopes(scopes) {
644
+ return [...new Map(scopes.map((scope) => [`${scope.kind}\0${scope.id}`, scope])).values()];
626
645
  }
627
646
  /**
628
647
  * The unified `memory.save` action path. Access IS the call authority — the
@@ -650,7 +669,7 @@ async function saveJudgmentRecord(adapter, input, access, commandId, session) {
650
669
  source_message_ref: session?.sourceMessageRef,
651
670
  source_refs: session?.sourceRefs ? [...session.sourceRefs] : undefined,
652
671
  });
653
- return saveMemoryInternal(adapter, clean, provenance, access, true, undefined, commandId);
672
+ return saveMemoryInternal(adapter, clean, provenance, access, undefined, commandId);
654
673
  }
655
674
  /**
656
675
  * The read-side scope rule for memory actions — the same bound `appendJudgment`
@@ -689,173 +708,12 @@ function boundReadScopesFor(access, requested) {
689
708
  async function saveLegacyMemory(adapter, input, legacy, access) {
690
709
  const clean = (0, provenance_js_1.sanitizePublicSaveMemoryInput)(input);
691
710
  const provenance = (0, provenance_js_1.normalizeMemoryWriteProvenance)();
692
- const effectiveAccess = access ?? (0, write_adapters_js_2.writeAccessForProvenance)(provenance, clean.scopes ?? []);
693
- return saveMemoryInternal(adapter, clean, provenance, effectiveAccess, access !== undefined, legacy);
694
- }
695
- async function promoteMemoryStatus(adapter, input) {
696
- const memoryId = input.memoryId;
697
- const now = input.nowMs ?? Date.now();
698
- const targetStatus = input.status;
699
- const row = adapter
700
- .prepare(`
701
- SELECT id, topic, decision, confidence, kind, summary, supersedes
702
- FROM decisions
703
- WHERE id = ?
704
- `)
705
- .get(memoryId);
706
- if (!row) {
707
- throw new Error(`Cannot promote missing memory ${memoryId}`);
708
- }
709
- const topic = String(row.topic);
710
- const summary = String(row.summary ?? row.decision ?? '');
711
- const kind = (row.kind ?? 'fact') || 'fact';
712
- const scopes = batchLoadScopes(adapter, [memoryId]).get(memoryId) ?? [];
713
- let evolution = { edges: [] };
714
- if (targetStatus === 'active') {
715
- const primaryScope = scopes[0] ?? null;
716
- let existingCandidates;
717
- if (primaryScope) {
718
- const scopeId = (0, db_manager_js_1.ensureMemoryScope)(adapter, primaryScope.kind, primaryScope.id);
719
- existingCandidates = adapter
720
- .prepare(`
721
- SELECT d.id, d.topic, d.summary, d.kind
722
- FROM decisions d
723
- JOIN memory_scope_bindings msb ON msb.memory_id = d.id
724
- WHERE d.topic = ? AND msb.scope_id = ? AND d.id <> ?
725
- AND (d.status = 'active' OR d.status IS NULL)
726
- AND d.superseded_by IS NULL
727
- ORDER BY d.created_at DESC
728
- LIMIT 5
729
- `)
730
- .all(topic, scopeId, memoryId);
731
- }
732
- else {
733
- existingCandidates = adapter
734
- .prepare(`
735
- SELECT id, topic, summary, kind
736
- FROM decisions
737
- WHERE topic = ? AND id <> ?
738
- AND (status = 'active' OR status IS NULL)
739
- AND superseded_by IS NULL
740
- ORDER BY created_at DESC
741
- LIMIT 5
742
- `)
743
- .all(topic, memoryId);
744
- }
745
- if (existingCandidates.length === 0) {
746
- try {
747
- const queryText = `${topic} ${summary}`;
748
- const embedding = await (0, embedder_js_1.generateEmbedding)(queryText, 'query');
749
- // Same exclusion as saveMemoryInternal's fallback: superseded history must
750
- // not crowd out the prior ACTIVE decision from the 3 candidate slots.
751
- const semanticResults = await (0, search_js_1.vectorSearch)(adapter, embedding, 3, 0.82, undefined, Array.from(EXCLUDED_STATUSES));
752
- let scopeFiltered = semanticResults;
753
- if (primaryScope) {
754
- const semIds = semanticResults.map((result) => String(result.id));
755
- const semScopeMap = batchLoadScopes(adapter, semIds);
756
- const scopeKey = `${primaryScope.kind}:${primaryScope.id}`;
757
- scopeFiltered = semanticResults.filter((result) => {
758
- const resultScopes = semScopeMap.get(String(result.id)) ?? [];
759
- return (resultScopes.length === 0 ||
760
- resultScopes.some((scope) => `${scope.kind}:${scope.id}` === scopeKey));
761
- });
762
- }
763
- existingCandidates = scopeFiltered
764
- .filter((result) => String(result.id) !== memoryId)
765
- .filter((result) => {
766
- const status = String(result.status || '');
767
- return !status || status === 'active' || status === '';
768
- })
769
- .map((result) => ({
770
- id: String(result.id),
771
- topic: String(result.topic || ''),
772
- summary: String(result.decision || ''),
773
- kind: 'fact',
774
- _semanticMatch: true,
775
- }));
776
- }
777
- catch {
778
- // Semantic search unavailable — proceed with exact-match candidates only.
779
- }
780
- }
781
- evolution = (0, evolution_engine_js_1.resolveMemoryEvolution)({
782
- incoming: { topic, summary, kind },
783
- existing: existingCandidates.map((candidate) => ({
784
- ...candidate,
785
- kind: (candidate.kind || 'fact'),
786
- })),
787
- });
788
- }
789
- const existingSupersedesTarget = typeof row.supersedes === 'string' && row.supersedes.length > 0 ? row.supersedes : null;
790
- const supersedesTarget = evolution.edges.find((edge) => edge.type === 'supersedes')?.to_id ??
791
- (targetStatus === 'active' ? existingSupersedesTarget : null);
792
- // The status change is an authored amendment: one append-only judgment record
793
- // carries it, and the target row's projection columns move in the same
794
- // transaction. A replayed command (same id, same payload) returns the stored
795
- // receipt instead of rewriting.
796
- const commandId = `promote:${memoryId}:${node_crypto_1.default
797
- .createHash('sha256')
798
- .update((0, canonicalize_js_1.canonicalizeJSON)({
799
- status: targetStatus,
800
- supersedes: supersedesTarget,
801
- edges: evolution.edges.map((edge) => `${edge.type}:${edge.to_id}`),
802
- now,
803
- }))
804
- .digest('hex')
805
- .slice(0, 16)}`;
806
- const supersedesEdges = evolution.edges.filter((edge) => edge.type === 'supersedes');
807
- const command = {
808
- commandId,
809
- // Audit records link to the amended memory instead of sharing its topic,
810
- // keeping them out of the topic's evolution candidate pool.
811
- topic: `judgment/${memoryId}`,
812
- summary: `Status '${targetStatus}' applied to ${memoryId}`,
813
- recordKind: 'judgment',
814
- payload: {
815
- amended: memoryId,
816
- status: targetStatus,
817
- supersedes: supersedesTarget,
818
- edges: evolution.edges.map((edge) => ({ type: edge.type, to_id: edge.to_id })),
819
- },
820
- scopes,
821
- agentId: null,
822
- links: [{ relation: 'amends', target: { kind: 'memory', id: memoryId } }],
823
- amends: [
824
- // supersedes is included only when a target resolved: applyAmendment
825
- // writes a column for every key present, so a null here would clear the
826
- // target's predecessor pointer on non-active promotions.
827
- {
828
- target: { kind: 'memory', id: memoryId },
829
- status: targetStatus,
830
- ...(supersedesTarget !== null ? { supersedes: supersedesTarget } : {}),
831
- },
832
- ...supersedesEdges.map((edge) => ({
833
- target: { kind: 'memory', id: edge.to_id },
834
- supersededBy: memoryId,
835
- status: 'superseded',
836
- })),
837
- ],
838
- projections: {
839
- decisionEdges: evolution.edges.map((edge) => ({
840
- fromId: memoryId,
841
- targetId: edge.to_id,
842
- relationship: edge.type,
843
- reason: edge.reason ?? null,
844
- weight: 1,
845
- })),
846
- },
847
- record: {
848
- kind: 'fact',
849
- status: 'active',
850
- summary: `Status '${targetStatus}' applied to ${memoryId}`,
851
- },
852
- recordedAt: now,
853
- event: { reason: `promote ${memoryId} to '${targetStatus}'` },
854
- };
855
- await (0, index_js_1.appendJudgment)(command, (0, write_adapters_js_2.unsignedWriteAccess)(scopes), { adapter });
711
+ const effectiveAccess = access ??
712
+ (0, write_adapters_js_2.writeAccessForProvenance)(provenance, uniqueScopes([...(clean.scopes ?? []), ...namedTargetScopes(adapter, clean)]));
713
+ return saveMemoryInternal(adapter, clean, provenance, effectiveAccess, legacy);
856
714
  }
857
715
  /** Append an access-checked status amendment for one stored memory record. */
858
- async function retireMemoryRecord(adapter, input, access, commandId) {
716
+ async function retireMemoryRecord(adapter, input, access, commandId, session) {
859
717
  const id = input.memoryId.trim();
860
718
  const reason = input.reason.trim();
861
719
  if (!id)
@@ -871,11 +729,30 @@ async function retireMemoryRecord(adapter, input, access, commandId) {
871
729
  }
872
730
  const admittedScopes = record.scopes.filter((recordScope) => access.scopes.some((scope) => scope.kind === recordScope.kind && scope.id === recordScope.id));
873
731
  const summary = `Status '${input.status}' applied to ${id}: ${reason}`;
732
+ // Who retired it, stated by the host as for a save, so a retirement can be traced to its turn.
733
+ const provenance = (0, provenance_js_1.normalizeMemoryWriteProvenance)({
734
+ actor: session?.actor ?? 'main_agent',
735
+ agent_id: access.agentId,
736
+ model_run_id: session?.modelRunId,
737
+ envelope_hash: session?.envelopeHash,
738
+ tool_name: session?.toolName,
739
+ gateway_call_id: session?.gatewayCallId,
740
+ context_packet_id: session?.contextPacketId,
741
+ source_turn_id: session?.sourceTurnId,
742
+ source_message_ref: session?.sourceMessageRef,
743
+ source_refs: session?.sourceRefs ? [...session.sourceRefs] : undefined,
744
+ });
874
745
  const receipt = await (0, index_js_1.appendJudgment)({
875
746
  commandId,
876
747
  topic: `judgment/${id}`,
877
748
  summary,
878
749
  recordKind: 'judgment',
750
+ sourceRefs: provenance.source_refs,
751
+ provenance: provenance.provenance,
752
+ agentId: provenance.agent_id,
753
+ modelRunId: provenance.model_run_id,
754
+ envelopeHash: provenance.envelope_hash,
755
+ gatewayCallId: provenance.gateway_call_id,
879
756
  payload: { amended: id, status: input.status, reason },
880
757
  scopes: admittedScopes,
881
758
  links: [{ relation: 'amends', target: { kind: 'memory', id } }],
@@ -889,12 +766,15 @@ async function buildProfile(adapter, scopes) {
889
766
  const records = await loadScopedMemories(adapter, scopes);
890
767
  return (0, profile_builder_js_1.classifyProfileEntries)(records);
891
768
  }
892
- const EXCLUDED_STATUSES = new Set([
893
- 'superseded',
894
- 'quarantined',
895
- 'contradicted',
896
- 'stale',
897
- ]);
769
+ const EXCLUDED_STATUSES = new Set(search_js_1.RECALL_EXCLUDED_STATUSES);
770
+ /** The ids among these that only amend another record (a retirement or an outcome change). */
771
+ function amendmentIds(adapter, ids) {
772
+ const rows = adapter
773
+ .prepare(`SELECT id FROM decisions WHERE id IN (${ids.map(() => '?').join(',')})
774
+ AND json_extract(payload_json, '$.amended') IS NOT NULL`)
775
+ .all(...ids);
776
+ return new Set(rows.map((row) => row.id));
777
+ }
898
778
  async function recallMemory(adapter, query, options = {}) {
899
779
  const bundle = (0, types_js_1.createEmptyRecallBundle)(query);
900
780
  const searchOptions = (0, search_quality_js_1.normalizeSearchQualityOptions)(options);
@@ -1072,7 +952,7 @@ async function recallMemory(adapter, query, options = {}) {
1072
952
  .map((t) => stemToken(t))
1073
953
  .filter((t) => !FTS5_NOISE_WORDS.has(t));
1074
954
  const ftsQuery = ftsTokens.length > 0 ? ftsTokens.join(' OR ') : query;
1075
- const ftsResults = await (0, search_js_1.fts5Search)(searchAdapter, ftsQuery, lexicalLimit, options.kind);
955
+ const ftsResults = await (0, search_js_1.fts5Search)(searchAdapter, ftsQuery, lexicalLimit, options.kind, options.includeHistory ? undefined : { statuses: [...EXCLUDED_STATUSES], amendments: true });
1076
956
  if (ftsResults.length > 0) {
1077
957
  const adapter = searchAdapter;
1078
958
  const fallbackSource = {
@@ -1154,6 +1034,10 @@ async function recallMemory(adapter, query, options = {}) {
1154
1034
  if (options.kind !== undefined) {
1155
1035
  lexicalRecords = lexicalRecords.filter((r) => matchesKind(r.kind));
1156
1036
  }
1037
+ if (!options.includeHistory && lexicalRecords.length > 0) {
1038
+ const amendments = amendmentIds(adapter, lexicalRecords.map((r) => r.id));
1039
+ lexicalRecords = lexicalRecords.filter((r) => !amendments.has(r.id));
1040
+ }
1157
1041
  lexicalCandidates = buildLexicalCandidates(lexicalRecords, query);
1158
1042
  if (subQueries.length > 1) {
1159
1043
  for (const sq of subQueries.slice(1)) {
@@ -1329,6 +1213,15 @@ async function recallMemory(adapter, query, options = {}) {
1329
1213
  ];
1330
1214
  });
1331
1215
  fusedHits = fusedHits.filter((hit) => hit.source_type !== 'decision' || acceptedPrimaryIds.has(hit.source_id));
1216
+ // A retirement or an outcome change audits another record; it is not a belief. Default recall
1217
+ // leaves it out and history shows it. Only the host writers put `amended` in a payload.
1218
+ if (!options.includeHistory && matched.length > 0) {
1219
+ const amendments = amendmentIds(adapter, matched.map((record) => record.id));
1220
+ if (amendments.size > 0) {
1221
+ matched = matched.filter((record) => !amendments.has(record.id));
1222
+ fusedHits = fusedHits.filter((hit) => hit.source_type !== 'decision' || !amendments.has(hit.source_id));
1223
+ }
1224
+ }
1332
1225
  // Honor options.limit on the final memories (matched is RRF-rank-sorted, canonical
1333
1226
  // dual-write appends last). Without this cap the full fusion set (hundreds of records)
1334
1227
  // flowed into bundle.memories AND the per-record enrichment SQL loops below.
@@ -1340,14 +1233,20 @@ async function recallMemory(adapter, query, options = {}) {
1340
1233
  const cappedIds = new Set(matched.map((record) => record.id));
1341
1234
  fusedHits = fusedHits.filter((hit) => hit.source_type !== 'decision' || cappedIds.has(hit.source_id));
1342
1235
  }
1343
- // Enrich active records with summaries from their superseded predecessors.
1344
- // When ingestConversation extracts multiple facts under the same topic, only
1345
- // the last survives as "active" — the earlier ones become superseded and are
1346
- // excluded from search. This recovers their key information so it is not lost.
1236
+ // Superseded records are excluded from search, so an active record carries what it replaced:
1237
+ // the reader sees the correction and what it corrected. A predecessor is shown only inside the
1238
+ // reader's scopes, and marked as replaced.
1347
1239
  if (matched.length > 0) {
1348
1240
  const stmtChain = adapter.prepare(`SELECT id, summary, decision FROM decisions WHERE superseded_by = ?`);
1241
+ const readerScopes = options.scopes && options.scopes.length > 0
1242
+ ? new Set(options.scopes.map((scope) => `${scope.kind}:${scope.id}`))
1243
+ : null;
1349
1244
  for (const record of matched) {
1350
- const predecessors = stmtChain.all(record.id);
1245
+ let predecessors = stmtChain.all(record.id);
1246
+ if (readerScopes && predecessors.length > 0) {
1247
+ const scopeMap = batchLoadScopes(adapter, predecessors.map((predecessor) => predecessor.id));
1248
+ predecessors = predecessors.filter((predecessor) => (scopeMap.get(predecessor.id) ?? []).some((scope) => readerScopes.has(`${scope.kind}:${scope.id}`)));
1249
+ }
1351
1250
  if (predecessors.length > 0) {
1352
1251
  const extra = predecessors
1353
1252
  .map((p) => String(p.summary ?? p.decision ?? ''))
@@ -1355,8 +1254,8 @@ async function recallMemory(adapter, query, options = {}) {
1355
1254
  .join(' | ');
1356
1255
  if (extra) {
1357
1256
  record.details = record.details
1358
- ? `${record.details}\n[Prior context] ${extra}`
1359
- : `[Prior context] ${extra}`;
1257
+ ? `${record.details}\n[Replaced by this record] ${extra}`
1258
+ : `[Replaced by this record] ${extra}`;
1360
1259
  }
1361
1260
  }
1362
1261
  }
@@ -1366,124 +1265,138 @@ async function recallMemory(adapter, query, options = {}) {
1366
1265
  bundle.graph_context.expanded = [];
1367
1266
  bundle.graph_context.edges = [];
1368
1267
  if (matched.length > 0 && !options.skipGraphExpansion && searchOptions.includeRelated) {
1369
- try {
1370
- const candidates = matched.map((m) => ({
1371
- id: m.id,
1372
- topic: m.topic,
1373
- decision: m.summary,
1374
- confidence: m.confidence,
1375
- created_at: m.created_at,
1376
- similarity: m.confidence ?? 0.5,
1377
- }));
1378
- const expanded = await expandWithGraphInAdapter(adapter, candidates);
1379
- const primaryIds = new Set(matched.map((m) => m.id));
1380
- let expandedOnly = expanded.filter((e) => !primaryIds.has(e.id));
1381
- let expandedScopeMap = new Map();
1382
- // Re-filter expanded results: apply status and scope checks
1383
- if (!options.includeHistory) {
1384
- expandedOnly = expandedOnly.filter((e) => {
1385
- const row = adapter
1386
- .prepare(`SELECT kind, status FROM decisions WHERE id = ?`)
1387
- .get(e.id);
1388
- const status = row?.status || '';
1389
- return matchesKind(row?.kind) && (!status || !EXCLUDED_STATUSES.has(status));
1390
- });
1391
- }
1392
- else if (options.kind !== undefined) {
1393
- expandedOnly = expandedOnly.filter((e) => {
1394
- const row = adapter.prepare(`SELECT kind FROM decisions WHERE id = ?`).get(e.id);
1395
- return matchesKind(row?.kind);
1396
- });
1397
- }
1398
- if (options.scopes && options.scopes.length > 0) {
1399
- const expandedIds = expandedOnly.map((e) => e.id);
1400
- expandedScopeMap = batchLoadScopes(adapter, expandedIds);
1401
- const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
1402
- expandedOnly = expandedOnly.filter((e) => {
1403
- const scopes = expandedScopeMap.get(e.id) ?? [];
1404
- if (scopes.length === 0)
1405
- return false;
1406
- return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
1407
- });
1408
- }
1409
- bundle.graph_context.expanded = expandedOnly.flatMap((e) => {
1410
- const kindRow = adapter.prepare(`SELECT kind FROM decisions WHERE id = ?`).get(e.id);
1411
- const expandedRecord = {
1412
- id: String(e.id),
1413
- topic: String(e.topic || ''),
1414
- kind: (kindRow?.kind ?? 'decision'),
1415
- summary: String(e.decision || ''),
1416
- details: '',
1417
- confidence: e.graph_rank ?? 0.5,
1418
- status: 'active',
1419
- scopes: expandedScopeMap.get(e.id) ?? [],
1420
- source: {
1421
- package: 'mama-core',
1422
- source_type: String(e.graph_source || 'graph_expansion'),
1423
- },
1424
- created_at: e.created_at ?? Date.now(),
1425
- updated_at: e.created_at ?? Date.now(),
1426
- };
1427
- const expandedDiagnostics = buildDiagnostics(expandedRecord, 'expanded');
1428
- if (!passesStrictness(expandedDiagnostics)) {
1429
- diagnostics.candidate_counts.rejected_by_strictness += 1;
1430
- return [];
1431
- }
1432
- return [
1433
- searchOptions.diagnostics
1434
- ? {
1435
- ...expandedRecord,
1436
- retrieval_diagnostics: expandedDiagnostics,
1437
- }
1438
- : expandedRecord,
1439
- ];
1268
+ const candidates = matched.map((m) => ({
1269
+ id: m.id,
1270
+ topic: m.topic,
1271
+ decision: m.summary,
1272
+ confidence: m.confidence,
1273
+ created_at: m.created_at,
1274
+ similarity: m.confidence ?? 0.5,
1275
+ }));
1276
+ const expanded = await expandWithGraphInAdapter(adapter, candidates);
1277
+ const primaryIds = new Set(matched.map((m) => m.id));
1278
+ let expandedOnly = expanded.filter((e) => !primaryIds.has(e.id));
1279
+ let expandedScopeMap = new Map();
1280
+ // Re-filter expanded results: apply status and scope checks
1281
+ if (!options.includeHistory) {
1282
+ expandedOnly = expandedOnly.filter((e) => {
1283
+ const row = adapter.prepare(`SELECT kind, status FROM decisions WHERE id = ?`).get(e.id);
1284
+ const status = row?.status || '';
1285
+ return matchesKind(row?.kind) && (!status || !EXCLUDED_STATUSES.has(status));
1440
1286
  });
1441
- diagnostics.candidate_counts.graph_expanded = bundle.graph_context.expanded.length;
1442
- // Graph-expanded hits are supporting context for the primary matches -
1443
- // they must never OUTRANK them. The previous score ((graph_rank)*0.1,
1444
- // typically 0.05-0.095) sat far above the entire RRF range (<=~0.018),
1445
- // so expansion hits displaced every primary hit from the fused top-N
1446
- // (delta-bench root cause: top-5 filled with related-but-wrong topics
1447
- // while the queried topic's own rows were cut). Scale them into a band
1448
- // strictly below the weakest primary hit, ordered by graph rank.
1449
- const minPrimaryScore = fusedHits.length > 0 ? Math.min(...fusedHits.map((hit) => hit.fused_rank_score)) : 0.002;
1450
- fusedHits = [
1451
- ...fusedHits,
1452
- ...bundle.graph_context.expanded.map((record) => ({
1453
- source_type: 'decision',
1454
- source_id: record.id,
1455
- record,
1456
- fused_rank_score: minPrimaryScore * 0.9 * Math.min(1, Math.max(0.1, record.confidence ?? 0.5)),
1457
- retrieval_diagnostics: record.retrieval_diagnostics,
1458
- })),
1459
- ].sort((left, right) => right.fused_rank_score - left.fused_rank_score);
1460
- // bundle.graph_context.expanded is the strictness-filtered set of
1461
- // expanded nodes that will actually be returned. Use those IDs (not
1462
- // expandedOnly, which still contains rejected candidates) so edges
1463
- // never point at nodes that never made it into the graph payload.
1464
- const acceptedExpandedIds = bundle.graph_context.expanded.map((record) => record.id);
1465
- const allIds = [...matched.map((m) => m.id), ...acceptedExpandedIds];
1466
- const allEdges = await loadEdgesForIds(adapter, allIds);
1467
- // Filter out edges pointing to decisions with excluded statuses
1468
- const activeIds = new Set(allIds);
1469
- const edgesToCheck = allEdges.filter((e) => !activeIds.has(e.to_id) || !activeIds.has(e.from_id));
1470
- if (edgesToCheck.length > 0) {
1471
- const checkIds = [
1472
- ...new Set(edgesToCheck.flatMap((e) => [e.from_id, e.to_id]).filter((id) => !activeIds.has(id))),
1473
- ];
1474
- const placeholders = checkIds.map(() => '?').join(', ');
1475
- const statusRows = adapter
1476
- .prepare(`SELECT id, status FROM decisions WHERE id IN (${placeholders})`)
1477
- .all(...checkIds);
1478
- const excludedIds = new Set(statusRows.filter((r) => r.status && EXCLUDED_STATUSES.has(r.status)).map((r) => r.id));
1479
- bundle.graph_context.edges = allEdges.filter((e) => !excludedIds.has(e.from_id) && !excludedIds.has(e.to_id));
1287
+ // A link an agent stated can point at a retirement or an outcome change; it stays out too.
1288
+ if (expandedOnly.length > 0) {
1289
+ const amendments = amendmentIds(adapter, expandedOnly.map((e) => e.id));
1290
+ expandedOnly = expandedOnly.filter((e) => !amendments.has(e.id));
1480
1291
  }
1481
- else {
1482
- bundle.graph_context.edges = allEdges;
1292
+ }
1293
+ else if (options.kind !== undefined) {
1294
+ expandedOnly = expandedOnly.filter((e) => {
1295
+ const row = adapter.prepare(`SELECT kind FROM decisions WHERE id = ?`).get(e.id);
1296
+ return matchesKind(row?.kind);
1297
+ });
1298
+ }
1299
+ if (options.scopes && options.scopes.length > 0) {
1300
+ const expandedIds = expandedOnly.map((e) => e.id);
1301
+ expandedScopeMap = batchLoadScopes(adapter, expandedIds);
1302
+ const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
1303
+ expandedOnly = expandedOnly.filter((e) => {
1304
+ const scopes = expandedScopeMap.get(e.id) ?? [];
1305
+ if (scopes.length === 0)
1306
+ return false;
1307
+ return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
1308
+ });
1309
+ // A correction is stated by a record; its reason shows under the rule the records follow.
1310
+ const correctionScopes = batchLoadScopes(adapter, expandedOnly.flatMap((e) => correctionAuthors(e.edge_corrected_by ?? [])));
1311
+ const readable = (correction) => (correctionScopes.get(correction.from) ?? []).some((scope) => requestedScopes.has(`${scope.kind}:${scope.id}`));
1312
+ expandedOnly = expandedOnly.map((e) => e.edge_corrected_by
1313
+ ? { ...e, edge_corrected_by: readableCorrections(e.edge_corrected_by, readable) }
1314
+ : e);
1315
+ }
1316
+ bundle.graph_context.expanded = expandedOnly.flatMap((e) => {
1317
+ const kindRow = adapter.prepare(`SELECT kind FROM decisions WHERE id = ?`).get(e.id);
1318
+ const expandedRecord = {
1319
+ id: String(e.id),
1320
+ topic: String(e.topic || ''),
1321
+ kind: (kindRow?.kind ?? 'decision'),
1322
+ summary: String(e.decision || ''),
1323
+ details: '',
1324
+ confidence: e.graph_rank ?? 0.5,
1325
+ status: 'active',
1326
+ scopes: expandedScopeMap.get(e.id) ?? [],
1327
+ source: {
1328
+ package: 'mama-core',
1329
+ source_type: String(e.graph_source || 'graph_expansion'),
1330
+ },
1331
+ created_at: e.created_at ?? Date.now(),
1332
+ updated_at: e.created_at ?? Date.now(),
1333
+ ...(e.related_to
1334
+ ? {
1335
+ reached_through: {
1336
+ from: e.related_to,
1337
+ relation: String(e.graph_source),
1338
+ reason: e.edge_reason ?? null,
1339
+ ...(e.edge_corrected_by ? { corrected_by: e.edge_corrected_by } : {}),
1340
+ },
1341
+ }
1342
+ : {}),
1343
+ };
1344
+ const expandedDiagnostics = buildDiagnostics(expandedRecord, 'expanded');
1345
+ if (!passesStrictness(expandedDiagnostics)) {
1346
+ diagnostics.candidate_counts.rejected_by_strictness += 1;
1347
+ return [];
1483
1348
  }
1349
+ return [
1350
+ searchOptions.diagnostics
1351
+ ? {
1352
+ ...expandedRecord,
1353
+ retrieval_diagnostics: expandedDiagnostics,
1354
+ }
1355
+ : expandedRecord,
1356
+ ];
1357
+ });
1358
+ diagnostics.candidate_counts.graph_expanded = bundle.graph_context.expanded.length;
1359
+ // Graph-expanded hits are supporting context for the primary matches -
1360
+ // they must never OUTRANK them. The previous score ((graph_rank)*0.1,
1361
+ // typically 0.05-0.095) sat far above the entire RRF range (<=~0.018),
1362
+ // so expansion hits displaced every primary hit from the fused top-N
1363
+ // (delta-bench root cause: top-5 filled with related-but-wrong topics
1364
+ // while the queried topic's own rows were cut). Scale them into a band
1365
+ // strictly below the weakest primary hit, ordered by graph rank.
1366
+ const minPrimaryScore = fusedHits.length > 0 ? Math.min(...fusedHits.map((hit) => hit.fused_rank_score)) : 0.002;
1367
+ fusedHits = [
1368
+ ...fusedHits,
1369
+ ...bundle.graph_context.expanded.map((record) => ({
1370
+ source_type: 'decision',
1371
+ source_id: record.id,
1372
+ record,
1373
+ fused_rank_score: minPrimaryScore * 0.9 * Math.min(1, Math.max(0.1, record.confidence ?? 0.5)),
1374
+ retrieval_diagnostics: record.retrieval_diagnostics,
1375
+ })),
1376
+ ].sort((left, right) => right.fused_rank_score - left.fused_rank_score);
1377
+ // bundle.graph_context.expanded is the strictness-filtered set of
1378
+ // expanded nodes that will actually be returned. Use those IDs (not
1379
+ // expandedOnly, which still contains rejected candidates) so edges
1380
+ // never point at nodes that never made it into the graph payload.
1381
+ const acceptedExpandedIds = bundle.graph_context.expanded.map((record) => record.id);
1382
+ const allIds = [...matched.map((m) => m.id), ...acceptedExpandedIds];
1383
+ const allEdges = await loadEdgesForIds(adapter, allIds);
1384
+ // Filter out edges pointing to decisions with excluded statuses
1385
+ const activeIds = new Set(allIds);
1386
+ const edgesToCheck = allEdges.filter((e) => !activeIds.has(e.to_id) || !activeIds.has(e.from_id));
1387
+ if (edgesToCheck.length > 0) {
1388
+ const checkIds = [
1389
+ ...new Set(edgesToCheck.flatMap((e) => [e.from_id, e.to_id]).filter((id) => !activeIds.has(id))),
1390
+ ];
1391
+ const placeholders = checkIds.map(() => '?').join(', ');
1392
+ const statusRows = adapter
1393
+ .prepare(`SELECT id, status FROM decisions WHERE id IN (${placeholders})`)
1394
+ .all(...checkIds);
1395
+ const excludedIds = new Set(statusRows.filter((r) => r.status && EXCLUDED_STATUSES.has(r.status)).map((r) => r.id));
1396
+ bundle.graph_context.edges = allEdges.filter((e) => !excludedIds.has(e.from_id) && !excludedIds.has(e.to_id));
1484
1397
  }
1485
- catch {
1486
- // Graph expansion is best-effort; do not fail recall
1398
+ else {
1399
+ bundle.graph_context.edges = allEdges;
1487
1400
  }
1488
1401
  }
1489
1402
  bundle.fused_hits = fusedHits;
@@ -1546,9 +1459,6 @@ async function ingestMemoryInternal(adapter, input) {
1546
1459
  async function ingestMemory(adapter, input) {
1547
1460
  return ingestMemoryInternal(adapter, (0, provenance_js_1.sanitizePublicIngestMemoryInput)(input));
1548
1461
  }
1549
- async function evolveMemory(input) {
1550
- return (0, evolution_engine_js_1.resolveMemoryEvolution)(input);
1551
- }
1552
1462
  async function buildMemoryBootstrap(adapter, params) {
1553
1463
  return (0, bootstrap_builder_js_1.buildMemoryAgentBootstrap)(adapter, params);
1554
1464
  }
@@ -1661,6 +1571,7 @@ function confidenceValue(record, fallback) {
1661
1571
  async function expandWithGraphInAdapter(adapter, candidates) {
1662
1572
  const graphEnhanced = new Map(); // Use Map for deduplication by ID
1663
1573
  const primaryIds = new Set(candidates.map((c) => c.id)); // Track primary candidates
1574
+ const reachedThrough = new Map(); // expanded record id -> the link's edge id
1664
1575
  // Process each candidate
1665
1576
  for (const candidate of candidates) {
1666
1577
  // Add primary candidate with higher rank
@@ -1671,97 +1582,96 @@ async function expandWithGraphInAdapter(adapter, candidates) {
1671
1582
  graph_rank: 1.0, // Highest rank
1672
1583
  });
1673
1584
  }
1674
- // 1. Add supersedes chain (evolution history)
1675
- try {
1676
- const chain = await (0, graph_query_js_1.queryDecisionGraph)(adapter, candidate.topic, candidate.id);
1677
- for (const decision of chain) {
1678
- if (!graphEnhanced.has(decision.id)) {
1679
- graphEnhanced.set(decision.id, {
1680
- ...decision,
1681
- graph_source: 'supersedes_chain',
1682
- graph_rank: 0.8, // Lower rank than primary
1683
- similarity: (candidate.similarity ?? 0) * 0.9, // Inherit similarity, slightly reduced
1684
- related_to: candidate.id, // Track relationship
1685
- });
1686
- }
1585
+ // 1. The records this one replaced, down its supersedes chain
1586
+ const chain = await (0, graph_query_js_1.queryDecisionGraph)(adapter, candidate.topic, candidate.id);
1587
+ for (const decision of chain) {
1588
+ if (!graphEnhanced.has(decision.id)) {
1589
+ graphEnhanced.set(decision.id, {
1590
+ ...decision,
1591
+ graph_source: 'supersedes_chain',
1592
+ graph_rank: 0.8, // Lower rank than primary
1593
+ similarity: (candidate.similarity ?? 0) * 0.9, // Inherit similarity, slightly reduced
1594
+ related_to: candidate.id, // Track relationship
1595
+ });
1687
1596
  }
1688
1597
  }
1689
- catch (error) {
1690
- (0, debug_logger_js_1.warn)(`Failed to get supersedes chain for ${candidate.topic}: ${error instanceof Error ? error.message : String(error)}`);
1691
- }
1692
- // 2. Add semantic edges (refines, contradicts, builds_on, debates, synthesizes)
1693
- try {
1694
- const rawEdges = (await (0, graph_query_js_1.querySemanticEdges)(adapter, [candidate.id])) || {};
1695
- const edges = {
1696
- refines: rawEdges.refines || [],
1697
- refined_by: rawEdges.refined_by || [],
1698
- contradicts: rawEdges.contradicts || [],
1699
- contradicted_by: rawEdges.contradicted_by || [],
1700
- builds_on: rawEdges.builds_on || [],
1701
- built_on_by: rawEdges.built_on_by || [],
1702
- debates: rawEdges.debates || [],
1703
- debated_by: rawEdges.debated_by || [],
1704
- synthesizes: rawEdges.synthesizes || [],
1705
- synthesized_by: rawEdges.synthesized_by || [],
1706
- };
1707
- // Helper to add edge to graph
1708
- const addEdge = (edge, idField, source, rank, simFactor) => {
1709
- const id = edge[idField];
1710
- if (!graphEnhanced.has(id)) {
1711
- graphEnhanced.set(id, {
1712
- id: id,
1713
- topic: edge.topic,
1714
- decision: edge.decision,
1715
- confidence: edge.confidence,
1716
- created_at: edge.created_at,
1717
- graph_source: source,
1718
- graph_rank: rank,
1719
- similarity: (candidate.similarity ?? 0) * simFactor,
1720
- related_to: candidate.id,
1721
- edge_reason: edge.reason,
1722
- });
1723
- }
1724
- };
1725
- // Add refines edges
1726
- for (const edge of edges.refines) {
1727
- addEdge(edge, 'to_id', 'refines', 0.7, 0.85);
1728
- }
1729
- // Add refined_by edges
1730
- for (const edge of edges.refined_by) {
1731
- addEdge(edge, 'from_id', 'refined_by', 0.7, 0.85);
1732
- }
1733
- // Add contradicts edges (lower rank, but still relevant)
1734
- for (const edge of edges.contradicts) {
1735
- addEdge(edge, 'to_id', 'contradicts', 0.6, 0.8);
1736
- }
1737
- // Story 2.1: Add builds_on edges (high relevance - extending prior work)
1738
- for (const edge of edges.builds_on) {
1739
- addEdge(edge, 'to_id', 'builds_on', 0.75, 0.9);
1740
- }
1741
- // Add built_on_by edges (someone built on this decision)
1742
- for (const edge of edges.built_on_by) {
1743
- addEdge(edge, 'from_id', 'built_on_by', 0.75, 0.9);
1744
- }
1745
- // Add debates edges (alternative view)
1746
- for (const edge of edges.debates) {
1747
- addEdge(edge, 'to_id', 'debates', 0.65, 0.85);
1748
- }
1749
- // Add debated_by edges
1750
- for (const edge of edges.debated_by) {
1751
- addEdge(edge, 'from_id', 'debated_by', 0.65, 0.85);
1752
- }
1753
- // Add synthesizes edges (unified approach)
1754
- for (const edge of edges.synthesizes) {
1755
- addEdge(edge, 'to_id', 'synthesizes', 0.7, 0.88);
1756
- }
1757
- // Add synthesized_by edges
1758
- for (const edge of edges.synthesized_by) {
1759
- addEdge(edge, 'from_id', 'synthesized_by', 0.7, 0.88);
1598
+ // 2. The links an agent stated (refines, contradicts, builds_on, debates, synthesizes)
1599
+ const rawEdges = (await (0, graph_query_js_1.querySemanticEdges)(adapter, [candidate.id])) || {};
1600
+ const edges = {
1601
+ refines: rawEdges.refines || [],
1602
+ refined_by: rawEdges.refined_by || [],
1603
+ contradicts: rawEdges.contradicts || [],
1604
+ contradicted_by: rawEdges.contradicted_by || [],
1605
+ builds_on: rawEdges.builds_on || [],
1606
+ built_on_by: rawEdges.built_on_by || [],
1607
+ debates: rawEdges.debates || [],
1608
+ debated_by: rawEdges.debated_by || [],
1609
+ synthesizes: rawEdges.synthesizes || [],
1610
+ synthesized_by: rawEdges.synthesized_by || [],
1611
+ };
1612
+ // Helper to add edge to graph
1613
+ const addEdge = (edge, idField, source, rank, simFactor) => {
1614
+ const id = edge[idField];
1615
+ if (!graphEnhanced.has(id)) {
1616
+ graphEnhanced.set(id, {
1617
+ id: id,
1618
+ topic: edge.topic,
1619
+ decision: edge.decision,
1620
+ confidence: edge.confidence,
1621
+ created_at: edge.created_at,
1622
+ graph_source: source,
1623
+ graph_rank: rank,
1624
+ similarity: (candidate.similarity ?? 0) * simFactor,
1625
+ related_to: candidate.id,
1626
+ edge_reason: edge.reason,
1627
+ });
1628
+ if (edge.edge_id)
1629
+ reachedThrough.set(id, edge.edge_id);
1760
1630
  }
1631
+ };
1632
+ // Add refines edges
1633
+ for (const edge of edges.refines) {
1634
+ addEdge(edge, 'to_id', 'refines', 0.7, 0.85);
1635
+ }
1636
+ // Add refined_by edges
1637
+ for (const edge of edges.refined_by) {
1638
+ addEdge(edge, 'from_id', 'refined_by', 0.7, 0.85);
1639
+ }
1640
+ // Add contradicts edges (lower rank, but still relevant)
1641
+ for (const edge of edges.contradicts) {
1642
+ addEdge(edge, 'to_id', 'contradicts', 0.6, 0.8);
1643
+ }
1644
+ // Story 2.1: Add builds_on edges (high relevance - extending prior work)
1645
+ for (const edge of edges.builds_on) {
1646
+ addEdge(edge, 'to_id', 'builds_on', 0.75, 0.9);
1761
1647
  }
1762
- catch (error) {
1763
- (0, debug_logger_js_1.warn)(`Failed to get semantic edges for ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`);
1648
+ // Add built_on_by edges (someone built on this decision)
1649
+ for (const edge of edges.built_on_by) {
1650
+ addEdge(edge, 'from_id', 'built_on_by', 0.75, 0.9);
1764
1651
  }
1652
+ // Add debates edges (alternative view)
1653
+ for (const edge of edges.debates) {
1654
+ addEdge(edge, 'to_id', 'debates', 0.65, 0.85);
1655
+ }
1656
+ // Add debated_by edges
1657
+ for (const edge of edges.debated_by) {
1658
+ addEdge(edge, 'from_id', 'debated_by', 0.65, 0.85);
1659
+ }
1660
+ // Add synthesizes edges (unified approach)
1661
+ for (const edge of edges.synthesizes) {
1662
+ addEdge(edge, 'to_id', 'synthesizes', 0.7, 0.88);
1663
+ }
1664
+ // Add synthesized_by edges
1665
+ for (const edge of edges.synthesized_by) {
1666
+ addEdge(edge, 'from_id', 'synthesized_by', 0.7, 0.88);
1667
+ }
1668
+ }
1669
+ // A link the agent later contradicted still leads here; the correction goes with the record.
1670
+ const corrections = (0, decision_links_js_1.correctionsOf)(adapter, [...reachedThrough.values()]);
1671
+ for (const [id, edgeId] of reachedThrough) {
1672
+ const correctedBy = corrections.get(edgeId);
1673
+ if (correctedBy)
1674
+ graphEnhanced.set(id, { ...graphEnhanced.get(id), edge_corrected_by: correctedBy });
1765
1675
  }
1766
1676
  // 3. Convert Map to Array
1767
1677
  const allResults = Array.from(graphEnhanced.values());
@@ -1831,8 +1741,117 @@ function buildRankerMeta(applied, modelId, skippedReason) {
1831
1741
  }
1832
1742
  return meta;
1833
1743
  }
1744
+ /** The relations search follows, and their names seen from the other end. */
1745
+ const POINTER_RELATIONS = {
1746
+ refines: 'refined_by',
1747
+ contradicts: 'contradicted_by',
1748
+ builds_on: 'built_on_by',
1749
+ debates: 'debated_by',
1750
+ synthesizes: 'synthesized_by',
1751
+ supersedes: 'superseded_by',
1752
+ };
1753
+ /**
1754
+ * Each direct hit names the records its stated links reach, in both directions, including a link
1755
+ * to another hit. Expanded rows rank below every direct hit (a measured regression when they did
1756
+ * not) and are cut at the usual limits, so the link comes along on its hit instead, and the
1757
+ * reader opens the record it names. A scoped search names only records in its scopes, and a
1758
+ * correction only when the record stating it is in them.
1759
+ */
1760
+ function withLinkPointers(rows, adapter, scopes) {
1761
+ const hitIds = rows.filter((row) => !row.related_to).map((row) => row.id);
1762
+ if (hitIds.length === 0)
1763
+ return rows;
1764
+ const hits = hitIds.map(() => '?').join(', ');
1765
+ const relations = Object.keys(POINTER_RELATIONS);
1766
+ const edges = adapter
1767
+ .prepare(`SELECT edge_id, from_id, to_id, relationship, reason FROM ${graph_query_js_1.STATED_DECISION_EDGES} e
1768
+ WHERE (from_id IN (${hits}) OR to_id IN (${hits}))
1769
+ AND relationship IN (${relations.map(() => '?').join(', ')})
1770
+ AND (approved_by_user = 1 OR approved_by_user IS NULL)
1771
+ ORDER BY created_at`)
1772
+ .all(...hitIds, ...hitIds, ...relations);
1773
+ if (edges.length === 0)
1774
+ return rows;
1775
+ const hitSet = new Set(hitIds);
1776
+ const otherIds = [...new Set(edges.flatMap((edge) => [edge.from_id, edge.to_id]))];
1777
+ const records = new Map(adapter
1778
+ .prepare(`SELECT id, topic, decision, status FROM decisions
1779
+ WHERE id IN (${otherIds.map(() => '?').join(', ')})`)
1780
+ .all(...otherIds).map((record) => [record.id, record]));
1781
+ const corrections = (0, decision_links_js_1.correctionsOf)(adapter, edges.flatMap((edge) => (edge.edge_id ? [edge.edge_id] : [])));
1782
+ const requested = scopes?.length ? new Set(scopes.map((s) => `${s.kind}:${s.id}`)) : null;
1783
+ const scopeMap = requested
1784
+ ? batchLoadScopes(adapter, [
1785
+ ...otherIds,
1786
+ ...[...corrections.values()].flatMap((list) => correctionAuthors(list)),
1787
+ ])
1788
+ : new Map();
1789
+ const inScope = (id) => !requested ||
1790
+ (scopeMap.get(id) ?? []).some((scope) => requested.has(`${scope.kind}:${scope.id}`));
1791
+ const byHit = new Map();
1792
+ const point = (hitId, otherId, relation, edge) => {
1793
+ const record = records.get(otherId);
1794
+ if (!record || !inScope(otherId))
1795
+ return;
1796
+ const correctedBy = edge.edge_id ? corrections.get(edge.edge_id) : undefined;
1797
+ const readable = correctedBy
1798
+ ? readableCorrections(correctedBy, (correction) => inScope(correction.from))
1799
+ : undefined;
1800
+ const list = byHit.get(hitId) ?? [];
1801
+ list.push({
1802
+ id: record.id,
1803
+ topic: record.topic,
1804
+ summary: record.decision.split('\n')[0].slice(0, 200),
1805
+ ...(record.status && record.status !== 'active' ? { status: record.status } : {}),
1806
+ relation,
1807
+ reason: edge.reason,
1808
+ ...(readable ? { corrected_by: readable } : {}),
1809
+ });
1810
+ byHit.set(hitId, list);
1811
+ };
1812
+ for (const edge of edges) {
1813
+ if (hitSet.has(edge.from_id))
1814
+ point(edge.from_id, edge.to_id, edge.relationship, edge);
1815
+ if (hitSet.has(edge.to_id))
1816
+ point(edge.to_id, edge.from_id, POINTER_RELATIONS[edge.relationship], edge);
1817
+ }
1818
+ return rows.map((row) => {
1819
+ const links = row.related_to ? undefined : byHit.get(row.id);
1820
+ return links ? { ...row, links } : row;
1821
+ });
1822
+ }
1823
+ /**
1824
+ * A hit that is one revision of a work item says which one and the item's head. Search ranks an
1825
+ * item's revisions by their text, so an earlier revision can rank above the one that corrected it
1826
+ * (on a copy of the owner's database a revision 16 of 20 ranked first and the head was not in the
1827
+ * top ten); the reader opens the head before answering from an earlier one.
1828
+ */
1829
+ function withWorkRevision(rows, adapter) {
1830
+ if (rows.length === 0)
1831
+ return rows;
1832
+ const found = new Map(adapter
1833
+ .prepare(`SELECT a.record_id, a.commitment_id, a.revision, c.current_revision
1834
+ FROM commitment_assignments a
1835
+ JOIN commitments c ON c.commitment_id = a.commitment_id
1836
+ WHERE a.record_id IN (${rows.map(() => '?').join(', ')})`)
1837
+ .all(...rows.map((row) => row.id)).map((row) => [row.record_id, row]));
1838
+ return rows.map((row) => {
1839
+ const work = found.get(row.id);
1840
+ return work
1841
+ ? {
1842
+ ...row,
1843
+ work_item: {
1844
+ commitment_id: work.commitment_id,
1845
+ revision: work.revision,
1846
+ head_revision: work.current_revision,
1847
+ },
1848
+ }
1849
+ : row;
1850
+ });
1851
+ }
1834
1852
  function mapRolledUpResult(result) {
1835
1853
  const record = resultRecord(result);
1854
+ const reached = record.reached_through;
1836
1855
  const retrievalDiagnostics = result.retrieval_diagnostics;
1837
1856
  const topic = stringOrNull(record.topic ?? record.title) ?? result.source_id;
1838
1857
  // For wiki_page leaves, prefer the markdown body (`content`) in `decision` so
@@ -1863,10 +1882,13 @@ function mapRolledUpResult(result) {
1863
1882
  created_at: record.created_at ?? null,
1864
1883
  event_date: record.event_date ?? null,
1865
1884
  event_datetime: record.event_datetime ?? null,
1866
- graph_source: retrievalDiagnostics?.graph_source ?? 'primary',
1885
+ // An expanded record says which hit it came from and through which link, so the reader
1886
+ // can tell it from a direct hit and weigh the link's reason (and any correction of it).
1887
+ graph_source: reached?.relation ?? retrievalDiagnostics?.graph_source ?? 'primary',
1867
1888
  graph_rank: 1,
1868
- related_to: null,
1869
- edge_reason: null,
1889
+ related_to: reached?.from ?? null,
1890
+ edge_reason: reached?.reason ?? null,
1891
+ ...(reached?.corrected_by ? { edge_corrected_by: reached.corrected_by } : {}),
1870
1892
  case_id: result.case_id,
1871
1893
  source_type: result.source_type,
1872
1894
  kind: stringOrNull(record.kind) ?? 'decision',
@@ -2026,7 +2048,7 @@ async function suggestInAdapter(adapter, userQuestion, options = {}
2026
2048
  if (rolledUp.length > 0) {
2027
2049
  const filteredResults = rolledUp.slice(0, rerankPoolLimit);
2028
2050
  const { results: mappedResults, meta: rankerMeta } = applyLearnedRanker(filteredResults.map(mapRolledUpResult));
2029
- const limitedResults = mappedResults.slice(0, limit);
2051
+ const limitedResults = withWorkRevision(withLinkPointers(mappedResults.slice(0, limit), adapter, options.scopes), adapter);
2030
2052
  if (format === 'markdown') {
2031
2053
  const context = limitedResults
2032
2054
  .map((result, index) => `${index + 1}. [${result.topic}] ${result.decision}\n ${result.reasoning}`)
@@ -2091,7 +2113,7 @@ async function suggestInAdapter(adapter, userQuestion, options = {}
2091
2113
  : {}),
2092
2114
  }));
2093
2115
  const { results: rankedRows, meta: rankerMeta } = applyLearnedRanker(baseRows);
2094
- const limitedRows = rankedRows.slice(0, limit);
2116
+ const limitedRows = withWorkRevision(withLinkPointers(rankedRows.slice(0, limit), adapter, options.scopes), adapter);
2095
2117
  if (format === 'markdown') {
2096
2118
  const context = limitedRows
2097
2119
  .map((row, index) => `${index + 1}. [${row.topic}] ${row.decision}\n ${row.reasoning}`)