memorix 1.4.1 → 1.4.2

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.
@@ -302,6 +302,7 @@ function providerQuality(input: {
302
302
  capabilities: {
303
303
  declarations: true,
304
304
  importHints: true,
305
+ localRelativeImportTargets: true,
305
306
  resolvedRelations: false,
306
307
  exactLocations: false,
307
308
  },
@@ -1,9 +1,14 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { posix as pathPosix } from 'node:path';
2
3
  import { getDatabase } from '../store/sqlite-db.js';
3
4
  import type {
4
5
  CodeEdge,
5
6
  CodeFile,
7
+ CodeGraphImpactSlice,
6
8
  CodeGraphStatus,
9
+ CodeStateDiff,
10
+ CodeStateFileChange,
11
+ CodeStateSnapshotFile,
7
12
  CodeStateScanCompleteness,
8
13
  CodeStateSnapshot,
9
14
  CodeStateSnapshotInput,
@@ -71,6 +76,41 @@ function rowToEdge(row: any): CodeEdge {
71
76
  } as CodeEdge;
72
77
  }
73
78
 
79
+ function resolveRelativeImportTarget(
80
+ sourcePath: string,
81
+ evidence: string | undefined,
82
+ fileByPath: Map<string, CodeFile>,
83
+ ): CodeFile | undefined {
84
+ const target = evidence?.trim().replace(/\\/g, '/');
85
+ if (!target?.startsWith('.')) return undefined;
86
+ const base = normalizeCodePath(pathPosix.normalize(pathPosix.join(pathPosix.dirname(sourcePath), target)));
87
+ if (!base || base === '..' || base.startsWith('../')) return undefined;
88
+ const candidates = [base];
89
+ const extensionMatch = base.match(/(\.[A-Za-z0-9]+)$/);
90
+ const hasExtension = Boolean(extensionMatch);
91
+ if (!hasExtension) {
92
+ const extensions = [...new Set([...fileByPath.keys()]
93
+ .map(path => path.match(/(\.[A-Za-z0-9]+)$/)?.[1])
94
+ .filter((extension): extension is string => Boolean(extension)))];
95
+ for (const extension of extensions) {
96
+ candidates.push(`${base}${extension}`, `${base}/index${extension}`);
97
+ }
98
+ } else {
99
+ // Node ESM projects commonly import the emitted .js path from a .ts source.
100
+ // Keep an exact path first, then resolve only equivalent local source forms.
101
+ const sourceStem = base.slice(0, -extensionMatch![1].length);
102
+ const sourceExtensions = extensionMatch![1] === '.js'
103
+ ? ['.ts', '.tsx', '.js', '.jsx']
104
+ : extensionMatch![1] === '.mjs'
105
+ ? ['.mts', '.mjs']
106
+ : extensionMatch![1] === '.cjs'
107
+ ? ['.cts', '.cjs']
108
+ : [];
109
+ for (const extension of sourceExtensions) candidates.push(`${sourceStem}${extension}`);
110
+ }
111
+ return candidates.map(candidate => fileByPath.get(candidate)).find((file): file is CodeFile => Boolean(file));
112
+ }
113
+
74
114
  function rowToRef(row: any): ObservationCodeRef {
75
115
  return {
76
116
  id: row.id,
@@ -134,6 +174,22 @@ function rowToSnapshot(row: any): CodeStateSnapshot {
134
174
  } as CodeStateSnapshot;
135
175
  }
136
176
 
177
+ function rowToSnapshotFile(row: any): CodeStateSnapshotFile {
178
+ return {
179
+ snapshotId: row.snapshotId,
180
+ projectId: row.projectId,
181
+ fileId: row.fileId,
182
+ path: row.path,
183
+ contentHash: row.contentHash,
184
+ };
185
+ }
186
+
187
+ function snapshotIsIncomplete(snapshot: CodeStateSnapshot): boolean {
188
+ return snapshot.completeness.skippedOversizedFiles > 0
189
+ || (snapshot.completeness.unreadableFiles ?? 0) > 0
190
+ || snapshot.completeness.removalScanDeferred;
191
+ }
192
+
137
193
  export class CodeGraphStore {
138
194
  private db: any = null;
139
195
  private dataDir: string | null = null;
@@ -312,6 +368,7 @@ export class CodeGraphStore {
312
368
  });
313
369
 
314
370
  tx();
371
+ this.reconcileLocalImportTargets(projectId);
315
372
  }
316
373
 
317
374
  /**
@@ -329,7 +386,10 @@ export class CodeGraphStore {
329
386
  const changed = input.changed.filter((delta) => delta.file.projectId === projectId);
330
387
  const metadataOnly = (input.metadataOnly ?? []).filter((file) => file.projectId === projectId);
331
388
  const removedFileIds = input.removedFileIds ?? [];
332
- if (changed.length === 0 && metadataOnly.length === 0 && removedFileIds.length === 0) return;
389
+ if (changed.length === 0 && metadataOnly.length === 0 && removedFileIds.length === 0) {
390
+ this.reconcileLocalImportTargets(projectId);
391
+ return;
392
+ }
333
393
 
334
394
  const staleRefsForFile = this.db.prepare(`
335
395
  UPDATE observation_code_refs
@@ -340,9 +400,18 @@ export class CodeGraphStore {
340
400
  )
341
401
  )
342
402
  `);
343
- const deleteEdgesForFile = this.db.prepare(`
403
+ const deleteEdgesFromFile = this.db.prepare(`
404
+ DELETE FROM code_edges
405
+ WHERE projectId = ? AND fromFileId = ?
406
+ `);
407
+ const clearImportTargetsForFile = this.db.prepare(`
408
+ UPDATE code_edges
409
+ SET toFileId = NULL
410
+ WHERE projectId = ? AND toFileId = ? AND type = 'imports'
411
+ `);
412
+ const deleteOtherIncomingEdgesForFile = this.db.prepare(`
344
413
  DELETE FROM code_edges
345
- WHERE projectId = ? AND (fromFileId = ? OR toFileId = ?)
414
+ WHERE projectId = ? AND toFileId = ? AND type != 'imports'
346
415
  `);
347
416
  const deleteSymbolsForFile = this.db.prepare(`DELETE FROM code_symbols WHERE projectId = ? AND fileId = ?`);
348
417
  const deleteFile = this.db.prepare(`DELETE FROM code_files WHERE projectId = ? AND id = ?`);
@@ -380,7 +449,9 @@ export class CodeGraphStore {
380
449
  const staleAt = new Date().toISOString();
381
450
  for (const fileId of removedFileIds) {
382
451
  staleRefsForFile.run(staleAt, projectId, fileId, projectId, fileId);
383
- deleteEdgesForFile.run(projectId, fileId, fileId);
452
+ deleteEdgesFromFile.run(projectId, fileId);
453
+ clearImportTargetsForFile.run(projectId, fileId);
454
+ deleteOtherIncomingEdgesForFile.run(projectId, fileId);
384
455
  deleteSymbolsForFile.run(projectId, fileId);
385
456
  deleteFile.run(projectId, fileId);
386
457
  }
@@ -388,7 +459,9 @@ export class CodeGraphStore {
388
459
  for (const delta of changed) {
389
460
  const fileId = delta.file.id;
390
461
  staleRefsForFile.run(staleAt, projectId, fileId, projectId, fileId);
391
- deleteEdgesForFile.run(projectId, fileId, fileId);
462
+ deleteEdgesFromFile.run(projectId, fileId);
463
+ clearImportTargetsForFile.run(projectId, fileId);
464
+ deleteOtherIncomingEdgesForFile.run(projectId, fileId);
392
465
  deleteSymbolsForFile.run(projectId, fileId);
393
466
  writeFile(delta.file);
394
467
 
@@ -429,6 +502,7 @@ export class CodeGraphStore {
429
502
  for (const file of metadataOnly) writeFile(file);
430
503
  });
431
504
  tx();
505
+ this.reconcileLocalImportTargets(projectId);
432
506
  }
433
507
 
434
508
  upsertObservationRefs(refs: ObservationCodeRef[]): void {
@@ -493,6 +567,26 @@ export class CodeGraphStore {
493
567
  tx();
494
568
  }
495
569
 
570
+ /**
571
+ * Lite import extraction records the original import text first. Resolve only
572
+ * local relative imports against the current index after each refresh, so an
573
+ * added or removed target cannot leave a fabricated file-to-file relation.
574
+ */
575
+ reconcileLocalImportTargets(projectId: string): void {
576
+ const fileById = new Map(this.listFiles(projectId).map(file => [file.id, file]));
577
+ const fileByPath = new Map([...fileById.values()].map(file => [file.path, file]));
578
+ const update = this.db.prepare('UPDATE code_edges SET toFileId = ? WHERE id = ?');
579
+ const tx = this.db.transaction(() => {
580
+ for (const edge of this.listEdges(projectId)) {
581
+ if (edge.type !== 'imports' || !edge.fromFileId) continue;
582
+ const source = fileById.get(edge.fromFileId);
583
+ const target = source ? resolveRelativeImportTarget(source.path, edge.evidence, fileByPath) : undefined;
584
+ update.run(target?.id ?? null, edge.id);
585
+ }
586
+ });
587
+ tx();
588
+ }
589
+
496
590
  getFile(projectId: string, path: string): CodeFile | null {
497
591
  const row = this.db.prepare(`SELECT * FROM code_files WHERE projectId = ? AND path = ?`).get(projectId, normalizeCodePath(path));
498
592
  return row ? rowToFile(row) : null;
@@ -577,6 +671,19 @@ export class CodeGraphStore {
577
671
  `).all(projectId).map(rowToRef);
578
672
  }
579
673
 
674
+ /** Count distinct observations whose binding to these exact files is stale. */
675
+ countStaleObservationRefsForFiles(projectId: string, fileIds: string[]): number {
676
+ const ids = [...new Set(fileIds.filter(Boolean))];
677
+ if (ids.length === 0) return 0;
678
+ const placeholders = ids.map(() => '?').join(', ');
679
+ const row = this.db.prepare(`
680
+ SELECT COUNT(DISTINCT observationId) AS count
681
+ FROM observation_code_refs
682
+ WHERE projectId = ? AND status = 'stale' AND fileId IN (${placeholders})
683
+ `).get(projectId, ...ids);
684
+ return Number(row?.count ?? 0);
685
+ }
686
+
580
687
  listReferencedSymbols(projectId: string): CodeSymbol[] {
581
688
  return this.db.prepare(`
582
689
  SELECT DISTINCT symbols.*
@@ -601,6 +708,100 @@ export class CodeGraphStore {
601
708
  ).all(projectId, safeLimit).map(rowToSnapshot);
602
709
  }
603
710
 
711
+ listSnapshotFiles(snapshotId: string): CodeStateSnapshotFile[] {
712
+ return this.db.prepare(`
713
+ SELECT snapshotId, projectId, fileId, path, contentHash
714
+ FROM code_state_snapshot_files
715
+ WHERE snapshotId = ?
716
+ ORDER BY path
717
+ `).all(snapshotId).map(rowToSnapshotFile);
718
+ }
719
+
720
+ diffSnapshots(projectId: string, fromSnapshotId: string, toSnapshotId: string): CodeStateDiff {
721
+ const snapshots = this.db.prepare(`
722
+ SELECT * FROM code_state_snapshots WHERE id IN (?, ?)
723
+ `).all(fromSnapshotId, toSnapshotId).map(rowToSnapshot) as CodeStateSnapshot[];
724
+ if (snapshots.length !== 2) {
725
+ return { projectId, fromSnapshotId, toSnapshotId, available: false, reason: 'missing-snapshot', changes: [] };
726
+ }
727
+ if (snapshots.some(snapshot => snapshot.projectId !== projectId)) {
728
+ return { projectId, fromSnapshotId, toSnapshotId, available: false, reason: 'project-mismatch', changes: [] };
729
+ }
730
+ if (snapshots.some(snapshotIsIncomplete)) {
731
+ return { projectId, fromSnapshotId, toSnapshotId, available: false, reason: 'incomplete-snapshot', changes: [] };
732
+ }
733
+ const before = this.listSnapshotFiles(fromSnapshotId);
734
+ const after = this.listSnapshotFiles(toSnapshotId);
735
+ if (before.length === 0 || after.length === 0) {
736
+ return {
737
+ projectId,
738
+ fromSnapshotId,
739
+ toSnapshotId,
740
+ available: false,
741
+ reason: 'legacy-snapshot-without-manifest',
742
+ changes: [],
743
+ };
744
+ }
745
+ const beforeByPath = new Map(before.map(file => [file.path, file]));
746
+ const afterByPath = new Map(after.map(file => [file.path, file]));
747
+ const paths = [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])].sort();
748
+ const changes: CodeStateFileChange[] = [];
749
+ for (const path of paths) {
750
+ const oldFile = beforeByPath.get(path);
751
+ const newFile = afterByPath.get(path);
752
+ if (!oldFile && newFile) {
753
+ changes.push({ path, kind: 'added', afterHash: newFile.contentHash });
754
+ } else if (oldFile && !newFile) {
755
+ changes.push({ path, kind: 'removed', beforeHash: oldFile.contentHash });
756
+ } else if (oldFile && newFile && oldFile.contentHash !== newFile.contentHash) {
757
+ changes.push({ path, kind: 'modified', beforeHash: oldFile.contentHash, afterHash: newFile.contentHash });
758
+ }
759
+ }
760
+ return { projectId, fromSnapshotId, toSnapshotId, available: true, changes };
761
+ }
762
+
763
+ impactSlice(projectId: string, changedPaths: string[], maxRelations = 24): CodeGraphImpactSlice {
764
+ const safeLimit = Number.isFinite(maxRelations) ? Math.max(1, Math.min(100, Math.floor(maxRelations))) : 24;
765
+ const normalizedPaths = [...new Set(changedPaths.map(normalizeCodePath).filter(Boolean))].sort();
766
+ const files = this.listFiles(projectId);
767
+ const fileById = new Map(files.map(file => [file.id, file.path]));
768
+ const fileIdBySymbolId = new Map(this.listSymbols(projectId).map(symbol => [symbol.id, symbol.fileId]));
769
+ const changedIds = new Set(files.filter(file => normalizedPaths.includes(file.path)).map(file => file.id));
770
+ const direct = new Set<string>();
771
+ let relationCount = 0;
772
+ let truncated = false;
773
+ for (const edge of this.listEdges(projectId)) {
774
+ const endpoints = [
775
+ edge.fromFileId,
776
+ edge.toFileId,
777
+ edge.fromSymbolId ? fileIdBySymbolId.get(edge.fromSymbolId) : undefined,
778
+ edge.toSymbolId ? fileIdBySymbolId.get(edge.toSymbolId) : undefined,
779
+ ].filter((id): id is string => Boolean(id));
780
+ if (!endpoints.some(id => changedIds.has(id))) continue;
781
+ const relatedPaths = endpoints
782
+ .filter(id => !changedIds.has(id))
783
+ .map(id => fileById.get(id))
784
+ .filter((path): path is string => Boolean(path));
785
+ if (relatedPaths.length === 0) continue;
786
+ if (relationCount >= safeLimit) {
787
+ truncated = true;
788
+ break;
789
+ }
790
+ relationCount += 1;
791
+ for (const path of relatedPaths) direct.add(path);
792
+ }
793
+ const snapshot = this.latestSnapshot(projectId);
794
+ return {
795
+ projectId,
796
+ ...(snapshot ? { snapshotId: snapshot.id } : {}),
797
+ changedPaths: normalizedPaths,
798
+ directlyConnectedPaths: [...direct].sort(),
799
+ relationCount,
800
+ truncated,
801
+ provider: snapshot?.provider ?? 'lite',
802
+ };
803
+ }
804
+
604
805
  /**
605
806
  * Record a completed scan and mark all current structural facts with its
606
807
  * epoch. The snapshot is written only after refresh reconciliation succeeds,
@@ -642,6 +843,16 @@ export class CodeGraphStore {
642
843
  this.db.prepare(
643
844
  "UPDATE observation_code_refs SET snapshotId = ? WHERE projectId = ? AND status = 'current'",
644
845
  ).run(id, input.projectId);
846
+ const snapshotFileRows = this.db.prepare(`
847
+ SELECT id, projectId, path, contentHash FROM code_files WHERE projectId = ? ORDER BY path
848
+ `).all(input.projectId) as Array<{ id: string; projectId: string; path: string; contentHash: string }>;
849
+ const insertSnapshotFile = this.db.prepare(`
850
+ INSERT INTO code_state_snapshot_files (snapshotId, projectId, fileId, path, contentHash)
851
+ VALUES (?, ?, ?, ?, ?)
852
+ `);
853
+ for (const file of snapshotFileRows) {
854
+ insertSnapshotFile.run(id, file.projectId, file.id, file.path, file.contentHash);
855
+ }
645
856
  snapshot = {
646
857
  ...input,
647
858
  id,
@@ -116,6 +116,46 @@ export interface CodeStateSnapshot extends CodeStateSnapshotInput {
116
116
  previousSnapshotId?: string;
117
117
  }
118
118
 
119
+ /** Hash-only file state retained for an explicit Code State snapshot. */
120
+ export interface CodeStateSnapshotFile {
121
+ snapshotId: string;
122
+ projectId: string;
123
+ fileId: string;
124
+ path: string;
125
+ contentHash: string;
126
+ }
127
+
128
+ export interface CodeStateFileChange {
129
+ path: string;
130
+ kind: 'added' | 'modified' | 'removed';
131
+ beforeHash?: string;
132
+ afterHash?: string;
133
+ }
134
+
135
+ /**
136
+ * An exact file-level comparison when both snapshots have hash manifests.
137
+ * It intentionally makes no claim about source-level semantic equivalence.
138
+ */
139
+ export interface CodeStateDiff {
140
+ projectId: string;
141
+ fromSnapshotId: string;
142
+ toSnapshotId: string;
143
+ available: boolean;
144
+ reason?: 'missing-snapshot' | 'project-mismatch' | 'legacy-snapshot-without-manifest' | 'incomplete-snapshot';
145
+ changes: CodeStateFileChange[];
146
+ }
147
+
148
+ /** Bounded one-hop structural evidence around changed file paths. */
149
+ export interface CodeGraphImpactSlice {
150
+ projectId: string;
151
+ snapshotId?: string;
152
+ changedPaths: string[];
153
+ directlyConnectedPaths: string[];
154
+ relationCount: number;
155
+ truncated: boolean;
156
+ provider: CodeGraphProviderKind;
157
+ }
158
+
119
159
  export interface CodeGraphStatus {
120
160
  provider: CodeGraphProviderKind;
121
161
  files: number;
@@ -164,6 +204,8 @@ export interface CodeGraphProviderQuality {
164
204
  capabilities: {
165
205
  declarations: boolean;
166
206
  importHints: boolean;
207
+ /** Deterministic local-path mapping only; not semantic name resolution. */
208
+ localRelativeImportTargets: boolean;
167
209
  resolvedRelations: false;
168
210
  exactLocations: false;
169
211
  };
@@ -43,6 +43,22 @@ export interface ContextReceiptOmission {
43
43
  count: number;
44
44
  }
45
45
 
46
+ export interface ContextReceiptGovernanceDecision {
47
+ kind: ContextCandidateKind;
48
+ id?: string;
49
+ disposition: 'include' | 'compact' | 'defer' | 'exclude';
50
+ reasons: string[];
51
+ }
52
+
53
+ /** Privacy-safe evidence qualification, never appended to the agent prompt. */
54
+ export interface ContextReceiptGovernance {
55
+ /** Current implementation qualifies optional memory/knowledge, not caller task facts. */
56
+ scope: 'optional-evidence';
57
+ mode: 'abstain' | 'card' | 'workset';
58
+ decisions: ContextReceiptGovernanceDecision[];
59
+ cautions: string[];
60
+ }
61
+
46
62
  export interface ContextReceipt {
47
63
  version: '1.3';
48
64
  target: ContextDeliveryTarget;
@@ -53,6 +69,7 @@ export interface ContextReceipt {
53
69
  };
54
70
  selected: ContextReceiptSelection[];
55
71
  omitted: ContextReceiptOmission[];
72
+ governance?: ContextReceiptGovernance;
56
73
  scheduledActions: string[];
57
74
  }
58
75
 
@@ -92,6 +109,14 @@ export function formatContextReceipt(receipt: ContextReceipt): string {
92
109
  }
93
110
  }
94
111
 
112
+ if (receipt.governance?.decisions.length) {
113
+ lines.push('', 'Optional evidence qualification');
114
+ for (const item of receipt.governance.decisions.slice(0, 20)) {
115
+ const id = item.id ? ` ${item.id}` : '';
116
+ lines.push(`- ${item.kind}${id}: ${item.disposition} (${item.reasons.join(', ')})`);
117
+ }
118
+ }
119
+
95
120
  if (receipt.scheduledActions.length > 0) {
96
121
  lines.push('', 'Scheduled follow-up');
97
122
  for (const action of receipt.scheduledActions) lines.push(`- ${action}`);
@@ -0,0 +1,156 @@
1
+ import type {
2
+ ContextCandidateFreshness,
3
+ ContextCandidateKind,
4
+ ContextCandidateTrust,
5
+ } from './context-assembly.js';
6
+
7
+ /**
8
+ * A small, serializable description of an item competing for agent context.
9
+ * This is intentionally independent from stores and prompt rendering so every
10
+ * delivery surface can apply the same evidence rules.
11
+ */
12
+ export interface GovernanceCandidate {
13
+ kind: ContextCandidateKind;
14
+ id?: string;
15
+ estimatedTokens: number;
16
+ relevance: number;
17
+ scopeAllowed: boolean;
18
+ freshness?: ContextCandidateFreshness;
19
+ trust?: ContextCandidateTrust;
20
+ quality?: 'verified' | 'probationary' | 'degraded' | 'blocked';
21
+ conflict?: 'none' | 'possible' | 'confirmed';
22
+ evidenceCount?: number;
23
+ }
24
+
25
+ export type GovernanceDisposition = 'include' | 'compact' | 'defer' | 'exclude';
26
+
27
+ export type GovernanceReason =
28
+ | 'scope-forbidden'
29
+ | 'confirmed-conflict'
30
+ | 'blocked-quality'
31
+ | 'degraded-quality'
32
+ | 'stale-evidence'
33
+ | 'unknown-freshness'
34
+ | 'possible-conflict'
35
+ | 'probationary-evidence'
36
+ | 'token-budget'
37
+ | 'current-source-backed-evidence'
38
+ | 'relevant-evidence'
39
+ | 'no-qualified-evidence';
40
+
41
+ export interface GovernanceDecision {
42
+ candidate: GovernanceCandidate;
43
+ disposition: GovernanceDisposition;
44
+ reasons: GovernanceReason[];
45
+ }
46
+
47
+ export interface GovernancePlan {
48
+ mode: 'abstain' | 'card' | 'workset';
49
+ budget: { maxTokens: number; usedTokens: number };
50
+ decisions: GovernanceDecision[];
51
+ cautions: GovernanceReason[];
52
+ }
53
+
54
+ function clampRelevance(value: number): number {
55
+ if (!Number.isFinite(value)) return 0;
56
+ return Math.max(0, Math.min(1, value));
57
+ }
58
+
59
+ function classify(candidate: GovernanceCandidate): Omit<GovernanceDecision, 'candidate'> {
60
+ const freshness = candidate.freshness ?? 'unknown';
61
+ const trust = candidate.trust ?? 'historical';
62
+ const quality = candidate.quality ?? 'probationary';
63
+ const conflict = candidate.conflict ?? 'none';
64
+
65
+ if (!candidate.scopeAllowed) {
66
+ return { disposition: 'exclude', reasons: ['scope-forbidden'] };
67
+ }
68
+ if (conflict === 'confirmed') {
69
+ return { disposition: 'exclude', reasons: ['confirmed-conflict'] };
70
+ }
71
+ if (quality === 'blocked') {
72
+ return { disposition: 'exclude', reasons: ['blocked-quality'] };
73
+ }
74
+ if (quality === 'degraded') {
75
+ return { disposition: 'defer', reasons: ['degraded-quality'] };
76
+ }
77
+ if (freshness === 'stale') {
78
+ return { disposition: 'defer', reasons: ['stale-evidence'] };
79
+ }
80
+
81
+ const reasons: GovernanceReason[] = [];
82
+ let disposition: GovernanceDisposition = 'include';
83
+ if (freshness === 'unknown') {
84
+ disposition = 'compact';
85
+ reasons.push('unknown-freshness');
86
+ }
87
+ if (conflict === 'possible') {
88
+ disposition = 'compact';
89
+ reasons.push('possible-conflict');
90
+ }
91
+ if (quality === 'probationary') {
92
+ disposition = 'compact';
93
+ reasons.push('probationary-evidence');
94
+ }
95
+ if (disposition === 'include' && freshness === 'current' && trust === 'source-backed') {
96
+ reasons.push('current-source-backed-evidence');
97
+ } else if (reasons.length === 0) {
98
+ reasons.push('relevant-evidence');
99
+ }
100
+ return { disposition, reasons };
101
+ }
102
+
103
+ function priority(decision: GovernanceDecision): number {
104
+ const dispositionWeight: Record<GovernanceDisposition, number> = {
105
+ include: 3,
106
+ compact: 2,
107
+ defer: 1,
108
+ exclude: 0,
109
+ };
110
+ const evidence = Math.min(3, decision.candidate.evidenceCount ?? 0) / 10;
111
+ return dispositionWeight[decision.disposition] + clampRelevance(decision.candidate.relevance) + evidence;
112
+ }
113
+
114
+ /**
115
+ * Decide which evidence may enter a bounded context response. The result never
116
+ * mutates memory and deliberately prefers an explicit abstention to a generic
117
+ * history dump.
118
+ */
119
+ export function governContextCandidates(
120
+ candidates: GovernanceCandidate[],
121
+ maxTokens: number,
122
+ ): GovernancePlan {
123
+ const safeBudget = Math.max(0, Math.floor(maxTokens));
124
+ const decisions = candidates.map(candidate => ({ candidate, ...classify(candidate) }));
125
+ const eligible = decisions
126
+ .filter(decision => decision.disposition === 'include' || decision.disposition === 'compact')
127
+ .sort((left, right) => priority(right) - priority(left));
128
+ let usedTokens = 0;
129
+
130
+ for (const decision of eligible) {
131
+ const tokens = Math.max(0, Math.ceil(decision.candidate.estimatedTokens));
132
+ if (usedTokens + tokens <= safeBudget) {
133
+ usedTokens += tokens;
134
+ continue;
135
+ }
136
+ decision.disposition = 'defer';
137
+ decision.reasons = [...decision.reasons, 'token-budget'];
138
+ }
139
+
140
+ const delivered = decisions.filter(decision => decision.disposition === 'include' || decision.disposition === 'compact');
141
+ const mode = delivered.length === 0
142
+ ? 'abstain'
143
+ : delivered.some(decision => decision.disposition === 'include')
144
+ ? 'workset'
145
+ : 'card';
146
+ const cautions = [...new Set(decisions
147
+ .filter(decision => decision.disposition !== 'include')
148
+ .flatMap(decision => decision.reasons))];
149
+
150
+ return {
151
+ mode,
152
+ budget: { maxTokens: safeBudget, usedTokens },
153
+ decisions,
154
+ cautions: cautions.length > 0 ? cautions : ['no-qualified-evidence'],
155
+ };
156
+ }
@@ -0,0 +1,102 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
3
+ import { getDatabase } from '../store/sqlite-db.js';
4
+ import {
5
+ MEMORY_OUTCOME_CANDIDATE_KINDS,
6
+ MEMORY_OUTCOME_SIGNAL_KINDS,
7
+ type MemoryOutcomeCandidateKind,
8
+ type MemoryOutcomeSignal,
9
+ type MemoryOutcomeSignalKind,
10
+ } from './outcome-types.js';
11
+
12
+ function optionalText(value: unknown): string | undefined {
13
+ return typeof value === 'string' && value.trim() ? value : undefined;
14
+ }
15
+
16
+ function text(value: string, field: string, max = 1_000): string {
17
+ const safe = sanitizeCredentials(value).replace(/\s+/g, ' ').trim();
18
+ if (!safe) throw new Error('Outcome signal ' + field + ' is required.');
19
+ if (safe.length > max) throw new Error('Outcome signal ' + field + ' is too long.');
20
+ return safe;
21
+ }
22
+
23
+ function oneOf<T extends readonly string[]>(value: unknown, values: T, field: string): T[number] {
24
+ if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
25
+ throw new Error('Outcome signal ' + field + ' is invalid.');
26
+ }
27
+ return value as T[number];
28
+ }
29
+
30
+ function rowToSignal(row: any): MemoryOutcomeSignal {
31
+ return {
32
+ id: row.id,
33
+ projectId: row.projectId,
34
+ candidateKind: row.candidateKind,
35
+ candidateId: row.candidateId,
36
+ kind: row.kind,
37
+ sourceRef: row.sourceRef,
38
+ ...(optionalText(row.snapshotId) ? { snapshotId: row.snapshotId } : {}),
39
+ ...(optionalText(row.detail) ? { detail: row.detail } : {}),
40
+ observedAt: row.observedAt,
41
+ } as MemoryOutcomeSignal;
42
+ }
43
+
44
+ /** Persistence only; callers decide which real-world event is worth recording. */
45
+ export class OutcomeStore {
46
+ private db: any = null;
47
+
48
+ async init(dataDir: string): Promise<void> {
49
+ this.db = getDatabase(dataDir);
50
+ }
51
+
52
+ record(input: Omit<MemoryOutcomeSignal, 'id' | 'observedAt'> & { observedAt?: string }): MemoryOutcomeSignal {
53
+ const signal: MemoryOutcomeSignal = {
54
+ id: randomUUID(),
55
+ projectId: text(input.projectId, 'project id'),
56
+ candidateKind: oneOf(input.candidateKind, MEMORY_OUTCOME_CANDIDATE_KINDS, 'candidate kind'),
57
+ candidateId: text(input.candidateId, 'candidate id'),
58
+ kind: oneOf(input.kind, MEMORY_OUTCOME_SIGNAL_KINDS, 'kind'),
59
+ sourceRef: text(input.sourceRef, 'source reference'),
60
+ ...(input.snapshotId ? { snapshotId: text(input.snapshotId, 'snapshot id') } : {}),
61
+ ...(input.detail ? { detail: text(input.detail, 'detail') } : {}),
62
+ observedAt: input.observedAt ?? new Date().toISOString(),
63
+ };
64
+ this.db.prepare(`
65
+ INSERT INTO memory_outcome_signals (
66
+ id, projectId, candidateKind, candidateId, kind, sourceRef, snapshotId, detail, observedAt
67
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
68
+ `).run(
69
+ signal.id,
70
+ signal.projectId,
71
+ signal.candidateKind,
72
+ signal.candidateId,
73
+ signal.kind,
74
+ signal.sourceRef,
75
+ signal.snapshotId ?? null,
76
+ signal.detail ?? null,
77
+ signal.observedAt,
78
+ );
79
+ return signal;
80
+ }
81
+
82
+ latestForCandidates(
83
+ projectId: string,
84
+ candidateKind: MemoryOutcomeCandidateKind,
85
+ candidateIds: string[],
86
+ ): Map<string, MemoryOutcomeSignal> {
87
+ const ids = [...new Set(candidateIds.filter(Boolean))];
88
+ if (ids.length === 0) return new Map();
89
+ const placeholders = ids.map(() => '?').join(', ');
90
+ const rows = this.db.prepare(`
91
+ SELECT * FROM memory_outcome_signals
92
+ WHERE projectId = ? AND candidateKind = ? AND candidateId IN (${placeholders})
93
+ ORDER BY observedAt DESC, id DESC
94
+ `).all(projectId, candidateKind, ...ids);
95
+ const latest = new Map<string, MemoryOutcomeSignal>();
96
+ for (const row of rows) {
97
+ const signal = rowToSignal(row);
98
+ if (!latest.has(signal.candidateId)) latest.set(signal.candidateId, signal);
99
+ }
100
+ return latest;
101
+ }
102
+ }