@jungjaehoon/mama-core 4.1.0 → 5.0.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 (38) 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 +3 -6
  4. package/dist/api/dispatch.js +12 -1
  5. package/dist/db-adapter/node-sqlite-adapter.d.ts +5 -1
  6. package/dist/db-adapter/node-sqlite-adapter.js +27 -24
  7. package/dist/db-manager.d.ts +2 -13
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +3 -6
  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 +11 -3
  15. package/dist/knowledge/index.js +9 -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/mama-api.d.ts +22 -7
  21. package/dist/mama-api.js +64 -15
  22. package/dist/memory/api.d.ts +16 -11
  23. package/dist/memory/api.js +376 -400
  24. package/dist/memory/decision-links.d.ts +54 -0
  25. package/dist/memory/decision-links.js +195 -0
  26. package/dist/memory/graph-read.d.ts +0 -16
  27. package/dist/memory/graph-read.js +10 -51
  28. package/dist/memory/judgment-types.d.ts +1 -20
  29. package/dist/memory/types.d.ts +13 -0
  30. package/dist/memory/write-adapters.d.ts +1 -39
  31. package/dist/memory/write-adapters.js +0 -98
  32. package/package.json +1 -1
  33. package/dist/knowledge/commitment-revision-migration.d.ts +0 -4
  34. package/dist/knowledge/commitment-revision-migration.js +0 -50
  35. package/dist/knowledge/decision-edges.d.ts +0 -70
  36. package/dist/knowledge/decision-edges.js +0 -123
  37. package/dist/memory/evolution-engine.d.ts +0 -22
  38. 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,170 +708,9 @@ 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
