@seanhogg/builderforce-memory 2026.10.27 → 2026.10.29

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 (253) hide show
  1. package/dist/ingest/IngestionPipeline.d.ts +1 -8
  2. package/dist/ingest/IngestionPipeline.d.ts.map +1 -1
  3. package/dist/ingest/IngestionPipeline.js +3 -15
  4. package/dist/ingest/IngestionPipeline.js.map +1 -1
  5. package/dist/ingest/docsExtract.d.ts +30 -0
  6. package/dist/ingest/docsExtract.d.ts.map +1 -0
  7. package/dist/ingest/docsExtract.js +221 -0
  8. package/dist/ingest/docsExtract.js.map +1 -0
  9. package/dist/ingest/hash.d.ts +15 -0
  10. package/dist/ingest/hash.d.ts.map +1 -0
  11. package/dist/ingest/hash.js +22 -0
  12. package/dist/ingest/hash.js.map +1 -0
  13. package/dist/ingest/parsers.d.ts.map +1 -1
  14. package/dist/ingest/parsers.js +4 -0
  15. package/dist/ingest/parsers.js.map +1 -1
  16. package/dist/ingest/types.d.ts +7 -0
  17. package/dist/ingest/types.d.ts.map +1 -1
  18. package/dist/ingest/types.js.map +1 -1
  19. package/dist/knowledge/build.d.ts +24 -0
  20. package/dist/knowledge/build.d.ts.map +1 -0
  21. package/dist/knowledge/build.js +263 -0
  22. package/dist/knowledge/build.js.map +1 -0
  23. package/dist/knowledge/cards.d.ts +17 -0
  24. package/dist/knowledge/cards.d.ts.map +1 -0
  25. package/dist/knowledge/cards.js +118 -0
  26. package/dist/knowledge/cards.js.map +1 -0
  27. package/dist/knowledge/findings.d.ts +24 -0
  28. package/dist/knowledge/findings.d.ts.map +1 -0
  29. package/dist/knowledge/findings.js +152 -0
  30. package/dist/knowledge/findings.js.map +1 -0
  31. package/dist/knowledge/importance.d.ts +21 -0
  32. package/dist/knowledge/importance.d.ts.map +1 -0
  33. package/dist/knowledge/importance.js +70 -0
  34. package/dist/knowledge/importance.js.map +1 -0
  35. package/dist/knowledge/index.d.ts +31 -0
  36. package/dist/knowledge/index.d.ts.map +1 -0
  37. package/dist/knowledge/index.js +26 -0
  38. package/dist/knowledge/index.js.map +1 -0
  39. package/dist/knowledge/judge.d.ts +86 -0
  40. package/dist/knowledge/judge.d.ts.map +1 -0
  41. package/dist/knowledge/judge.js +213 -0
  42. package/dist/knowledge/judge.js.map +1 -0
  43. package/dist/knowledge/model.d.ts +333 -0
  44. package/dist/knowledge/model.d.ts.map +1 -0
  45. package/dist/knowledge/model.js +13 -0
  46. package/dist/knowledge/model.js.map +1 -0
  47. package/dist/knowledge/packs/docs.d.ts +17 -0
  48. package/dist/knowledge/packs/docs.d.ts.map +1 -0
  49. package/dist/knowledge/packs/docs.js +89 -0
  50. package/dist/knowledge/packs/docs.js.map +1 -0
  51. package/dist/knowledge/partitions.d.ts +31 -0
  52. package/dist/knowledge/partitions.d.ts.map +1 -0
  53. package/dist/knowledge/partitions.js +135 -0
  54. package/dist/knowledge/partitions.js.map +1 -0
  55. package/dist/knowledge/plan.d.ts +33 -0
  56. package/dist/knowledge/plan.d.ts.map +1 -0
  57. package/dist/knowledge/plan.js +89 -0
  58. package/dist/knowledge/plan.js.map +1 -0
  59. package/dist/knowledge/refs.d.ts +40 -0
  60. package/dist/knowledge/refs.d.ts.map +1 -0
  61. package/dist/knowledge/refs.js +102 -0
  62. package/dist/knowledge/refs.js.map +1 -0
  63. package/dist/knowledge/resolve.d.ts +35 -0
  64. package/dist/knowledge/resolve.d.ts.map +1 -0
  65. package/dist/knowledge/resolve.js +67 -0
  66. package/dist/knowledge/resolve.js.map +1 -0
  67. package/dist/knowledge/review.d.ts +23 -0
  68. package/dist/knowledge/review.d.ts.map +1 -0
  69. package/dist/knowledge/review.js +233 -0
  70. package/dist/knowledge/review.js.map +1 -0
  71. package/dist/knowledge/sources.d.ts +47 -0
  72. package/dist/knowledge/sources.d.ts.map +1 -0
  73. package/dist/knowledge/sources.js +136 -0
  74. package/dist/knowledge/sources.js.map +1 -0
  75. package/dist/knowledge/stores/localSnapshotStore.d.ts +50 -0
  76. package/dist/knowledge/stores/localSnapshotStore.d.ts.map +1 -0
  77. package/dist/knowledge/stores/localSnapshotStore.js +125 -0
  78. package/dist/knowledge/stores/localSnapshotStore.js.map +1 -0
  79. package/dist/knowledge/trace.d.ts +32 -0
  80. package/dist/knowledge/trace.d.ts.map +1 -0
  81. package/dist/knowledge/trace.js +102 -0
  82. package/dist/knowledge/trace.js.map +1 -0
  83. package/dist/knowledge/truncate.d.ts +24 -0
  84. package/dist/knowledge/truncate.d.ts.map +1 -0
  85. package/dist/knowledge/truncate.js +128 -0
  86. package/dist/knowledge/truncate.js.map +1 -0
  87. package/dist/memory/SemanticRecall.d.ts.map +1 -1
  88. package/dist/memory/SemanticRecall.js +3 -1
  89. package/dist/memory/SemanticRecall.js.map +1 -1
  90. package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
  91. package/dist/retrieval/HybridRetriever.js +3 -1
  92. package/dist/retrieval/HybridRetriever.js.map +1 -1
  93. package/dist/retrieval/bm25.d.ts +3 -2
  94. package/dist/retrieval/bm25.d.ts.map +1 -1
  95. package/dist/retrieval/bm25.js +5 -3
  96. package/dist/retrieval/bm25.js.map +1 -1
  97. package/dist/retrieval/fusion.d.ts +2 -1
  98. package/dist/retrieval/fusion.d.ts.map +1 -1
  99. package/dist/retrieval/fusion.js +4 -2
  100. package/dist/retrieval/fusion.js.map +1 -1
  101. package/dist/retrieval/hnsw.d.ts +49 -11
  102. package/dist/retrieval/hnsw.d.ts.map +1 -1
  103. package/dist/retrieval/hnsw.js +57 -29
  104. package/dist/retrieval/hnsw.js.map +1 -1
  105. package/dist/retrieval/index.d.ts +7 -3
  106. package/dist/retrieval/index.d.ts.map +1 -1
  107. package/dist/retrieval/index.js +5 -2
  108. package/dist/retrieval/index.js.map +1 -1
  109. package/dist/retrieval/knowledge-index/bytes.d.ts +43 -0
  110. package/dist/retrieval/knowledge-index/bytes.d.ts.map +1 -0
  111. package/dist/retrieval/knowledge-index/bytes.js +151 -0
  112. package/dist/retrieval/knowledge-index/bytes.js.map +1 -0
  113. package/dist/retrieval/knowledge-index/cascade.d.ts +37 -0
  114. package/dist/retrieval/knowledge-index/cascade.d.ts.map +1 -0
  115. package/dist/retrieval/knowledge-index/cascade.js +111 -0
  116. package/dist/retrieval/knowledge-index/cascade.js.map +1 -0
  117. package/dist/retrieval/knowledge-index/compact.d.ts +18 -0
  118. package/dist/retrieval/knowledge-index/compact.d.ts.map +1 -0
  119. package/dist/retrieval/knowledge-index/compact.js +64 -0
  120. package/dist/retrieval/knowledge-index/compact.js.map +1 -0
  121. package/dist/retrieval/knowledge-index/csr.d.ts +41 -0
  122. package/dist/retrieval/knowledge-index/csr.d.ts.map +1 -0
  123. package/dist/retrieval/knowledge-index/csr.js +76 -0
  124. package/dist/retrieval/knowledge-index/csr.js.map +1 -0
  125. package/dist/retrieval/knowledge-index/digest.d.ts +21 -0
  126. package/dist/retrieval/knowledge-index/digest.d.ts.map +1 -0
  127. package/dist/retrieval/knowledge-index/digest.js +50 -0
  128. package/dist/retrieval/knowledge-index/digest.js.map +1 -0
  129. package/dist/retrieval/knowledge-index/embeddings.d.ts +32 -0
  130. package/dist/retrieval/knowledge-index/embeddings.d.ts.map +1 -0
  131. package/dist/retrieval/knowledge-index/embeddings.js +78 -0
  132. package/dist/retrieval/knowledge-index/embeddings.js.map +1 -0
  133. package/dist/retrieval/knowledge-index/expand.d.ts +19 -0
  134. package/dist/retrieval/knowledge-index/expand.d.ts.map +1 -0
  135. package/dist/retrieval/knowledge-index/expand.js +49 -0
  136. package/dist/retrieval/knowledge-index/expand.js.map +1 -0
  137. package/dist/retrieval/knowledge-index/index.d.ts +40 -0
  138. package/dist/retrieval/knowledge-index/index.d.ts.map +1 -0
  139. package/dist/retrieval/knowledge-index/index.js +134 -0
  140. package/dist/retrieval/knowledge-index/index.js.map +1 -0
  141. package/dist/retrieval/knowledge-index/lexical.d.ts +26 -0
  142. package/dist/retrieval/knowledge-index/lexical.d.ts.map +1 -0
  143. package/dist/retrieval/knowledge-index/lexical.js +76 -0
  144. package/dist/retrieval/knowledge-index/lexical.js.map +1 -0
  145. package/dist/retrieval/knowledge-index/pairs.d.ts +22 -0
  146. package/dist/retrieval/knowledge-index/pairs.d.ts.map +1 -0
  147. package/dist/retrieval/knowledge-index/pairs.js +51 -0
  148. package/dist/retrieval/knowledge-index/pairs.js.map +1 -0
  149. package/dist/retrieval/knowledge-index/prune.d.ts +21 -0
  150. package/dist/retrieval/knowledge-index/prune.d.ts.map +1 -0
  151. package/dist/retrieval/knowledge-index/prune.js +43 -0
  152. package/dist/retrieval/knowledge-index/prune.js.map +1 -0
  153. package/dist/retrieval/knowledge-index/quantize.d.ts +37 -0
  154. package/dist/retrieval/knowledge-index/quantize.d.ts.map +1 -0
  155. package/dist/retrieval/knowledge-index/quantize.js +77 -0
  156. package/dist/retrieval/knowledge-index/quantize.js.map +1 -0
  157. package/dist/retrieval/knowledge-index/serialize.d.ts +18 -0
  158. package/dist/retrieval/knowledge-index/serialize.d.ts.map +1 -0
  159. package/dist/retrieval/knowledge-index/serialize.js +123 -0
  160. package/dist/retrieval/knowledge-index/serialize.js.map +1 -0
  161. package/dist/retrieval/knowledge-index/sha256.d.ts +30 -0
  162. package/dist/retrieval/knowledge-index/sha256.d.ts.map +1 -0
  163. package/dist/retrieval/knowledge-index/sha256.js +130 -0
  164. package/dist/retrieval/knowledge-index/sha256.js.map +1 -0
  165. package/dist/retrieval/knowledge-index/state.d.ts +55 -0
  166. package/dist/retrieval/knowledge-index/state.d.ts.map +1 -0
  167. package/dist/retrieval/knowledge-index/state.js +67 -0
  168. package/dist/retrieval/knowledge-index/state.js.map +1 -0
  169. package/dist/retrieval/knowledge-index/tokenize.d.ts +20 -0
  170. package/dist/retrieval/knowledge-index/tokenize.d.ts.map +1 -0
  171. package/dist/retrieval/knowledge-index/tokenize.js +42 -0
  172. package/dist/retrieval/knowledge-index/tokenize.js.map +1 -0
  173. package/dist/retrieval/knowledge-index/traverse.d.ts +26 -0
  174. package/dist/retrieval/knowledge-index/traverse.d.ts.map +1 -0
  175. package/dist/retrieval/knowledge-index/traverse.js +90 -0
  176. package/dist/retrieval/knowledge-index/traverse.js.map +1 -0
  177. package/dist/retrieval/knowledge-index/types.d.ts +85 -0
  178. package/dist/retrieval/knowledge-index/types.d.ts.map +1 -0
  179. package/dist/retrieval/knowledge-index/types.js +9 -0
  180. package/dist/retrieval/knowledge-index/types.js.map +1 -0
  181. package/dist/retrieval/mmr.d.ts +11 -4
  182. package/dist/retrieval/mmr.d.ts.map +1 -1
  183. package/dist/retrieval/mmr.js +28 -8
  184. package/dist/retrieval/mmr.js.map +1 -1
  185. package/dist/retrieval/order.d.ts +41 -0
  186. package/dist/retrieval/order.d.ts.map +1 -0
  187. package/dist/retrieval/order.js +54 -0
  188. package/dist/retrieval/order.js.map +1 -0
  189. package/dist/retrieval/weightedFusion.d.ts +2 -1
  190. package/dist/retrieval/weightedFusion.d.ts.map +1 -1
  191. package/dist/retrieval/weightedFusion.js +7 -5
  192. package/dist/retrieval/weightedFusion.js.map +1 -1
  193. package/dist/vectorstore/MemoryVectorStore.d.ts.map +1 -1
  194. package/dist/vectorstore/MemoryVectorStore.js +2 -1
  195. package/dist/vectorstore/MemoryVectorStore.js.map +1 -1
  196. package/package.json +16 -5
  197. package/src/ingest/IngestionPipeline.ts +3 -15
  198. package/src/ingest/docsExtract.ts +219 -0
  199. package/src/ingest/hash.ts +22 -0
  200. package/src/ingest/parsers.ts +4 -0
  201. package/src/ingest/types.ts +7 -0
  202. package/src/knowledge/build.test.ts +150 -0
  203. package/src/knowledge/build.ts +266 -0
  204. package/src/knowledge/cards.ts +130 -0
  205. package/src/knowledge/docs.test.ts +118 -0
  206. package/src/knowledge/findings.ts +159 -0
  207. package/src/knowledge/importance.ts +72 -0
  208. package/src/knowledge/index.ts +53 -0
  209. package/src/knowledge/judge.ts +231 -0
  210. package/src/knowledge/model.ts +312 -0
  211. package/src/knowledge/packs/docs.ts +91 -0
  212. package/src/knowledge/partitions.test.ts +98 -0
  213. package/src/knowledge/partitions.ts +119 -0
  214. package/src/knowledge/plan.ts +115 -0
  215. package/src/knowledge/refs.ts +108 -0
  216. package/src/knowledge/resolve.ts +78 -0
  217. package/src/knowledge/review.test.ts +180 -0
  218. package/src/knowledge/review.ts +235 -0
  219. package/src/knowledge/sources.ts +146 -0
  220. package/src/knowledge/stores/localSnapshotStore.test.ts +82 -0
  221. package/src/knowledge/stores/localSnapshotStore.ts +159 -0
  222. package/src/knowledge/testkit.test-util.ts +92 -0
  223. package/src/knowledge/trace.test.ts +91 -0
  224. package/src/knowledge/trace.ts +111 -0
  225. package/src/knowledge/truncate.ts +118 -0
  226. package/src/memory/SemanticRecall.ts +3 -1
  227. package/src/retrieval/HybridRetriever.ts +3 -1
  228. package/src/retrieval/bm25.ts +5 -3
  229. package/src/retrieval/fusion.ts +5 -2
  230. package/src/retrieval/hnsw.ts +82 -35
  231. package/src/retrieval/index.ts +17 -3
  232. package/src/retrieval/knowledge-index/bytes.ts +166 -0
  233. package/src/retrieval/knowledge-index/cascade.ts +125 -0
  234. package/src/retrieval/knowledge-index/compact.ts +68 -0
  235. package/src/retrieval/knowledge-index/csr.ts +102 -0
  236. package/src/retrieval/knowledge-index/digest.ts +54 -0
  237. package/src/retrieval/knowledge-index/embeddings.ts +85 -0
  238. package/src/retrieval/knowledge-index/expand.ts +55 -0
  239. package/src/retrieval/knowledge-index/index.ts +162 -0
  240. package/src/retrieval/knowledge-index/lexical.ts +78 -0
  241. package/src/retrieval/knowledge-index/pairs.ts +57 -0
  242. package/src/retrieval/knowledge-index/prune.ts +48 -0
  243. package/src/retrieval/knowledge-index/quantize.ts +78 -0
  244. package/src/retrieval/knowledge-index/serialize.ts +124 -0
  245. package/src/retrieval/knowledge-index/sha256.ts +122 -0
  246. package/src/retrieval/knowledge-index/state.ts +108 -0
  247. package/src/retrieval/knowledge-index/tokenize.ts +41 -0
  248. package/src/retrieval/knowledge-index/traverse.ts +95 -0
  249. package/src/retrieval/knowledge-index/types.ts +78 -0
  250. package/src/retrieval/mmr.ts +35 -10
  251. package/src/retrieval/order.ts +68 -0
  252. package/src/retrieval/weightedFusion.ts +8 -5
  253. package/src/vectorstore/MemoryVectorStore.ts +2 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * src/knowledge/review.test.ts
