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.
- package/CHANGELOG.md +19 -0
- package/dist/cli/index.js +1165 -464
- package/dist/index.js +933 -316
- package/dist/maintenance-runner.js +514 -159
- package/dist/media-video-runner.js +514 -159
- package/dist/memcode-runtime/CHANGELOG.md +19 -0
- package/dist/sdk.js +932 -315
- package/dist/vector-backfill-runner.js +514 -159
- package/docs/1.4.2-EVIDENCE-GOVERNED-MEMORY-SPEC.md +434 -0
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/codegraph.ts +83 -2
- package/src/codegraph/auto-context.ts +20 -0
- package/src/codegraph/external-provider.ts +1 -0
- package/src/codegraph/store.ts +216 -5
- package/src/codegraph/types.ts +42 -0
- package/src/knowledge/context-assembly.ts +25 -0
- package/src/knowledge/governor.ts +156 -0
- package/src/knowledge/outcome-store.ts +102 -0
- package/src/knowledge/outcome-types.ts +49 -0
- package/src/knowledge/workflows.ts +32 -1
- package/src/knowledge/workset.ts +203 -13
- package/src/store/sqlite-db.ts +42 -0
package/src/codegraph/store.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/codegraph/types.ts
CHANGED
|
@@ -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
|
+
}
|