716
  async function retireMemoryRecord(adapter, input, access, commandId) {
@@ -1366,124 +1224,133 @@ async function recallMemory(adapter, query, options = {}) {
1366
1224
  bundle.graph_context.expanded = [];
1367
1225
  bundle.graph_context.edges = [];
1368
1226
  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
- ];
1227
+ const candidates = matched.map((m) => ({
1228
+ id: m.id,
1229
+ topic: m.topic,
1230
+ decision: m.summary,
1231
+ confidence: m.confidence,
1232
+ created_at: m.created_at,
1233
+ similarity: m.confidence ?? 0.5,
1234
+ }));
1235
+ const expanded = await expandWithGraphInAdapter(adapter, candidates);
1236
+ const primaryIds = new Set(matched.map((m) => m.id));
1237
+ let expandedOnly = expanded.filter((e) => !primaryIds.has(e.id));
1238
+ let expandedScopeMap = new Map();
1239
+ // Re-filter expanded results: apply status and scope checks
1240
+ if (!options.includeHistory) {
1241
+ expandedOnly = expandedOnly.filter((e) => {
1242
+ const row = adapter.prepare(`SELECT kind, status FROM decisions WHERE id = ?`).get(e.id);
1243
+ const status = row?.status || '';
1244
+ return matchesKind(row?.kind) && (!status || !EXCLUDED_STATUSES.has(status));
1440
1245
  });
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));
1480
- }
1481
- else {
1482
- bundle.graph_context.edges = allEdges;
1246
+ }
1247
+ else if (options.kind !== undefined) {
1248
+ expandedOnly = expandedOnly.filter((e) => {
1249
+ const row = adapter.prepare(`SELECT kind FROM decisions WHERE id = ?`).get(e.id);
1250
+ return matchesKind(row?.kind);
1251
+ });
1252
+ }
1253
+ if (options.scopes && options.scopes.length > 0) {
1254
+ const expandedIds = expandedOnly.map((e) => e.id);
1255
+ expandedScopeMap = batchLoadScopes(adapter, expandedIds);
1256
+ const requestedScopes = new Set(options.scopes.map((s) => `${s.kind}:${s.id}`));
1257
+ expandedOnly = expandedOnly.filter((e) => {
1258
+ const scopes = expandedScopeMap.get(e.id) ?? [];
1259
+ if (scopes.length === 0)
1260
+ return false;
1261
+ return scopes.some((s) => requestedScopes.has(`${s.kind}:${s.id}`));
1262
+ });
1263
+ // A correction is stated by a record; its reason shows under the rule the records follow.
1264
+ const correctionScopes = batchLoadScopes(adapter, expandedOnly.flatMap((e) => correctionAuthors(e.edge_corrected_by ?? [])));
1265
+ const readable = (correction) => (correctionScopes.get(correction.from) ?? []).some((scope) => requestedScopes.has(`${scope.kind}:${scope.id}`));
1266
+ expandedOnly = expandedOnly.map((e) => e.edge_corrected_by
1267
+ ? { ...e, edge_corrected_by: readableCorrections(e.edge_corrected_by, readable) }
1268
+ : e);
1269
+ }
1270
+ bundle.graph_context.expanded = expandedOnly.flatMap((e) => {
1271
+ const kindRow = adapter.prepare(`SELECT kind FROM decisions WHERE id = ?`).get(e.id);
1272
+ const expandedRecord = {
1273
+ id: String(e.id),
1274
+ topic: String(e.topic || ''),
1275
+ kind: (kindRow?.kind ?? 'decision'),
1276
+ summary: String(e.decision || ''),
1277
+ details: '',
1278
+ confidence: e.graph_rank ?? 0.5,
1279
+ status: 'active',
1280
+ scopes: expandedScopeMap.get(e.id) ?? [],
1281
+ source: {
1282
+ package: 'mama-core',
1283
+ source_type: String(e.graph_source || 'graph_expansion'),
1284
+ },
1285
+ created_at: e.created_at ?? Date.now(),
1286
+ updated_at: e.created_at ?? Date.now(),
1287
+ ...(e.related_to
1288
+ ? {
1289
+ reached_through: {
1290
+ from: e.related_to,
1291
+ relation: String(e.graph_source),
1292
+ reason: e.edge_reason ?? null,
1293
+ ...(e.edge_corrected_by ? { corrected_by: e.edge_corrected_by } : {}),
1294
+ },
1295
+ }
1296
+ : {}),
1297
+ };
1298
+ const expandedDiagnostics = buildDiagnostics(expandedRecord, 'expanded');
1299
+ if (!passesStrictness(expandedDiagnostics)) {
1300
+ diagnostics.candidate_counts.rejected_by_strictness += 1;
1301
+ return [];
1483
1302
  }
1303
+ return [
1304
+ searchOptions.diagnostics
1305
+ ? {
1306
+ ...expandedRecord,
1307
+ retrieval_diagnostics: expandedDiagnostics,
1308
+ }
1309
+ : expandedRecord,
1310
+ ];
1311
+ });
1312
+ diagnostics.candidate_counts.graph_expanded = bundle.graph_context.expanded.length;
1313
+ // Graph-expanded hits are supporting context for the primary matches -
1314
+ // they must never OUTRANK them. The previous score ((graph_rank)*0.1,
1315
+ // typically 0.05-0.095) sat far above the entire RRF range (<=~0.018),
1316
+ // so expansion hits displaced every primary hit from the fused top-N
1317
+ // (delta-bench root cause: top-5 filled with related-but-wrong topics
1318
+ // while the queried topic's own rows were cut). Scale them into a band
1319
+ // strictly below the weakest primary hit, ordered by graph rank.
1320
+ const minPrimaryScore = fusedHits.length > 0 ? Math.min(...fusedHits.map((hit) => hit.fused_rank_score)) : 0.002;
1321
+ fusedHits = [
1322
+ ...fusedHits,
1323
+ ...bundle.graph_context.expanded.map((record) => ({
1324
+ source_type: 'decision',
1325
+ source_id: record.id,
1326
+ record,
1327
+ fused_rank_score: minPrimaryScore * 0.9 * Math.min(1, Math.max(0.1, record.confidence ?? 0.5)),
1328
+ retrieval_diagnostics: record.retrieval_diagnostics,
1329
+ })),
1330
+ ].sort((left, right) => right.fused_rank_score - left.fused_rank_score);
1331
+ // bundle.graph_context.expanded is the strictness-filtered set of
1332
+ // expanded nodes that will actually be returned. Use those IDs (not
1333
+ // expandedOnly, which still contains rejected candidates) so edges
1334
+ // never point at nodes that never made it into the graph payload.
1335
+ const acceptedExpandedIds = bundle.graph_context.expanded.map((record) => record.id);
1336
+ const allIds = [...matched.map((m) => m.id), ...acceptedExpandedIds];
1337
+ const allEdges = await loadEdgesForIds(adapter, allIds);
1338
+ // Filter out edges pointing to decisions with excluded statuses
1339
+ const activeIds = new Set(allIds);
1340
+ const edgesToCheck = allEdges.filter((e) => !activeIds.has(e.to_id) || !activeIds.has(e.from_id));
1341
+ if (edgesToCheck.length > 0) {
1342
+ const checkIds = [
1343
+ ...new Set(edgesToCheck.flatMap((e) => [e.from_id, e.to_id]).filter((id) => !activeIds.has(id))),
1344
+ ];
1345
+ const placeholders = checkIds.map(() => '?').join(', ');
1346
+ const statusRows = adapter
1347
+ .prepare(`SELECT id, status FROM decisions WHERE id IN (${placeholders})`)
1348
+ .all(...checkIds);
1349
+ const excludedIds = new Set(statusRows.filter((r) => r.status && EXCLUDED_STATUSES.has(r.status)).map((r) => r.id));
1350
+ bundle.graph_context.edges = allEdges.filter((e) => !excludedIds.has(e.from_id) && !excludedIds.has(e.to_id));
1484
1351
  }