3
+ * planReview / reviewDirectory with fake ports: unchanged files are never read, extracted
4
+ * output is never written as memory, a changed hash marks only that source stale, the
5
+ * budget stops judgement (and counts the rest), invalid judgement is dropped and counted,
6
+ * and abort keeps what was written consistent per file.
7
+ */
8
+
9
+ import { InMemoryIngestManifest } from '../ingest/IngestionPipeline.js';
10
+ import { hashText } from '../ingest/hash.js';
11
+ import { buildGraph } from './build.js';
12
+ import type { Extracted, JudgementRequest, KnowledgeStore, ReviewArgs, StoredFact, StoredRelation } from './model.js';
13
+ import { DOCS_PACK } from './packs/docs.js';
14
+ import { planReview } from './plan.js';
15
+ import { assertStoredBatch, reviewDirectory } from './review.js';
16
+ import { fakeJudge, memSource, memStore, ROOT, type SpySource } from './testkit.test-util.js';
17
+
18
+ const BUDGET = { modelCalls: 200, ms: 60_000 };
19
+
20
+ function args(source: SpySource, store: KnowledgeStore, manifest: InMemoryIngestManifest, extra: Partial<ReviewArgs> = {}): ReviewArgs {
21
+ return { source, store, manifest, packs: [DOCS_PACK], budget: BUDGET, ...extra };
22
+ }
23
+
24
+ async function graphOf(source: SpySource, store: KnowledgeStore) {
25
+ const extracted: Extracted[] = [];
26
+ for await (const f of source.list()) {
27
+ const hit = (await store.getExtracted!(f.hash))?.find((e) => e.sourceRef === `src:${ROOT}::${f.path}`);
28
+ if (hit) extracted.push(hit);
29
+ }
30
+ return buildGraph({
31
+ rootId: ROOT, extracted, packs: [DOCS_PACK],
32
+ facts: await store.facts({ rootId: ROOT }), relations: await store.relations({ rootId: ROOT }),
33
+ });
34
+ }
35
+
36
+ const anchoredTo = (facts: StoredFact[], path: string) => facts.filter((f) => f.fileAnchors.some((a) => a.path === path));
37
+
38
+ describe('planReview', () => {
39
+ it('reads only the ignore files — never an unchanged file — and reports skips', async () => {
40
+ const source = memSource({
41
+ '.gitignore': 'skip/\n', 'a.md': '# A\nalpha\n', 'b.md': '# B\nbeta\n', 'img.png': 'x', 'skip/x.md': '# X\n',
42
+ });
43
+ const manifest = new InMemoryIngestManifest();
44
+ await reviewDirectory(args(source, memStore(), manifest));
45
+
46
+ source.files['a.md'] = '# A\nalpha changed\n';
47
+ source.reads.length = 0;
48
+ const plan = await planReview(source, manifest, [DOCS_PACK]);
49
+ expect(source.reads).toEqual(['.gitignore']);
50
+ expect(plan.changed).toEqual(['a.md']);
51
+ expect(plan.unchanged).toBe(1);
52
+ expect(plan.byPack).toEqual({ docs: 2 });
53
+ expect(plan.estimatedModelCalls).toBeGreaterThan(0);
54
+ expect(plan.skipped).toEqual(expect.arrayContaining([
55
+ { path: '.gitignore', reason: 'ignored' }, { path: 'img.png', reason: 'binary' }, { path: 'skip/x.md', reason: 'ignored' },
56
+ ]));
57
+
58
+ source.reads.length = 0;
59
+ await reviewDirectory(args(source, memStore(), manifest));
60
+ expect(source.reads).toEqual(['.gitignore', 'a.md']);
61
+ });
62
+
63
+ it('enforces the size cap and lists removed files', async () => {
64
+ const source = memSource({ 'a.md': '# A\nalpha\n', 'big.md': 'x'.repeat(50) });
65
+ const manifest = new InMemoryIngestManifest();
66
+ await reviewDirectory(args(source, memStore(), manifest));
67
+ delete source.files['a.md'];
68
+ const plan = await planReview(source, manifest, [DOCS_PACK], { maxFileBytes: 10 });
69
+ expect(plan.removed).toEqual(['a.md']);
70
+ expect(plan.skipped).toContainEqual({ path: 'big.md', reason: 'too-large' });
71
+ });
72
+ });
73
+
74
+ describe('reviewDirectory', () => {
75
+ it('never writes extracted output as memory; caches it by content hash instead', async () => {
76
+ const source = memSource({ 'a.md': '# A\nalpha\n', 'b.md': '# B\nbeta\n' });
77
+ const store = memStore();
78
+ const batches: Array<{ facts: StoredFact[]; relations: StoredRelation[] }> = [];
79
+ const write = store.write.bind(store);
80
+ store.write = (b) => { batches.push(b); return write(b); };
81
+ const judge = fakeJudge();
82
+ const report = await reviewDirectory(args(source, store, new InMemoryIngestManifest(), {
83
+ judgement: judge,
84
+ similar: async (ids) => (ids.length >= 2 ? [{ a: ids[0]!, b: ids[1]!, similarity: 0.9 }] : []),
85
+ }));
86
+
87
+ const written = batches.flatMap((b) => [...b.facts, ...b.relations]);
88
+ expect(written.length).toBeGreaterThan(0);
89
+ expect(written.every((x) => x.origin === 'model')).toBe(true);
90
+ expect(report.extracted).toBe(2);
91
+ expect(report.judged).toBe(3); // two sections + one compare
92
+ expect(report.factsWritten).toBeGreaterThan(0);
93
+ expect(await store.getExtracted(hashText('# A\nalpha\n'))).toHaveLength(1);
94
+ expect(await store.relations({ rootId: ROOT })).toContainEqual(expect.objectContaining({ kind: 'contradicts', confidence: 0.9 }));
95
+ expect((await store.scopes())[0]).toMatchObject({ rootId: ROOT, lastDigest: report.digest, packs: ['docs'] });
96
+ });
97
+
98
+ it('the store guard rejects extracted items', async () => {
99
+ const bad = { from: 'src:r::a', to: 'src:r::b', kind: 'links', weight: 1, origin: 'extracted' } as unknown as StoredRelation;
100
+ expect(() => assertStoredBatch({ facts: [], relations: [bad] })).toThrow();
101
+ await expect(memStore().write({ scope: { rootId: ROOT, title: 't', packs: [] }, facts: [], relations: [bad] })).rejects.toThrow();
102
+ });
103
+
104
+ it('a changed hash marks only that source stale; unchanged sections are re-anchored, not re-judged', async () => {
105
+ const source = memSource({ 'a.md': '# A\nalpha\n# A2\nsame\n', 'b.md': '# B\nbeta\n' });
106
+ const store = memStore();
107
+ const manifest = new InMemoryIngestManifest();
108
+ await reviewDirectory(args(source, store, manifest, { judgement: fakeJudge() }));
109
+
110
+ source.files['a.md'] = '# A\nalpha two\n# A2\nsame\n';
111
+ await reviewDirectory(args(source, store, manifest)); // extracted-only: no model
112
+ let g = await graphOf(source, store);
113
+ const facts = await store.facts({ rootId: ROOT });
114
+ const claimsA = anchoredTo(facts, 'a.md').filter((f) => f.key.startsWith('claim:'));
115
+ const claimsB = anchoredTo(facts, 'b.md').filter((f) => f.key.startsWith('claim:'));
116
+ expect(claimsA).toHaveLength(2);
117
+ for (const c of claimsA) expect(g.nodes.find((n) => n.ref === `fact:${c.key}`)?.stale).toBe(true);
118
+ for (const c of claimsB) expect(g.nodes.find((n) => n.ref === `fact:${c.key}`)?.stale).toBeUndefined();
119
+ const rels = await store.relations({ rootId: ROOT });
120
+ expect(rels.filter((r) => r.to.startsWith(`src:${ROOT}::a.md#`) && r.kind === 'about').every((r) => r.staleAt)).toBe(true);
121
+ expect(rels.filter((r) => r.to.startsWith(`src:${ROOT}::b.md#`)).every((r) => !r.staleAt)).toBe(true);
122
+
123
+ // Re-judging: only the changed section costs a call; the unchanged one is fresh again.
124
+ source.files['a.md'] = '# A\nalpha three\n# A2\nsame\n';
125
+ const judge = fakeJudge();
126
+ await reviewDirectory(args(source, store, manifest, { judgement: judge }));
127
+ expect(judge.requests.filter((r) => r.kind === 'extract').map((r) => r.items[0]!.text)).toEqual(['alpha three']);
128
+ g = await graphOf(source, store);
129
+ const same = (await store.facts({ rootId: ROOT })).find((f) => f.key.startsWith('claim:') && f.content.startsWith('Lease ends same'))!;
130
+ expect(g.nodes.find((n) => n.ref === `fact:${same.key}`)?.stale).toBeUndefined();
131
+ });
132
+
133
+ it('the budget stops judgement; the rest is counted unjudged and stays changed', async () => {
134
+ const source = memSource({ 'a.md': '# A\nalpha\n# B\nbeta\n', 'b.md': '# C\ngamma\n' });
135
+ const manifest = new InMemoryIngestManifest();
136
+ const judge = fakeJudge();
137
+ const report = await reviewDirectory(args(source, memStore(), manifest, { judgement: judge, budget: { modelCalls: 1, ms: 60_000 } }));
138
+ expect(judge.requests).toHaveLength(1);
139
+ expect(report.judged).toBe(1);
140
+ expect(report.unjudged).toBe(2);
141
+ expect(report.extracted).toBe(2);
142
+ expect((await planReview(source, manifest, [DOCS_PACK])).changed).toEqual(['a.md', 'b.md']);
143
+ });
144
+
145
+ it('drops invalid judgement output and counts it', async () => {
146
+ const judge = fakeJudge((req: JudgementRequest) => ({
147
+ extract: [{
148
+ ref: req.items[0]!.ref,
149
+ claims: [
150
+ { subject: 'Lease', predicate: 'ends', object: '2027', quote: 'ends 2027' },
151
+ { subject: 'Lease', object: 'x', quote: '' } as never,
152
+ ],
153
+ entities: [{ name: '', type: 'thing' }, { name: 'Lease', type: 'contract' }],
154
+ }],
155
+ invalid: 1,
156
+ }));
157
+ const store = memStore();
158
+ const report = await reviewDirectory(args(memSource({ 'a.md': '# A\nalpha\n' }), store, new InMemoryIngestManifest(), { judgement: judge }));
159
+ expect(report.invalidJudgements).toBe(3);
160
+ const facts = await store.facts({ rootId: ROOT });
161
+ expect(facts.filter((f) => f.key.startsWith('claim:'))).toHaveLength(1);
162
+ expect(facts.map((f) => f.key)).toContain('ent:docs:lease');
163
+ });
164
+
165
+ it('abort between files keeps the written file whole and stops before the next', async () => {
166
+ const source = memSource({ 'a.md': '# A\nalpha\n', 'b.md': '# B\nbeta\n' });
167
+ const store = memStore();
168
+ const controller = new AbortController();
169
+ const report = await reviewDirectory(args(source, store, new InMemoryIngestManifest(), {
170
+ judgement: fakeJudge(),
171
+ signal: controller.signal,
172
+ onProgress: (p) => { if (p.phase === 'write') controller.abort(); },
173
+ }));
174
+ expect(report.aborted).toBe(true);
175
+ const facts = await store.facts({ rootId: ROOT });
176
+ expect(anchoredTo(facts, 'a.md').length).toBeGreaterThan(0);
177
+ expect(anchoredTo(facts, 'b.md')).toHaveLength(0);
178
+ expect(source.reads).not.toContain('b.md');
179
+ });
180
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * knowledge/review.ts — `reviewDirectory`: point it at a folder and it learns what the
3
+ * folder says (PRD 38 §4.3).
4
+ *
5
+ * plan (walk + manifest diff) → per changed file: read · parse (the ONE ParserRegistry,
6
+ * composed from the packs) · extract (cached by content hash) · judge (changed sections
7
+ * of judged kinds, within budget) · write · markStale → analyzers → compare near-duplicate
8
+ * claims → manifest + scope digest.
9
+ *
10
+ * Store judgement, compute perception: only `model` facts and relations are written —
11
+ * `extracted` output goes to the extracted cache, never to memory ({@link assertStoredBatch}).
12
+ * Abort is honoured between files; a file is written whole or not at all. A file whose
13
+ * sections were cut by the budget stays "changed" in the manifest, so continuing re-plans it
14
+ * and judges only the sections not judged yet.
15
+ */
16
+
17
+ import { hashText } from '../ingest/hash.js';
18
+ import { sha256Hex } from '../retrieval/knowledge-index/sha256.js';
19
+ import type {
20
+ ExtractContext, Extracted, KnowledgeParser, KnowledgeStore, NodeRef, ReviewArgs, ReviewReport, ScopeRecord,
21
+ StoredFact, StoredRelation,
22
+ } from './model.js';
23
+ import type { SourceDocument, ParsedSection } from '../ingest/types.js';
24
+ import { SECTION_INDEX_BADGE } from '../ingest/docsExtract.js';
25
+ import { planDetail } from './plan.js';
26
+ import {
27
+ asPathSource, composePacks, filesManifestKey, isBinaryText, judgedManifestKey, manifestMap, resolveRelativePath,
28
+ saveManifestMap,
29
+ } from './sources.js';
30
+ import {
31
+ budgetLeft, compareClaims, DEFAULT_REVIEW_MODEL_CALLS, JudgedBatch, judgeSections, type JudgeBudget,
32
+ } from './judge.js';
33
+ import { entityName, knownKeysOf, resolveEntities } from './resolve.js';
34
+ import { basenameOf, canonicalJson, compareStr, factKeyOf, srcRef } from './refs.js';
35
+
36
+ /** Throws if a batch carries `extracted` items — perception is never memory. */
37
+ export function assertStoredBatch(batch: { facts: readonly StoredFact[]; relations: readonly StoredRelation[] }): void {
38
+ const bad = [...batch.facts, ...batch.relations].find((x) => (x.origin as string) === 'extracted');
39
+ if (bad) throw new Error('knowledge: extracted output is never written to a KnowledgeStore');
40
+ }
41
+
42
+ async function extractedFor(
43
+ store: KnowledgeStore, parser: KnowledgeParser, pack: string, src: SourceDocument, sections: ParsedSection[],
44
+ ctx: ExtractContext, hash: string,
45
+ ): Promise<Extracted> {
46
+ const ref = srcRef(ctx.rootId, src.id);
47
+ const cached = await store.getExtracted?.(hash);
48
+ const hit = cached?.find((e) => e.sourceRef === ref);
49
+ if (hit) return hit;
50
+ const ex: Extracted = parser.extract
51
+ ? { ...parser.extract(src, sections, ctx), sourceHash: hash }
52
+ : {
53
+ sourceRef: ref, sourceHash: hash, edges: [], keys: [{ ref, key: src.id }],
54
+ nodes: [{ ref, kind: 'file', label: basenameOf(src.id), pack, importance: 0, origin: 'extracted', sourceHash: hash }],
55
+ };
56
+ await store.putExtracted?.(hash, [...(cached ?? []).filter((e) => e.sourceRef !== ref), ex]);
57
+ return ex;
58
+ }
59
+
60
+ /** Stored knowledge, indexed for re-anchoring unchanged sections without a model call. */
61
+ class StoredIndex {
62
+ readonly facts: Map<string, StoredFact>;
63
+ private readonly from = new Map<string, StoredRelation[]>();
64
+ private readonly to = new Map<string, StoredRelation[]>();
65
+
66
+ constructor(facts: StoredFact[], relations: StoredRelation[]) {
67
+ this.facts = new Map(facts.map((f) => [f.key, f] as const));
68
+ for (const r of relations) {
69
+ if (r.origin !== 'model') continue;
70
+ (this.from.get(r.from) ?? this.from.set(r.from, []).get(r.from)!).push(r);
71
+ (this.to.get(r.to) ?? this.to.set(r.to, []).get(r.to)!).push(r);
72
+ }
73
+ }
74
+
75
+ /** An unchanged section keeps its claims and mentions; they are re-anchored to the file's new hash. */
76
+ refresh(section: NodeRef, path: string, hash: string, batch: JudgedBatch, entities: Map<string, StoredFact>): void {
77
+ const reanchor = (f: StoredFact): StoredFact => ({
78
+ ...f, fileAnchors: f.fileAnchors.map((a) => (a.path === path ? { ...a, contentHash: hash } : a)),
79
+ });
80
+ const relation = (r: StoredRelation) => batch.relation({ ...r, sourceHash: hash, staleAt: null });
81
+ for (const r of this.to.get(section) ?? []) {
82
+ if (r.kind !== 'about') continue;
83
+ relation(r);
84
+ const key = factKeyOf(r.from);
85
+ const fact = key && (batch.facts.get(key) ?? this.facts.get(key));
86
+ if (fact && fact.origin === 'model') batch.fact(reanchor(fact));
87
+ for (const m of this.from.get(r.from) ?? []) if (m.kind === 'mentions') relation(m);
88
+ }
89
+ for (const r of this.from.get(section) ?? []) {
90
+ if (r.kind !== 'mentions') continue;
91
+ relation(r);
92
+ const ent = entities.get(r.to);
93
+ if (ent && ent.origin === 'model') {
94
+ const next = reanchor(ent);
95
+ entities.set(r.to, next);
96
+ batch.fact(next);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ export async function reviewDirectory(args: ReviewArgs): Promise<ReviewReport> {
103
+ const now = () => Date.now();
104
+ const started = now();
105
+ const { source, store, packs, manifest, signal } = args;
106
+ const rootId = source.rootId;
107
+ const scope = { rootId };
108
+ const progress = (p: Parameters<NonNullable<ReviewArgs['onProgress']>>[0]) => args.onProgress?.(p);
109
+ const report: ReviewReport = {
110
+ rootId, digest: '', extracted: 0, judged: 0, unjudged: 0, invalidJudgements: 0,
111
+ factsWritten: 0, relationsWritten: 0, staleMarked: 0, aborted: false, durationMs: 0,
112
+ };
113
+
114
+ const detail = await planDetail(source, manifest, packs);
115
+ progress({ phase: 'plan', done: detail.files.length, total: detail.files.length });
116
+ const composed = composePacks(packs);
117
+ const packIds = packs.map((p) => p.id).sort(compareStr);
118
+ const scopeRecord: ScopeRecord = { rootId, title: source.title, packs: packIds };
119
+ const [storedFacts, storedRelations] = await Promise.all([store.facts(scope), store.relations(scope)]);
120
+ const stored = new StoredIndex(storedFacts, storedRelations);
121
+ const entities = new Map(storedFacts.filter((f) => f.key.startsWith('ent:')).map((f) => [f.key, f] as const));
122
+ const ctx: ExtractContext = {
123
+ rootId,
124
+ knownKeys: knownKeysOf(resolveEntities([...entities.keys()].map((k) => ({ ref: k as NodeRef, key: entityName(k) })))),
125
+ resolvePath: resolveRelativePath,
126
+ };
127
+ const budget: JudgeBudget = {
128
+ modelCalls: args.budget?.modelCalls ?? DEFAULT_REVIEW_MODEL_CALLS,
129
+ deadline: started + (Number.isFinite(args.budget?.ms) ? args.budget.ms : Number.POSITIVE_INFINITY),
130
+ used: 0, now,
131
+ };
132
+ const batch = new JudgedBatch();
133
+ const judgedPrior = await manifestMap(manifest, judgedManifestKey(rootId));
134
+ const judgedNow = new Map(judgedPrior);
135
+ const filesNow = new Map(detail.prior);
136
+ const newClaims = new Map<string, { text: string; hash: string }>();
137
+
138
+ const write = async (withScope: ScopeRecord = scopeRecord) => {
139
+ const out = batch.take();
140
+ assertStoredBatch(out);
141
+ if (out.facts.length === 0 && out.relations.length === 0 && withScope === scopeRecord) return;
142
+ await store.write({ scope: withScope, ...out });
143
+ report.factsWritten += out.facts.length;
144
+ report.relationsWritten += out.relations.length;
145
+ };
146
+
147
+ const changed = detail.files.filter((f) => f.changed);
148
+ for (const [i, f] of changed.entries()) {
149
+ if (signal?.aborted) { report.aborted = true; break; }
150
+ progress({ phase: 'extract', done: i, total: changed.length, path: f.path });
151
+ const doc = await source.read(f.path);
152
+ if (doc.content.kind === 'text' && isBinaryText(doc.content.text)) continue;
153
+ const src = asPathSource(doc, f.path, f.hash);
154
+ const hit = composed.resolve(src);
155
+ if (!hit) continue;
156
+ const sections = hit.parser.parse(src);
157
+ const ex = await extractedFor(store, hit.parser, hit.pack.id, src, sections, ctx, f.hash);
158
+ report.extracted += 1;
159
+
160
+ const spec = hit.pack.judgement;
161
+ let complete = true;
162
+ if (spec && args.judgement && (spec.claims || spec.entities)) {
163
+ const kinds = new Set(spec.nodeKinds);
164
+ const toJudge: Array<{ ref: NodeRef; text: string }> = [];
165
+ for (const node of ex.nodes) {
166
+ const index = node.badges?.[SECTION_INDEX_BADGE];
167
+ const text = typeof index === 'number' ? sections[index]?.text : undefined;
168
+ if (!kinds.has(node.kind) || !text) continue;
169
+ if (judgedPrior.get(node.ref) === hashText(text)) stored.refresh(node.ref, f.path, f.hash, batch, entities);
170
+ else toJudge.push({ ref: node.ref, text });
171
+ }
172
+ const unjudgedBefore = batch.unjudged;
173
+ progress({ phase: 'judge', done: i, total: changed.length, path: f.path });
174
+ try {
175
+ const claims = await judgeSections({
176
+ port: args.judgement, pack: hit.pack.id, path: f.path, fileHash: f.hash, sections: toJudge,
177
+ budget, batch, entities, ...(signal ? { signal } : {}),
178
+ onJudged: (ref) => judgedNow.set(ref, hashText(toJudge.find((s) => s.ref === ref)!.text)),
179
+ });
180
+ for (const c of claims) newClaims.set(c.ref, { text: c.text, hash: f.hash });
181
+ } catch (err) {
182
+ if (!signal?.aborted) throw err;
183
+ batch.take();
184
+ report.aborted = true;
185
+ break;
186
+ }
187
+ complete = batch.unjudged === unjudgedBefore;
188
+ }
189
+ progress({ phase: 'write', done: i, total: changed.length, path: f.path });
190
+ await write();
191
+ await store.markStale(scope, srcRef(rootId, f.path), f.hash);
192
+ report.staleMarked += 1;
193
+ if (complete) filesNow.set(f.path, f.hash);
194
+ }
195
+
196
+ if (!report.aborted) {
197
+ for (const path of detail.plan.removed) {
198
+ await store.markStale(scope, srcRef(rootId, path), '');
199
+ report.staleMarked += 1;
200
+ filesNow.delete(path);
201
+ }
202
+ for (const pack of packs) {
203
+ if (!pack.analyzers) continue;
204
+ for (const ex of await pack.analyzers.analyse(rootId, signal)) {
205
+ await store.putExtracted?.(ex.sourceHash, [ex]);
206
+ report.extracted += 1;
207
+ }
208
+ }
209
+ progress({ phase: 'resolve', done: 0, total: newClaims.size });
210
+ const wantsCompare = packs.some((p) => p.judgement?.contradictions);
211
+ if (wantsCompare && args.judgement && args.similar && newClaims.size > 0 && budgetLeft(budget)) {
212
+ const pairs = await args.similar([...newClaims.keys()].sort(compareStr));
213
+ const claims = new Map(newClaims);
214
+ for (const p of pairs) {
215
+ for (const id of [p.a, p.b]) {
216
+ const fact = !claims.has(id) ? stored.facts.get(factKeyOf(id as NodeRef) ?? '') : undefined;
217
+ if (fact?.key.startsWith('claim:')) claims.set(id, { text: fact.content, hash: fact.fileAnchors[0]?.contentHash ?? '' });
218
+ }
219
+ }
220
+ progress({ phase: 'judge', done: 0, total: pairs.length });
221
+ await compareClaims({ port: args.judgement, pairs, claims, budget, batch, ...(signal ? { signal } : {}) });
222
+ }
223
+ }
224
+
225
+ report.judged = batch.judged;
226
+ report.unjudged = batch.unjudged;
227
+ report.invalidJudgements = batch.invalid;
228
+ report.digest = sha256Hex(canonicalJson({ rootId, packs: packIds, files: [...filesNow].sort((a, b) => compareStr(a[0], b[0])) }));
229
+ await saveManifestMap(manifest, filesManifestKey(rootId), filesNow);
230
+ await saveManifestMap(manifest, judgedManifestKey(rootId), judgedNow);
231
+ await write(report.aborted ? scopeRecord : { ...scopeRecord, lastDigest: report.digest, lastReviewedAt: new Date(now()).toISOString() });
232
+ report.durationMs = now() - started;
233
+ progress({ phase: 'done', done: changed.length, total: changed.length });
234
+ return report;
235
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * knowledge/sources.ts — what a review needs to know about a file BEFORE (or without)
3
+ * reading it: its pack, its media type, whether it is binary or ignored. Shared by
4
+ * `planReview` and `reviewDirectory`, so the plan's numbers and the run agree.
5
+ */
6
+
7
+ import { ParserRegistry } from '../ingest/parsers.js';
8
+ import { CONTENT_HASH_FIELD, type ChunkFingerprint, type IngestManifest, type SourceDocument } from '../ingest/types.js';
9
+ import type { KnowledgePack, KnowledgeParser } from './model.js';
10
+ import { matchGlob } from './partitions.js';
11
+ import { compareStr } from './refs.js';
12
+
13
+ /** Default per-file size cap (bytes). */
14
+ export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
15
+ /** Ignore files read through the source, when present. */
16
+ export const IGNORE_FILES = ['.gitignore', '.builderforce/ignore'] as const;
17
+ /** Always ignored: VCS internals, installed dependencies, builderforce config. */
18
+ const DEFAULT_IGNORES = ['.git/', 'node_modules/', '.builderforce/', '/.gitignore'];
19
+
20
+ const MEDIA_TYPES: Record<string, string> = {
21
+ md: 'text/markdown', markdown: 'text/markdown', mdx: 'text/markdown',
22
+ html: 'text/html', htm: 'text/html', xhtml: 'application/xhtml+xml', xml: 'application/xml',
23
+ json: 'application/json', csv: 'text/csv', tsv: 'text/tab-separated-values',
24
+ };
25
+
26
+ const BINARY_EXT = new Set([
27
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'icns', 'tif', 'tiff', 'psd', 'avif', 'heic',
28
+ 'mp3', 'mp4', 'mov', 'avi', 'mkv', 'webm', 'wav', 'flac', 'ogg', 'm4a',
29
+ 'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'tar', 'jar', 'war',
30
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a', 'class', 'wasm', 'node', 'pyc',
31
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
32
+ 'ttf', 'otf', 'woff', 'woff2', 'eot', 'sqlite', 'db', 'vsix', 'lock',
33
+ ]);
34
+
35
+ const extOf = (path: string): string => {
36
+ const m = /\.([A-Za-z0-9]+)$/.exec(path);
37
+ return m ? m[1]!.toLowerCase() : '';
38
+ };
39
+
40
+ export function mediaTypeOfPath(path: string): string {
41
+ return MEDIA_TYPES[extOf(path)] ?? 'text/plain';
42
+ }
43
+
44
+ /** By extension — the plan never reads a file to decide. (pdf/office text needs a host extractor.) */
45
+ export function isBinaryPath(path: string): boolean {
46
+ return BINARY_EXT.has(extOf(path));
47
+ }
48
+
49
+ /** A NUL in the first 8 KB means binary, whatever the extension said. */
50
+ export function isBinaryText(text: string): boolean {
51
+ return text.slice(0, 8192).includes('\u0000');
52
+ }
53
+
54
+ /** A read source with its id set to the root-relative path and a media type filled in. */
55
+ export function asPathSource(doc: SourceDocument, path: string, hash: string): SourceDocument {
56
+ const content = doc.content.kind === 'text' && !doc.content.mediaType
57
+ ? { ...doc.content, mediaType: mediaTypeOfPath(path) }
58
+ : doc.content;
59
+ return { ...doc, id: path, content, metadata: { ...(doc.metadata ?? {}), [CONTENT_HASH_FIELD]: hash } };
60
+ }
61
+
62
+ /** A content-free stand-in, so `accepts` can pick a pack without a read. */
63
+ export function stubSource(path: string): SourceDocument {
64
+ return { id: path, tenantId: '', content: { kind: 'text', text: '', mediaType: mediaTypeOfPath(path) } };
65
+ }
66
+
67
+ export interface ComposedPacks {
68
+ /** The ONE registry, composed from the packs' parsers in pack order. */
69
+ registry: ParserRegistry;
70
+ resolve(source: SourceDocument): { parser: KnowledgeParser; pack: KnowledgePack } | undefined;
71
+ }
72
+
73
+ export function composePacks(packs: readonly KnowledgePack[]): ComposedPacks {
74
+ const owner = new Map<KnowledgeParser, KnowledgePack>();
75
+ for (const pack of packs) for (const p of pack.parsers) if (!owner.has(p)) owner.set(p, pack);
76
+ const registry = new ParserRegistry([...owner.keys()]);
77
+ return {
78
+ registry,
79
+ resolve(source) {
80
+ const parser = registry.resolve(source) as KnowledgeParser | undefined;
81
+ const pack = parser && owner.get(parser);
82
+ return parser && pack ? { parser, pack } : undefined;
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * A `.gitignore`-style matcher: `#` comments, `!` negation (last match wins), a trailing
89
+ * `/` for directories only, a `/` elsewhere anchors to the root, otherwise any depth.
90
+ */
91
+ export function ignoreMatcher(texts: readonly string[]): (path: string) => boolean {
92
+ const rules: Array<{ glob: string; negate: boolean; dirOnly: boolean }> = [];
93
+ for (const line of [...DEFAULT_IGNORES, ...texts.flatMap((t) => t.split(/\r?\n/))]) {
94
+ let p = line.replace(/\s+$/, '');
95
+ if (!p || p.startsWith('#')) continue;
96
+ const negate = p.startsWith('!');
97
+ if (negate) p = p.slice(1);
98
+ const dirOnly = p.endsWith('/');
99
+ if (dirOnly) p = p.slice(0, -1);
100
+ const anchored = p.includes('/');
101
+ p = p.replace(/^\//, '');
102
+ if (p) rules.push({ glob: anchored ? p : `**/${p}`, negate, dirOnly });
103
+ }
104
+ return (path) => {
105
+ const segments = path.split('/');
106
+ const dirs = segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).join('/'));
107
+ let ignored = false;
108
+ for (const r of rules) {
109
+ const hit = (r.dirOnly ? dirs : [...dirs, path]).some((c) => matchGlob(c, r.glob));
110
+ if (hit) ignored = !r.negate;
111
+ }
112
+ return ignored;
113
+ };
114
+ }
115
+
116
+ /** Manifest entry holding `{ id: path, hash }` for every reviewed file of a root. */
117
+ export const filesManifestKey = (rootId: string): string => `knowledge:${rootId}`;
118
+ /** Manifest entry holding `{ id: sectionRef, hash: textHash }` for every judged section. */
119
+ export const judgedManifestKey = (rootId: string): string => `knowledge:${rootId}:judged`;
120
+
121
+ export async function manifestMap(manifest: IngestManifest, key: string): Promise<Map<string, string>> {
122
+ const prior: ChunkFingerprint[] = (await manifest.get(key)) ?? [];
123
+ return new Map(prior.map((f) => [f.id, f.hash] as const));
124
+ }
125
+
126
+ export async function saveManifestMap(manifest: IngestManifest, key: string, map: ReadonlyMap<string, string>): Promise<void> {
127
+ const entries = [...map].sort((a, b) => compareStr(a[0], b[0])).map(([id, hash]) => ({ id, hash }));
128
+ await manifest.set(key, entries);
129
+ }
130
+
131
+ /**
132
+ * Resolves a link/import target against the linking file's folder (a leading `/` is the
133
+ * root). Pure path arithmetic: undefined only when the target escapes the root.
134
+ */
135
+ export function resolveRelativePath(fromPath: string, target: string): string | undefined {
136
+ const t = target.replace(/\\/g, '/');
137
+ const out = t.startsWith('/') ? [] : fromPath.split('/').slice(0, -1);
138
+ for (const seg of t.split('/')) {
139
+ if (seg === '' || seg === '.') continue;
140
+ if (seg === '..') {
141
+ if (out.length === 0) return undefined;
142
+ out.pop();
143
+ } else out.push(seg);
144
+ }
145
+ return out.length ? out.join('/') : undefined;
146
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * src/knowledge/stores/localSnapshotStore.test.ts
3
+ * Upserts on the platform's unique keys (fact key; relation from+to+kind; scope rootId),
4
+ * markStale scoped to one source, canonical bytes, and the extracted guard.
5
+ */
6
+
7
+ import type { ScopeRecord, StoredFact, StoredRelation } from '../model.js';
8
+ import { LocalSnapshotStore } from './localSnapshotStore.js';
9
+ import { memPersistence } from '../testkit.test-util.js';
10
+
11
+ const SCOPE: ScopeRecord = { rootId: 'r', title: 'R', packs: ['docs'] };
12
+ const fact = (key: string, content: string): StoredFact => ({
13
+ key, content, source: 's', importance: 0.5, origin: 'model', fileAnchors: [{ path: 'a.md', contentHash: 'h1' }],
14
+ });
15
+ const rel = (to: string, kind: string, extra: Partial<StoredRelation> = {}): StoredRelation => ({
16
+ from: 'fact:claim:1', to: to as StoredRelation['to'], kind, weight: 1, origin: 'model', sourceHash: 'h1', ...extra,
17
+ });
18
+
19
+ const store = () => new LocalSnapshotStore(memPersistence(), { now: () => 'T' });
20
+
21
+ describe('LocalSnapshotStore', () => {
22
+ it('upserts facts by key and relations by (from, to, kind)', async () => {
23
+ const s = store();
24
+ await s.write({ scope: SCOPE, facts: [fact('claim:1', 'v1'), fact('claim:2', 'x')], relations: [rel('src:r::a.md#a', 'about')] });
25
+ await s.write({
26
+ scope: SCOPE,
27
+ facts: [fact('claim:1', 'v2')],
28
+ relations: [rel('src:r::a.md#a', 'about', { weight: 3 }), rel('src:r::a.md#a', 'mentions')],
29
+ });
30
+ const facts = await s.facts({ rootId: 'r' });
31
+ expect(facts.map((f) => [f.key, f.content])).toEqual(expect.arrayContaining([['claim:1', 'v2'], ['claim:2', 'x']]));
32
+ expect(facts).toHaveLength(2);
33
+ const rels = await s.relations({ rootId: 'r' });
34
+ expect(rels).toHaveLength(2);
35
+ expect(rels.find((r) => r.kind === 'about')?.weight).toBe(3);
36
+ expect(await s.facts({ rootId: 'other' })).toEqual([]);
37
+ });
38
+
39
+ it('upserts the scope by rootId without dropping fields a later write omits', async () => {
40
+ const s = store();
41
+ await s.write({ scope: { ...SCOPE, lastDigest: 'd1', lastReviewedAt: 'T1' }, facts: [], relations: [] });
42
+ await s.write({ scope: SCOPE, facts: [], relations: [] });
43
+ expect(await s.scopes()).toEqual([{ ...SCOPE, lastDigest: 'd1', lastReviewedAt: 'T1' }]);
44
+ });
45
+
46
+ it('markStale touches only model relations on that source judged from another hash', async () => {
47
+ const s = store();
48
+ await s.write({
49
+ scope: SCOPE, facts: [],
50
+ relations: [
51
+ rel('src:r::a.md#a', 'about'),
52
+ rel('src:r::a.md#b', 'about', { sourceHash: 'h2' }),
53
+ rel('src:r::b.md#c', 'about'),
54
+ rel('src:r::a.md#d', 'about', { origin: 'human', sourceHash: undefined }),
55
+ ],
56
+ });
57
+ await s.markStale({ rootId: 'r' }, 'src:r::a.md', 'h2');
58
+ const byTo = new Map((await s.relations({ rootId: 'r' })).map((r) => [r.to, r.staleAt] as const));
59
+ expect(byTo.get('src:r::a.md#a')).toBe('T');
60
+ expect(byTo.get('src:r::a.md#b')).toBeUndefined();
61
+ expect(byTo.get('src:r::b.md#c')).toBeUndefined();
62
+ expect(byTo.get('src:r::a.md#d')).toBeUndefined();
63
+ });
64
+
65
+ it('persists canonically: the same content in any order is the same bytes, and reloads', async () => {
66
+ const p1 = memPersistence(), p2 = memPersistence();
67
+ const a = new LocalSnapshotStore(p1), b = new LocalSnapshotStore(p2);
68
+ await a.write({ scope: SCOPE, facts: [fact('k1', '1'), fact('k2', '2')], relations: [] });
69
+ await b.write({ scope: SCOPE, facts: [fact('k2', '2'), fact('k1', '1')], relations: [] });
70
+ expect(p1.value).toBe(p2.value);
71
+ expect(await new LocalSnapshotStore(p1).facts({ rootId: 'r' })).toHaveLength(2);
72
+ });
73
+
74
+ it('caches extracted output by hash and refuses extracted items as memory', async () => {
75
+ const s = store();
76
+ await s.putExtracted('h', [{ sourceRef: 'src:r::a.md', sourceHash: 'h', nodes: [], edges: [], keys: [] }]);
77
+ expect(await s.getExtracted('h')).toHaveLength(1);
78
+ expect(await s.getExtracted('nope')).toBeUndefined();
79
+ const bad = { ...fact('k', 'c'), origin: 'extracted' } as unknown as StoredFact;
80
+ await expect(s.write({ scope: SCOPE, facts: [bad], relations: [] })).rejects.toThrow();
81
+ });
82
+ });