1485
- catch {
1486
- // Graph expansion is best-effort; do not fail recall
1352
+ else {
1353
+ bundle.graph_context.edges = allEdges;
1487
1354
  }
1488
1355
  }
1489
1356
  bundle.fused_hits = fusedHits;
@@ -1546,9 +1413,6 @@ async function ingestMemoryInternal(adapter, input) {
1546
1413
  async function ingestMemory(adapter, input) {
1547
1414
  return ingestMemoryInternal(adapter, (0, provenance_js_1.sanitizePublicIngestMemoryInput)(input));
1548
1415
  }
1549
- async function evolveMemory(input) {
1550
- return (0, evolution_engine_js_1.resolveMemoryEvolution)(input);
1551
- }
1552
1416
  async function buildMemoryBootstrap(adapter, params) {
1553
1417
  return (0, bootstrap_builder_js_1.buildMemoryAgentBootstrap)(adapter, params);
1554
1418
  }
@@ -1661,6 +1525,7 @@ function confidenceValue(record, fallback) {
1661
1525
  async function expandWithGraphInAdapter(adapter, candidates) {
1662
1526
  const graphEnhanced = new Map(); // Use Map for deduplication by ID
1663
1527
  const primaryIds = new Set(candidates.map((c) => c.id)); // Track primary candidates
1528
+ const reachedThrough = new Map(); // expanded record id -> the link's edge id
1664
1529
  // Process each candidate
1665
1530
  for (const candidate of candidates) {
1666
1531
  // Add primary candidate with higher rank
@@ -1671,97 +1536,96 @@ async function expandWithGraphInAdapter(adapter, candidates) {
1671
1536
  graph_rank: 1.0, // Highest rank
1672
1537
  });
1673
1538
  }
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
- }
1539
+ // 1. The records this one replaced, down its supersedes chain
1540
+ const chain = await (0, graph_query_js_1.queryDecisionGraph)(adapter, candidate.topic, candidate.id);
1541
+ for (const decision of chain) {
1542
+ if (!graphEnhanced.has(decision.id)) {
1543
+ graphEnhanced.set(decision.id, {
1544
+ ...decision,
1545
+ graph_source: 'supersedes_chain',
1546
+ graph_rank: 0.8, // Lower rank than primary
1547
+ similarity: (candidate.similarity ?? 0) * 0.9, // Inherit similarity, slightly reduced
1548
+ related_to: candidate.id, // Track relationship
1549
+ });
1687
1550
  }
1688
1551
  }
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);
1552
+ // 2. The links an agent stated (refines, contradicts, builds_on, debates, synthesizes)
1553
+ const rawEdges = (await (0, graph_query_js_1.querySemanticEdges)(adapter, [candidate.id])) || {};
1554
+ const edges = {
1555
+ refines: rawEdges.refines || [],
1556
+ refined_by: rawEdges.refined_by || [],
1557
+ contradicts: rawEdges.contradicts || [],
1558
+ contradicted_by: rawEdges.contradicted_by || [],
1559
+ builds_on: rawEdges.builds_on || [],
1560
+ built_on_by: rawEdges.built_on_by || [],
1561
+ debates: rawEdges.debates || [],
1562
+ debated_by: rawEdges.debated_by || [],
1563
+ synthesizes: rawEdges.synthesizes || [],
1564
+ synthesized_by: rawEdges.synthesized_by || [],
1565
+ };
1566
+ // Helper to add edge to graph
1567
+ const addEdge = (edge, idField, source, rank, simFactor) => {
1568
+ const id = edge[idField];
1569
+ if (!graphEnhanced.has(id)) {
1570
+ graphEnhanced.set(id, {
1571
+ id: id,
1572
+ topic: edge.topic,
1573
+ decision: edge.decision,
1574
+ confidence: edge.confidence,
1575
+ created_at: edge.created_at,
1576
+ graph_source: source,
1577
+ graph_rank: rank,
1578
+ similarity: (candidate.similarity ?? 0) * simFactor,
1579
+ related_to: candidate.id,
1580
+ edge_reason: edge.reason,
1581
+ });
1582
+ if (edge.edge_id)
1583
+ reachedThrough.set(id, edge.edge_id);
1760
1584
  }
1585
+ };
1586
+ // Add refines edges
1587
+ for (const edge of edges.refines) {
1588
+ addEdge(edge, 'to_id', 'refines', 0.7, 0.85);
1589
+ }
1590
+ // Add refined_by edges
1591
+ for (const edge of edges.refined_by) {
1592
+ addEdge(edge, 'from_id', 'refined_by', 0.7, 0.85);
1593
+ }
1594
+ // Add contradicts edges (lower rank, but still relevant)
1595
+ for (const edge of edges.contradicts) {
1596
+ addEdge(edge, 'to_id', 'contradicts', 0.6, 0.8);
1597
+ }
1598
+ // Story 2.1: Add builds_on edges (high relevance - extending prior work)
1599
+ for (const edge of edges.builds_on) {
1600
+ addEdge(edge, 'to_id', 'builds_on', 0.75, 0.9);
1601
+ }
1602
+ // Add built_on_by edges (someone built on this decision)
1603
+ for (const edge of edges.built_on_by) {
1604
+ addEdge(edge, 'from_id', 'built_on_by', 0.75, 0.9);
1605
+ }
1606
+ // Add debates edges (alternative view)
1607
+ for (const edge of edges.debates) {
1608
+ addEdge(edge, 'to_id', 'debates', 0.65, 0.85);
1609
+ }
1610
+ // Add debated_by edges
1611
+ for (const edge of edges.debated_by) {
1612
+ addEdge(edge, 'from_id', 'debated_by', 0.65, 0.85);
1761
1613
  }
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)}`);
1614
+ // Add synthesizes edges (unified approach)
1615
+ for (const edge of edges.synthesizes) {
1616
+ addEdge(edge, 'to_id', 'synthesizes', 0.7, 0.88);
1764
1617
  }
1618
+ // Add synthesized_by edges
1619
+ for (const edge of edges.synthesized_by) {
1620
+ addEdge(edge, 'from_id', 'synthesized_by', 0.7, 0.88);
1621
+ }
1622
+ }
1623
+ // A link the agent later contradicted still leads here; the correction goes with the record.
1624
+ const corrections = (0, decision_links_js_1.correctionsOf)(adapter, [...reachedThrough.values()]);
1625
+ for (const [id, edgeId] of reachedThrough) {
1626
+ const correctedBy = corrections.get(edgeId);
1627
+ if (correctedBy)
1628
+ graphEnhanced.set(id, { ...graphEnhanced.get(id), edge_corrected_by: correctedBy });
1765
1629
  }
1766
1630
  // 3. Convert Map to Array
1767
1631
  const allResults = Array.from(graphEnhanced.values());
@@ -1831,8 +1695,117 @@ function buildRankerMeta(applied, modelId, skippedReason) {
1831
1695
  }
1832
1696
  return meta;
1833
1697
  }
1698
+ /** The relations search follows, and their names seen from the other end. */
1699
+ const POINTER_RELATIONS = {
1700
+ refines: 'refined_by',
1701
+ contradicts: 'contradicted_by',
1702
+ builds_on: 'built_on_by',
1703
+ debates: 'debated_by',
1704
+ synthesizes: 'synthesized_by',
1705
+ supersedes: 'superseded_by',
1706
+ };
1707
+ /**
1708
+ * Each direct hit names the records its stated links reach, in both directions, including a link
1709
+ * to another hit. Expanded rows rank below every direct hit (a measured regression when they did
1710
+ * not) and are cut at the usual limits, so the link comes along on its hit instead, and the
1711
+ * reader opens the record it names. A scoped search names only records in its scopes, and a
1712
+ * correction only when the record stating it is in them.
1713
+ */
1714
+ function withLinkPointers(rows, adapter, scopes) {
1715
+ const hitIds = rows.filter((row) => !row.related_to).map((row) => row.id);
1716
+ if (hitIds.length === 0)
1717
+ return rows;
1718
+ const hits = hitIds.map(() => '?').join(', ');
1719
+ const relations = Object.keys(POINTER_RELATIONS);
1720
+ const edges = adapter
1721
+ .prepare(`SELECT edge_id, from_id, to_id, relationship, reason FROM ${graph_query_js_1.STATED_DECISION_EDGES} e
1722
+ WHERE (from_id IN (${hits}) OR to_id IN (${hits}))
1723
+ AND relationship IN (${relations.map(() => '?').join(', ')})
1724
+ AND (approved_by_user = 1 OR approved_by_user IS NULL)
1725
+ ORDER BY created_at`)
1726
+ .all(...hitIds, ...hitIds, ...relations);
1727
+ if (edges.length === 0)
1728
+ return rows;
1729
+ const hitSet = new Set(hitIds);
1730
+ const otherIds = [...new Set(edges.flatMap((edge) => [edge.from_id, edge.to_id]))];
1731
+ const records = new Map(adapter
1732
+ .prepare(`SELECT id, topic, decision, status FROM decisions
1733
+ WHERE id IN (${otherIds.map(() => '?').join(', ')})`)
1734
+ .all(...otherIds).map((record) => [record.id, record]));
1735
+ const corrections = (0, decision_links_js_1.correctionsOf)(adapter, edges.flatMap((edge) => (edge.edge_id ? [edge.edge_id] : [])));
1736
+ const requested = scopes?.length ? new Set(scopes.map((s) => `${s.kind}:${s.id}`)) : null;
1737
+ const scopeMap = requested
1738
+ ? batchLoadScopes(adapter, [
1739
+ ...otherIds,
1740
+ ...[...corrections.values()].flatMap((list) => correctionAuthors(list)),
1741
+ ])
1742
+ : new Map();
1743
+ const inScope = (id) => !requested ||
1744
+ (scopeMap.get(id) ?? []).some((scope) => requested.has(`${scope.kind}:${scope.id}`));
1745
+ const byHit = new Map();
1746
+ const point = (hitId, otherId, relation, edge) => {
1747
+ const record = records.get(otherId);
1748
+ if (!record || !inScope(otherId))
1749
+ return;
1750
+ const correctedBy = edge.edge_id ? corrections.get(edge.edge_id) : undefined;
1751
+ const readable = correctedBy
1752
+ ? readableCorrections(correctedBy, (correction) => inScope(correction.from))
1753
+ : undefined;
1754
+ const list = byHit.get(hitId) ?? [];
1755
+ list.push({
1756
+ id: record.id,
1757
+ topic: record.topic,
1758
+ summary: record.decision.split('\n')[0].slice(0, 200),
1759
+ ...(record.status && record.status !== 'active' ? { status: record.status } : {}),
1760
+ relation,
1761
+ reason: edge.reason,
1762
+ ...(readable ? { corrected_by: readable } : {}),
1763
+ });
1764
+ byHit.set(hitId, list);
1765
+ };
1766
+ for (const edge of edges) {
1767
+ if (hitSet.has(edge.from_id))
1768
+ point(edge.from_id, edge.to_id, edge.relationship, edge);
1769
+ if (hitSet.has(edge.to_id))
1770
+ point(edge.to_id, edge.from_id, POINTER_RELATIONS[edge.relationship], edge);
1771
+ }
1772
+ return rows.map((row) => {
1773
+ const links = row.related_to ? undefined : byHit.get(row.id);
1774
+ return links ? { ...row, links } : row;
1775
+ });
1776
+ }
1777
+ /**
1778
+ * A hit that is one revision of a work item says which one and the item's head. Search ranks an
1779
+ * item's revisions by their text, so an earlier revision can rank above the one that corrected it
1780
+ * (on a copy of the owner's database a revision 16 of 20 ranked first and the head was not in the
1781
+ * top ten); the reader opens the head before answering from an earlier one.
1782
+ */
1783
+ function withWorkRevision(rows, adapter) {
1784
+ if (rows.length === 0)
1785
+ return rows;
1786
+ const found = new Map(adapter
1787
+ .prepare(`SELECT a.record_id, a.commitment_id, a.revision, c.current_revision
1788
+ FROM commitment_assignments a
1789
+ JOIN commitments c ON c.commitment_id = a.commitment_id
1790
+ WHERE a.record_id IN (${rows.map(() => '?').join(', ')})`)
1791
+ .all(...rows.map((row) => row.id)).map((row) => [row.record_id, row]));
1792
+ return rows.map((row) => {
1793
+ const work = found.get(row.id);
1794
+ return work
1795
+ ? {
1796
+ ...row,
1797
+ work_item: {
1798
+ commitment_id: work.commitment_id,
1799
+ revision: work.revision,
1800
+ head_revision: work.current_revision,
1801
+ },
1802
+ }
1803
+ : row;
1804
+ });
1805
+ }
1834
1806
  function mapRolledUpResult(result) {
1835
1807
  const record = resultRecord(result);
1808
+ const reached = record.reached_through;
1836
1809
  const retrievalDiagnostics = result.retrieval_diagnostics;
1837
1810
  const topic = stringOrNull(record.topic ?? record.title) ?? result.source_id;
1838
1811
  // For wiki_page leaves, prefer the markdown body (`content`) in `decision` so
@@ -1863,10 +1836,13 @@ function mapRolledUpResult(result) {
1863
1836
  created_at: record.created_at ?? null,
1864
1837
  event_date: record.event_date ?? null,
1865
1838
  event_datetime: record.event_datetime ?? null,
1866
- graph_source: retrievalDiagnostics?.graph_source ?? 'primary',
1839
+ // An expanded record says which hit it came from and through which link, so the reader
1840
+ // can tell it from a direct hit and weigh the link's reason (and any correction of it).
1841
+ graph_source: reached?.relation ?? retrievalDiagnostics?.graph_source ?? 'primary',
1867
1842
  graph_rank: 1,
1868
- related_to: null,
1869
- edge_reason: null,
1843
+ related_to: reached?.from ?? null,
1844
+ edge_reason: reached?.reason ?? null,
1845
+ ...(reached?.corrected_by ? { edge_corrected_by: reached.corrected_by } : {}),
1870
1846
  case_id: result.case_id,
1871
1847
  source_type: result.source_type,
1872
1848
  kind: stringOrNull(record.kind) ?? 'decision',
@@ -2026,7 +2002,7 @@ async function suggestInAdapter(adapter, userQuestion, options = {}
2026
2002
  if (rolledUp.length > 0) {
2027
2003
  const filteredResults = rolledUp.slice(0, rerankPoolLimit);
2028
2004
  const { results: mappedResults, meta: rankerMeta } = applyLearnedRanker(filteredResults.map(mapRolledUpResult));
2029
- const limitedResults = mappedResults.slice(0, limit);
2005
+ const limitedResults = withWorkRevision(withLinkPointers(mappedResults.slice(0, limit), adapter, options.scopes), adapter);
2030
2006
  if (format === 'markdown') {
2031
2007
  const context = limitedResults
2032
2008
  .map((result, index) => `${index + 1}. [${result.topic}] ${result.decision}\n ${result.reasoning}`)
@@ -2091,7 +2067,7 @@ async function suggestInAdapter(adapter, userQuestion, options = {}
2091
2067
  : {}),
2092
2068
  }));
2093
2069
  const { results: rankedRows, meta: rankerMeta } = applyLearnedRanker(baseRows);
2094
- const limitedRows = rankedRows.slice(0, limit);
2070
+ const limitedRows = withWorkRevision(withLinkPointers(rankedRows.slice(0, limit), adapter, options.scopes), adapter);
2095
2071
  if (format === 'markdown') {
2096
2072
  const context = limitedRows
2097
2073
  .map((row, index) => `${index + 1}. [${row.topic}] ${row.decision}\n ${row.reasoning}`)