@seanhogg/builderforce-memory 2026.10.28 → 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.
- package/dist/ingest/IngestionPipeline.d.ts +1 -8
- package/dist/ingest/IngestionPipeline.d.ts.map +1 -1
- package/dist/ingest/IngestionPipeline.js +3 -15
- package/dist/ingest/IngestionPipeline.js.map +1 -1
- package/dist/ingest/docsExtract.d.ts +30 -0
- package/dist/ingest/docsExtract.d.ts.map +1 -0
- package/dist/ingest/docsExtract.js +221 -0
- package/dist/ingest/docsExtract.js.map +1 -0
- package/dist/ingest/hash.d.ts +15 -0
- package/dist/ingest/hash.d.ts.map +1 -0
- package/dist/ingest/hash.js +22 -0
- package/dist/ingest/hash.js.map +1 -0
- package/dist/ingest/parsers.d.ts.map +1 -1
- package/dist/ingest/parsers.js +4 -0
- package/dist/ingest/parsers.js.map +1 -1
- package/dist/ingest/types.d.ts +7 -0
- package/dist/ingest/types.d.ts.map +1 -1
- package/dist/ingest/types.js.map +1 -1
- package/dist/knowledge/build.d.ts +24 -0
- package/dist/knowledge/build.d.ts.map +1 -0
- package/dist/knowledge/build.js +263 -0
- package/dist/knowledge/build.js.map +1 -0
- package/dist/knowledge/cards.d.ts +17 -0
- package/dist/knowledge/cards.d.ts.map +1 -0
- package/dist/knowledge/cards.js +118 -0
- package/dist/knowledge/cards.js.map +1 -0
- package/dist/knowledge/findings.d.ts +24 -0
- package/dist/knowledge/findings.d.ts.map +1 -0
- package/dist/knowledge/findings.js +152 -0
- package/dist/knowledge/findings.js.map +1 -0
- package/dist/knowledge/importance.d.ts +21 -0
- package/dist/knowledge/importance.d.ts.map +1 -0
- package/dist/knowledge/importance.js +70 -0
- package/dist/knowledge/importance.js.map +1 -0
- package/dist/knowledge/index.d.ts +31 -0
- package/dist/knowledge/index.d.ts.map +1 -0
- package/dist/knowledge/index.js +26 -0
- package/dist/knowledge/index.js.map +1 -0
- package/dist/knowledge/judge.d.ts +86 -0
- package/dist/knowledge/judge.d.ts.map +1 -0
- package/dist/knowledge/judge.js +213 -0
- package/dist/knowledge/judge.js.map +1 -0
- package/dist/knowledge/model.d.ts +333 -0
- package/dist/knowledge/model.d.ts.map +1 -0
- package/dist/knowledge/model.js +13 -0
- package/dist/knowledge/model.js.map +1 -0
- package/dist/knowledge/packs/docs.d.ts +17 -0
- package/dist/knowledge/packs/docs.d.ts.map +1 -0
- package/dist/knowledge/packs/docs.js +89 -0
- package/dist/knowledge/packs/docs.js.map +1 -0
- package/dist/knowledge/partitions.d.ts +31 -0
- package/dist/knowledge/partitions.d.ts.map +1 -0
- package/dist/knowledge/partitions.js +135 -0
- package/dist/knowledge/partitions.js.map +1 -0
- package/dist/knowledge/plan.d.ts +33 -0
- package/dist/knowledge/plan.d.ts.map +1 -0
- package/dist/knowledge/plan.js +89 -0
- package/dist/knowledge/plan.js.map +1 -0
- package/dist/knowledge/refs.d.ts +40 -0
- package/dist/knowledge/refs.d.ts.map +1 -0
- package/dist/knowledge/refs.js +102 -0
- package/dist/knowledge/refs.js.map +1 -0
- package/dist/knowledge/resolve.d.ts +35 -0
- package/dist/knowledge/resolve.d.ts.map +1 -0
- package/dist/knowledge/resolve.js +67 -0
- package/dist/knowledge/resolve.js.map +1 -0
- package/dist/knowledge/review.d.ts +23 -0
- package/dist/knowledge/review.d.ts.map +1 -0
- package/dist/knowledge/review.js +233 -0
- package/dist/knowledge/review.js.map +1 -0
- package/dist/knowledge/sources.d.ts +47 -0
- package/dist/knowledge/sources.d.ts.map +1 -0
- package/dist/knowledge/sources.js +136 -0
- package/dist/knowledge/sources.js.map +1 -0
- package/dist/knowledge/stores/localSnapshotStore.d.ts +50 -0
- package/dist/knowledge/stores/localSnapshotStore.d.ts.map +1 -0
- package/dist/knowledge/stores/localSnapshotStore.js +125 -0
- package/dist/knowledge/stores/localSnapshotStore.js.map +1 -0
- package/dist/knowledge/trace.d.ts +32 -0
- package/dist/knowledge/trace.d.ts.map +1 -0
- package/dist/knowledge/trace.js +102 -0
- package/dist/knowledge/trace.js.map +1 -0
- package/dist/knowledge/truncate.d.ts +24 -0
- package/dist/knowledge/truncate.d.ts.map +1 -0
- package/dist/knowledge/truncate.js +128 -0
- package/dist/knowledge/truncate.js.map +1 -0
- package/dist/memory/SemanticRecall.d.ts.map +1 -1
- package/dist/memory/SemanticRecall.js +3 -1
- package/dist/memory/SemanticRecall.js.map +1 -1
- package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
- package/dist/retrieval/HybridRetriever.js +3 -1
- package/dist/retrieval/HybridRetriever.js.map +1 -1
- package/dist/retrieval/bm25.d.ts +3 -2
- package/dist/retrieval/bm25.d.ts.map +1 -1
- package/dist/retrieval/bm25.js +5 -3
- package/dist/retrieval/bm25.js.map +1 -1
- package/dist/retrieval/fusion.d.ts +2 -1
- package/dist/retrieval/fusion.d.ts.map +1 -1
- package/dist/retrieval/fusion.js +4 -2
- package/dist/retrieval/fusion.js.map +1 -1
- package/dist/retrieval/hnsw.d.ts +49 -11
- package/dist/retrieval/hnsw.d.ts.map +1 -1
- package/dist/retrieval/hnsw.js +57 -29
- package/dist/retrieval/hnsw.js.map +1 -1
- package/dist/retrieval/index.d.ts +7 -3
- package/dist/retrieval/index.d.ts.map +1 -1
- package/dist/retrieval/index.js +5 -2
- package/dist/retrieval/index.js.map +1 -1
- package/dist/retrieval/knowledge-index/bytes.d.ts +43 -0
- package/dist/retrieval/knowledge-index/bytes.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/bytes.js +151 -0
- package/dist/retrieval/knowledge-index/bytes.js.map +1 -0
- package/dist/retrieval/knowledge-index/cascade.d.ts +37 -0
- package/dist/retrieval/knowledge-index/cascade.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/cascade.js +111 -0
- package/dist/retrieval/knowledge-index/cascade.js.map +1 -0
- package/dist/retrieval/knowledge-index/compact.d.ts +18 -0
- package/dist/retrieval/knowledge-index/compact.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/compact.js +64 -0
- package/dist/retrieval/knowledge-index/compact.js.map +1 -0
- package/dist/retrieval/knowledge-index/csr.d.ts +41 -0
- package/dist/retrieval/knowledge-index/csr.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/csr.js +76 -0
- package/dist/retrieval/knowledge-index/csr.js.map +1 -0
- package/dist/retrieval/knowledge-index/digest.d.ts +21 -0
- package/dist/retrieval/knowledge-index/digest.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/digest.js +50 -0
- package/dist/retrieval/knowledge-index/digest.js.map +1 -0
- package/dist/retrieval/knowledge-index/embeddings.d.ts +32 -0
- package/dist/retrieval/knowledge-index/embeddings.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/embeddings.js +78 -0
- package/dist/retrieval/knowledge-index/embeddings.js.map +1 -0
- package/dist/retrieval/knowledge-index/expand.d.ts +19 -0
- package/dist/retrieval/knowledge-index/expand.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/expand.js +49 -0
- package/dist/retrieval/knowledge-index/expand.js.map +1 -0
- package/dist/retrieval/knowledge-index/index.d.ts +40 -0
- package/dist/retrieval/knowledge-index/index.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/index.js +134 -0
- package/dist/retrieval/knowledge-index/index.js.map +1 -0
- package/dist/retrieval/knowledge-index/lexical.d.ts +26 -0
- package/dist/retrieval/knowledge-index/lexical.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/lexical.js +76 -0
- package/dist/retrieval/knowledge-index/lexical.js.map +1 -0
- package/dist/retrieval/knowledge-index/pairs.d.ts +22 -0
- package/dist/retrieval/knowledge-index/pairs.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/pairs.js +51 -0
- package/dist/retrieval/knowledge-index/pairs.js.map +1 -0
- package/dist/retrieval/knowledge-index/prune.d.ts +21 -0
- package/dist/retrieval/knowledge-index/prune.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/prune.js +43 -0
- package/dist/retrieval/knowledge-index/prune.js.map +1 -0
- package/dist/retrieval/knowledge-index/quantize.d.ts +37 -0
- package/dist/retrieval/knowledge-index/quantize.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/quantize.js +77 -0
- package/dist/retrieval/knowledge-index/quantize.js.map +1 -0
- package/dist/retrieval/knowledge-index/serialize.d.ts +18 -0
- package/dist/retrieval/knowledge-index/serialize.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/serialize.js +123 -0
- package/dist/retrieval/knowledge-index/serialize.js.map +1 -0
- package/dist/retrieval/knowledge-index/sha256.d.ts +30 -0
- package/dist/retrieval/knowledge-index/sha256.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/sha256.js +130 -0
- package/dist/retrieval/knowledge-index/sha256.js.map +1 -0
- package/dist/retrieval/knowledge-index/state.d.ts +55 -0
- package/dist/retrieval/knowledge-index/state.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/state.js +67 -0
- package/dist/retrieval/knowledge-index/state.js.map +1 -0
- package/dist/retrieval/knowledge-index/tokenize.d.ts +20 -0
- package/dist/retrieval/knowledge-index/tokenize.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/tokenize.js +42 -0
- package/dist/retrieval/knowledge-index/tokenize.js.map +1 -0
- package/dist/retrieval/knowledge-index/traverse.d.ts +26 -0
- package/dist/retrieval/knowledge-index/traverse.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/traverse.js +90 -0
- package/dist/retrieval/knowledge-index/traverse.js.map +1 -0
- package/dist/retrieval/knowledge-index/types.d.ts +85 -0
- package/dist/retrieval/knowledge-index/types.d.ts.map +1 -0
- package/dist/retrieval/knowledge-index/types.js +9 -0
- package/dist/retrieval/knowledge-index/types.js.map +1 -0
- package/dist/retrieval/mmr.d.ts +11 -4
- package/dist/retrieval/mmr.d.ts.map +1 -1
- package/dist/retrieval/mmr.js +28 -8
- package/dist/retrieval/mmr.js.map +1 -1
- package/dist/retrieval/order.d.ts +41 -0
- package/dist/retrieval/order.d.ts.map +1 -0
- package/dist/retrieval/order.js +54 -0
- package/dist/retrieval/order.js.map +1 -0
- package/dist/retrieval/weightedFusion.d.ts +2 -1
- package/dist/retrieval/weightedFusion.d.ts.map +1 -1
- package/dist/retrieval/weightedFusion.js +7 -5
- package/dist/retrieval/weightedFusion.js.map +1 -1
- package/dist/vectorstore/MemoryVectorStore.d.ts.map +1 -1
- package/dist/vectorstore/MemoryVectorStore.js +2 -1
- package/dist/vectorstore/MemoryVectorStore.js.map +1 -1
- package/package.json +8 -3
- package/src/ingest/IngestionPipeline.ts +3 -15
- package/src/ingest/docsExtract.ts +219 -0
- package/src/ingest/hash.ts +22 -0
- package/src/ingest/parsers.ts +4 -0
- package/src/ingest/types.ts +7 -0
- package/src/knowledge/build.test.ts +150 -0
- package/src/knowledge/build.ts +266 -0
- package/src/knowledge/cards.ts +130 -0
- package/src/knowledge/docs.test.ts +118 -0
- package/src/knowledge/findings.ts +159 -0
- package/src/knowledge/importance.ts +72 -0
- package/src/knowledge/index.ts +53 -0
- package/src/knowledge/judge.ts +231 -0
- package/src/knowledge/model.ts +312 -0
- package/src/knowledge/packs/docs.ts +91 -0
- package/src/knowledge/partitions.test.ts +98 -0
- package/src/knowledge/partitions.ts +119 -0
- package/src/knowledge/plan.ts +115 -0
- package/src/knowledge/refs.ts +108 -0
- package/src/knowledge/resolve.ts +78 -0
- package/src/knowledge/review.test.ts +180 -0
- package/src/knowledge/review.ts +235 -0
- package/src/knowledge/sources.ts +146 -0
- package/src/knowledge/stores/localSnapshotStore.test.ts +82 -0
- package/src/knowledge/stores/localSnapshotStore.ts +159 -0
- package/src/knowledge/testkit.test-util.ts +92 -0
- package/src/knowledge/trace.test.ts +91 -0
- package/src/knowledge/trace.ts +111 -0
- package/src/knowledge/truncate.ts +118 -0
- package/src/memory/SemanticRecall.ts +3 -1
- package/src/retrieval/HybridRetriever.ts +3 -1
- package/src/retrieval/bm25.ts +5 -3
- package/src/retrieval/fusion.ts +5 -2
- package/src/retrieval/hnsw.ts +82 -35
- package/src/retrieval/index.ts +17 -3
- package/src/retrieval/knowledge-index/bytes.ts +166 -0
- package/src/retrieval/knowledge-index/cascade.ts +125 -0
- package/src/retrieval/knowledge-index/compact.ts +68 -0
- package/src/retrieval/knowledge-index/csr.ts +102 -0
- package/src/retrieval/knowledge-index/digest.ts +54 -0
- package/src/retrieval/knowledge-index/embeddings.ts +85 -0
- package/src/retrieval/knowledge-index/expand.ts +55 -0
- package/src/retrieval/knowledge-index/index.ts +162 -0
- package/src/retrieval/knowledge-index/lexical.ts +78 -0
- package/src/retrieval/knowledge-index/pairs.ts +57 -0
- package/src/retrieval/knowledge-index/prune.ts +48 -0
- package/src/retrieval/knowledge-index/quantize.ts +78 -0
- package/src/retrieval/knowledge-index/serialize.ts +124 -0
- package/src/retrieval/knowledge-index/sha256.ts +122 -0
- package/src/retrieval/knowledge-index/state.ts +108 -0
- package/src/retrieval/knowledge-index/tokenize.ts +41 -0
- package/src/retrieval/knowledge-index/traverse.ts +95 -0
- package/src/retrieval/knowledge-index/types.ts +78 -0
- package/src/retrieval/mmr.ts +35 -10
- package/src/retrieval/order.ts +68 -0
- package/src/retrieval/weightedFusion.ts +8 -5
- package/src/vectorstore/MemoryVectorStore.ts +2 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ingest/docsExtract.ts — the knowledge-graph `extract` of the builtin text parsers
|
|
3
|
+
* (markdown, HTML, plain text). PRD 38 §4.5, the docs pack.
|
|
4
|
+
*
|
|
5
|
+
* Pure: a function of the source text, its root-relative path (`source.id`), the parsed
|
|
6
|
+
* sections and the extract context. Emits, all with `origin: 'extracted'`:
|
|
7
|
+
*
|
|
8
|
+
* • a `file` node, and a `section` node per heading breadcrumb, joined by `contains`;
|
|
9
|
+
* • `links` edges for relative markdown links, `[[wiki]]` links and HTML hrefs, resolved
|
|
10
|
+
* through `ctx.resolvePath` (an unresolvable target is skipped; a missing one stays an
|
|
11
|
+
* edge, so the graph can report it broken);
|
|
12
|
+
* • `mentions` edges where a section's normalized text contains a known entity key;
|
|
13
|
+
* • keys: path, basename, heading slugs and front-matter values.
|
|
14
|
+
*
|
|
15
|
+
* A section node carries `badges.section` (its index in the parser's output) so the review
|
|
16
|
+
* can hand exactly that text to judgement, and `badges.excerpt` for the index card.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtractContext, Extracted, KEdge, KNode, NodeRef } from '../knowledge/model.js';
|
|
20
|
+
import { basenameOf, compareStr, normalizeKey, slugOf, srcRef } from '../knowledge/refs.js';
|
|
21
|
+
import { hashText } from './hash.js';
|
|
22
|
+
import { CONTENT_HASH_FIELD, type ParsedSection, type SourceDocument } from './types.js';
|
|
23
|
+
|
|
24
|
+
export const DOCS_PACK_ID = 'docs';
|
|
25
|
+
/** Badge: index of the section in the parser's output. */
|
|
26
|
+
export const SECTION_INDEX_BADGE = 'section';
|
|
27
|
+
const EXCERPT_CHARS = 400;
|
|
28
|
+
const BREADCRUMB_SEPARATOR = ' › ';
|
|
29
|
+
|
|
30
|
+
type Ctx = ExtractContext;
|
|
31
|
+
|
|
32
|
+
const textOf = (source: SourceDocument): string => (source.content.kind === 'text' ? source.content.text : '');
|
|
33
|
+
|
|
34
|
+
class ExtractBuilder {
|
|
35
|
+
readonly path: string;
|
|
36
|
+
readonly hash: string;
|
|
37
|
+
readonly fileRef: NodeRef;
|
|
38
|
+
private readonly nodes = new Map<NodeRef, KNode>();
|
|
39
|
+
private readonly edges = new Map<string, KEdge>();
|
|
40
|
+
private readonly keys = new Map<string, { ref: NodeRef; key: string }>();
|
|
41
|
+
|
|
42
|
+
constructor(source: SourceDocument, private readonly ctx: Ctx, label?: string) {
|
|
43
|
+
this.path = source.id;
|
|
44
|
+
const stated = source.metadata?.[CONTENT_HASH_FIELD];
|
|
45
|
+
this.hash = typeof stated === 'string' ? stated : hashText(textOf(source));
|
|
46
|
+
this.fileRef = srcRef(ctx.rootId, this.path);
|
|
47
|
+
const base = basenameOf(this.path);
|
|
48
|
+
this.node(this.fileRef, 'file', label || base);
|
|
49
|
+
this.key(this.fileRef, this.path);
|
|
50
|
+
this.key(this.fileRef, base.toLowerCase());
|
|
51
|
+
this.key(this.fileRef, normalizeKey(base.replace(/\.[^.]+$/, '')));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
node(ref: NodeRef, kind: string, label: string, badges?: Record<string, number | string>): KNode {
|
|
55
|
+
const node: KNode = {
|
|
56
|
+
ref, kind, label, pack: DOCS_PACK_ID, importance: 0, origin: 'extracted', sourceHash: this.hash,
|
|
57
|
+
...(badges ? { badges } : {}),
|
|
58
|
+
};
|
|
59
|
+
this.nodes.set(ref, node);
|
|
60
|
+
return node;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
edge(from: NodeRef, to: NodeRef, kind: string): void {
|
|
64
|
+
if (from === to) return;
|
|
65
|
+
const id = `${from}\u0000${to}\u0000${kind}`;
|
|
66
|
+
const prior = this.edges.get(id);
|
|
67
|
+
if (prior) prior.weight += 1;
|
|
68
|
+
else this.edges.set(id, { from, to, kind, weight: 1, origin: 'extracted' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
key(ref: NodeRef, key: string): void {
|
|
72
|
+
if (key) this.keys.set(`${ref}\u0000${key}`, { ref, key });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `links` edges to each resolvable target. */
|
|
76
|
+
link(from: NodeRef, target: string): void {
|
|
77
|
+
const clean = target.split('#')[0]!.split('?')[0]!.trim();
|
|
78
|
+
if (!clean || /^[a-z][a-z0-9+.-]*:/i.test(clean)) return;
|
|
79
|
+
let decoded = clean;
|
|
80
|
+
try { decoded = decodeURI(clean); } catch { /* keep the raw target */ }
|
|
81
|
+
const resolved = this.ctx.resolvePath(this.path, decoded);
|
|
82
|
+
if (resolved !== undefined && resolved !== this.path) this.edge(from, srcRef(this.ctx.rootId, resolved), 'links');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
wikiLinks(from: NodeRef, text: string): void {
|
|
86
|
+
for (const m of text.matchAll(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g)) {
|
|
87
|
+
const name = m[1]!.trim();
|
|
88
|
+
if (name) this.link(from, /\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(name) ? name : `${name}.md`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `mentions` edges where the normalized text contains a known key as whole words. */
|
|
93
|
+
mentions(from: NodeRef, text: string): void {
|
|
94
|
+
if (this.ctx.knownKeys.size === 0) return;
|
|
95
|
+
const padded = ` ${normalizeKey(text)} `;
|
|
96
|
+
const known = [...this.ctx.knownKeys.entries()].sort((a, b) => compareStr(a[0], b[0]));
|
|
97
|
+
for (const [key, ref] of known) {
|
|
98
|
+
if (key && padded.includes(` ${key} `)) this.edge(from, ref, 'mentions');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A whole-body `section` (HTML, plain text): judged like a markdown section. */
|
|
103
|
+
bodySection(index: number, text: string): NodeRef {
|
|
104
|
+
const ref: NodeRef = `${this.fileRef}#${index === 0 ? 'body' : `body-${index + 1}`}`;
|
|
105
|
+
this.node(ref, 'section', basenameOf(this.path), { [SECTION_INDEX_BADGE]: index, excerpt: excerpt(text) });
|
|
106
|
+
this.edge(this.fileRef, ref, 'contains');
|
|
107
|
+
return ref;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
build(): Extracted {
|
|
111
|
+
const byRef = <T extends { ref: NodeRef }>(a: T, b: T) => compareStr(a.ref, b.ref);
|
|
112
|
+
return {
|
|
113
|
+
sourceRef: this.fileRef,
|
|
114
|
+
sourceHash: this.hash,
|
|
115
|
+
nodes: [...this.nodes.values()].sort(byRef),
|
|
116
|
+
edges: [...this.edges.values()].sort((a, b) =>
|
|
117
|
+
compareStr(a.from, b.from) || compareStr(a.to, b.to) || compareStr(a.kind, b.kind)),
|
|
118
|
+
keys: [...this.keys.values()].sort((a, b) => byRef(a, b) || compareStr(a.key, b.key)),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `key: value` and `- item` front matter between leading `---` fences. */
|
|
124
|
+
export function frontMatterOf(text: string): Record<string, string[]> {
|
|
125
|
+
const out: Record<string, string[]> = {};
|
|
126
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text);
|
|
127
|
+
if (!m) return out;
|
|
128
|
+
let current: string | undefined;
|
|
129
|
+
for (const line of m[1]!.split(/\r?\n/)) {
|
|
130
|
+
const item = /^\s+-\s+(.*)$/.exec(line);
|
|
131
|
+
if (item && current) { out[current]!.push(unquote(item[1]!)); continue; }
|
|
132
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
|
|
133
|
+
if (!kv) continue;
|
|
134
|
+
current = kv[1]!;
|
|
135
|
+
const raw = kv[2]!.trim();
|
|
136
|
+
const list = /^\[(.*)\]$/.exec(raw);
|
|
137
|
+
out[current] = list ? list[1]!.split(',').map(unquote).filter(Boolean) : raw ? [unquote(raw)] : [];
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function unquote(s: string): string {
|
|
143
|
+
return s.trim().replace(/^["']|["']$/g, '').trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function excerpt(text: string): string {
|
|
147
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, EXCERPT_CHARS);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const MD_LINK = /(?<!!)\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
|
|
151
|
+
|
|
152
|
+
export function extractMarkdown(source: SourceDocument, sections: ParsedSection[], ctx: Ctx): Extracted {
|
|
153
|
+
const b = new ExtractBuilder(source, ctx);
|
|
154
|
+
const fm = frontMatterOf(textOf(source));
|
|
155
|
+
const fileBadges: Record<string, string> = {};
|
|
156
|
+
for (const k of Object.keys(fm).sort(compareStr)) {
|
|
157
|
+
fileBadges[`fm.${k}`] = fm[k]!.join(', ');
|
|
158
|
+
for (const v of fm[k]!) b.key(b.fileRef, normalizeKey(v));
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(fileBadges).length) b.node(b.fileRef, 'file', fm['title']?.[0] || basenameOf(b.path), fileBadges);
|
|
161
|
+
|
|
162
|
+
const refByCrumb = new Map<string, NodeRef>();
|
|
163
|
+
const occurrences = new Map<string, number>();
|
|
164
|
+
const sectionNode = (crumbs: string[], index?: number, text?: string): NodeRef => {
|
|
165
|
+
const slugPath = crumbs.map(slugOf).join('/');
|
|
166
|
+
const n = (occurrences.get(slugPath) ?? 0) + 1;
|
|
167
|
+
occurrences.set(slugPath, n);
|
|
168
|
+
const ref: NodeRef = `${b.fileRef}#${n > 1 ? `${slugPath}-${n}` : slugPath}`;
|
|
169
|
+
const badges: Record<string, number | string> = { depth: crumbs.length, breadcrumb: crumbs.join(BREADCRUMB_SEPARATOR) };
|
|
170
|
+
if (index !== undefined) badges[SECTION_INDEX_BADGE] = index;
|
|
171
|
+
if (text) badges['excerpt'] = excerpt(text);
|
|
172
|
+
b.node(ref, 'section', crumbs[crumbs.length - 1]!, badges);
|
|
173
|
+
b.key(ref, slugOf(crumbs[crumbs.length - 1]!));
|
|
174
|
+
const parent = crumbs.length > 1 ? refByCrumb.get(crumbs.slice(0, -1).join('\u0000')) : b.fileRef;
|
|
175
|
+
b.edge(parent ?? b.fileRef, ref, 'contains');
|
|
176
|
+
refByCrumb.set(crumbs.join('\u0000'), ref);
|
|
177
|
+
return ref;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
sections.forEach((section, index) => {
|
|
181
|
+
const headingPath = section.metadata?.['headingPath'];
|
|
182
|
+
let from = b.fileRef;
|
|
183
|
+
if (typeof headingPath === 'string' && headingPath) {
|
|
184
|
+
const crumbs = headingPath.split(BREADCRUMB_SEPARATOR);
|
|
185
|
+
// Headings with no body of their own are not emitted by the parser; recover them.
|
|
186
|
+
for (let k = 1; k < crumbs.length; k++) {
|
|
187
|
+
if (!refByCrumb.has(crumbs.slice(0, k).join('\u0000'))) sectionNode(crumbs.slice(0, k));
|
|
188
|
+
}
|
|
189
|
+
from = sectionNode(crumbs, index, section.text);
|
|
190
|
+
}
|
|
191
|
+
for (const m of section.text.matchAll(MD_LINK)) {
|
|
192
|
+
if (!m[1]!.startsWith('#')) b.link(from, m[1]!);
|
|
193
|
+
}
|
|
194
|
+
b.wikiLinks(from, section.text);
|
|
195
|
+
b.mentions(from, section.text);
|
|
196
|
+
});
|
|
197
|
+
return b.build();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function extractHtml(source: SourceDocument, sections: ParsedSection[], ctx: Ctx): Extracted {
|
|
201
|
+
const title = sections[0]?.metadata?.['htmlTitle'];
|
|
202
|
+
const b = new ExtractBuilder(source, ctx, typeof title === 'string' ? title : undefined);
|
|
203
|
+
if (typeof title === 'string' && title) b.key(b.fileRef, normalizeKey(title));
|
|
204
|
+
for (const m of textOf(source).matchAll(/<a\s[^>]*href\s*=\s*["']([^"']+)["']/gi)) {
|
|
205
|
+
if (!m[1]!.startsWith('#')) b.link(b.fileRef, m[1]!);
|
|
206
|
+
}
|
|
207
|
+
sections.forEach((section, index) => b.mentions(b.bodySection(index, section.text), section.text));
|
|
208
|
+
return b.build();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function extractPlainText(source: SourceDocument, sections: ParsedSection[], ctx: Ctx): Extracted {
|
|
212
|
+
const b = new ExtractBuilder(source, ctx);
|
|
213
|
+
sections.forEach((section, index) => {
|
|
214
|
+
const from = b.bodySection(index, section.text);
|
|
215
|
+
b.wikiLinks(from, section.text);
|
|
216
|
+
b.mentions(from, section.text);
|
|
217
|
+
});
|
|
218
|
+
return b.build();
|
|
219
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ingest/hash.ts — the content hash every ingest consumer shares (the pipeline's chunk
|
|
3
|
+
* hashes, the knowledge graph's extracted output and card hashes). A leaf module, so
|
|
4
|
+
* `ingest/docsExtract.ts` can use it without an import cycle through the pipeline.
|
|
5
|
+
* (SHA-256 digests live in `retrieval/knowledge-index/sha256.ts`.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* FNV-1a, 32-bit, rendered as hex.
|
|
10
|
+
*
|
|
11
|
+
* A content hash here answers "did this chunk change", not "was this chunk
|
|
12
|
+
* tampered with", so a fast non-cryptographic hash is the right tool — and it is
|
|
13
|
+
* synchronous, unlike WebCrypto, which keeps chunking a pure function.
|
|
14
|
+
*/
|
|
15
|
+
export function hashText(text: string): string {
|
|
16
|
+
let hash = 0x811c9dc5;
|
|
17
|
+
for (let i = 0; i < text.length; i++) {
|
|
18
|
+
hash ^= text.charCodeAt(i);
|
|
19
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return hash.toString(16).padStart(8, '0');
|
|
22
|
+
}
|
package/src/ingest/parsers.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { MetadataRecord, MetadataValue } from '../vectorstore/types.js';
|
|
20
|
+
import { extractHtml, extractMarkdown, extractPlainText } from './docsExtract.js';
|
|
20
21
|
import type { DocumentParser, ParsedSection, SourceDocument, StructuredRow } from './types.js';
|
|
21
22
|
|
|
22
23
|
const textOf = (source: SourceDocument): string =>
|
|
@@ -237,6 +238,7 @@ export const markdownParser: DocumentParser = {
|
|
|
237
238
|
flush();
|
|
238
239
|
return sections;
|
|
239
240
|
},
|
|
241
|
+
extract: extractMarkdown,
|
|
240
242
|
};
|
|
241
243
|
|
|
242
244
|
// ── HTML ──────────────────────────────────────────────────────────────────────
|
|
@@ -270,6 +272,7 @@ export const htmlParser: DocumentParser = {
|
|
|
270
272
|
if (titleMatch?.[1]) metadata['htmlTitle'] = titleMatch[1].trim();
|
|
271
273
|
return [{ text, metadata }];
|
|
272
274
|
},
|
|
275
|
+
extract: extractHtml,
|
|
273
276
|
};
|
|
274
277
|
|
|
275
278
|
// ── plain text ────────────────────────────────────────────────────────────────
|
|
@@ -281,6 +284,7 @@ export const plainTextParser: DocumentParser = {
|
|
|
281
284
|
const text = textOf(source).trim();
|
|
282
285
|
return text ? [{ text }] : [];
|
|
283
286
|
},
|
|
287
|
+
extract: extractPlainText,
|
|
284
288
|
};
|
|
285
289
|
|
|
286
290
|
// ── registry ──────────────────────────────────────────────────────────────────
|
package/src/ingest/types.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { MetadataRecord, MetadataValue } from '../vectorstore/types.js';
|
|
14
|
+
import type { ExtractContext, Extracted } from '../knowledge/model.js';
|
|
14
15
|
|
|
15
16
|
/** One row of a structured source (a DB result set, a CSV, a spreadsheet). */
|
|
16
17
|
export type StructuredRow = Record<string, MetadataValue>;
|
|
@@ -57,6 +58,12 @@ export interface DocumentParser {
|
|
|
57
58
|
/** True when this parser handles the source. First match in registry order wins. */
|
|
58
59
|
accepts(source: SourceDocument): boolean;
|
|
59
60
|
parse(source: SourceDocument): ParsedSection[];
|
|
61
|
+
/**
|
|
62
|
+
* Optional, pure: the knowledge-graph structure of a parsed source (file/section nodes,
|
|
63
|
+
* links, mentions, keys). Perception, never memory — recomputed from the source and
|
|
64
|
+
* cached by content hash. Parsers without it contribute a bare file node.
|
|
65
|
+
*/
|
|
66
|
+
extract?(source: SourceDocument, sections: ParsedSection[], ctx: ExtractContext): Extracted;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
/** Identity of one stored chunk, for incremental sync. */
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/knowledge/build.test.ts
|
|
3
|
+
* buildGraph: byte-identical output for equal input, the fact join and staleness, the
|
|
4
|
+
* near-duplicate/duplicate rule, live overlay, and the truncation keep-rules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { buildGraph, graphDigest } from './build.js';
|
|
8
|
+
import { brokenLinkRule } from './packs/docs.js';
|
|
9
|
+
import type { BuildGraphInput, KnowledgePack, StoredFact } from './model.js';
|
|
10
|
+
import { edge, extractedOf, fileNode, ref, ROOT } from './testkit.test-util.js';
|
|
11
|
+
|
|
12
|
+
// The broken-link rule makes the truncation fixture's dangling link a finding.
|
|
13
|
+
const PACK: KnowledgePack = { id: 'docs', parsers: [], findings: [brokenLinkRule] };
|
|
14
|
+
|
|
15
|
+
function fixture(): BuildGraphInput {
|
|
16
|
+
return {
|
|
17
|
+
rootId: ROOT,
|
|
18
|
+
packs: [PACK],
|
|
19
|
+
extracted: [
|
|
20
|
+
extractedOf(fileNode('a/one.md'), [edge('a/one.md', 'a/two.md'), edge('a/one.md', 'b/three.md')]),
|
|
21
|
+
extractedOf(fileNode('a/two.md'), [edge('a/two.md', 'b/three.md')]),
|
|
22
|
+
extractedOf(fileNode('b/three.md'), [edge('b/three.md', 'a/one.md', 'duplicate')]),
|
|
23
|
+
],
|
|
24
|
+
facts: [
|
|
25
|
+
{ key: 'note', content: 'A human note', source: 's', importance: 1, origin: 'human', fileAnchors: [{ path: 'a/one.md', contentHash: 'old' }] },
|
|
26
|
+
{ key: 'claim:1', content: 'one ends 2027', source: 's', importance: 1, origin: 'model', fileAnchors: [{ path: 'a/one.md', contentHash: 'old' }] },
|
|
27
|
+
{ key: 'claim:2', content: 'two is fine', source: 's', importance: 1, origin: 'model', fileAnchors: [{ path: 'a/two.md', contentHash: 'h:a/two.md' }] },
|
|
28
|
+
{ key: 'floating', content: 'No anchor', source: 's', importance: 1, origin: 'agent', fileAnchors: [] },
|
|
29
|
+
],
|
|
30
|
+
relations: [{ from: 'fact:claim:1', to: 'fact:claim:2', kind: 'contradicts', weight: 1, origin: 'model', confidence: 0.8 }],
|
|
31
|
+
nearDuplicates: [
|
|
32
|
+
{ a: ref('a/one.md'), b: ref('a/two.md'), similarity: 0.91 },
|
|
33
|
+
{ a: ref('a/one.md'), b: ref('b/three.md'), similarity: 0.95 },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function reversed(input: BuildGraphInput): BuildGraphInput {
|
|
39
|
+
return {
|
|
40
|
+
...input,
|
|
41
|
+
extracted: [...input.extracted].reverse().map((e) => ({ ...e, edges: [...e.edges].reverse() })),
|
|
42
|
+
facts: [...input.facts].reverse(),
|
|
43
|
+
relations: [...input.relations].reverse(),
|
|
44
|
+
nearDuplicates: [...(input.nearDuplicates ?? [])].reverse(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('buildGraph — determinism', () => {
|
|
49
|
+
it('equal input (in any order) gives byte-identical JSON and digest', () => {
|
|
50
|
+
const a = JSON.stringify(buildGraph(fixture()));
|
|
51
|
+
const b = JSON.stringify(buildGraph(reversed(fixture())));
|
|
52
|
+
expect(a).toBe(b);
|
|
53
|
+
expect(graphDigest(fixture())).toBe(graphDigest(reversed(fixture())));
|
|
54
|
+
expect(graphDigest(fixture())).toMatch(/^[0-9a-f]{64}$/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('a different input gives a different digest', () => {
|
|
58
|
+
const changed = fixture();
|
|
59
|
+
changed.facts = changed.facts.slice(1);
|
|
60
|
+
expect(graphDigest(changed)).not.toBe(graphDigest(fixture()));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('buildGraph — the join', () => {
|
|
65
|
+
const g = buildGraph(fixture());
|
|
66
|
+
const node = (r: string) => g.nodes.find((n) => n.ref === r);
|
|
67
|
+
|
|
68
|
+
it('joins facts to sources by anchor path', () => {
|
|
69
|
+
expect(g.edges).toEqual(expect.arrayContaining([
|
|
70
|
+
expect.objectContaining({ from: 'fact:note', to: ref('a/one.md'), kind: 'about', origin: 'human' }),
|
|
71
|
+
expect.objectContaining({ from: 'fact:claim:2', to: ref('a/two.md'), kind: 'about', origin: 'model' }),
|
|
72
|
+
]));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a fact with no anchor is a node with no about edge', () => {
|
|
76
|
+
expect(node('fact:floating')).toBeDefined();
|
|
77
|
+
expect(g.edges.some((e) => e.from === 'fact:floating')).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('flags a model fact judged from another hash stale — never a human one', () => {
|
|
81
|
+
expect(node('fact:claim:1')?.stale).toBe(true);
|
|
82
|
+
expect(node('fact:claim:2')?.stale).toBeUndefined();
|
|
83
|
+
expect(node('fact:note')?.stale).toBeUndefined();
|
|
84
|
+
expect(g.stats.stale).toBe(1);
|
|
85
|
+
expect(g.findings.some((f) => f.kind === 'stale' && f.nodes.includes('fact:claim:1'))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('stored relations become edges', () => {
|
|
89
|
+
expect(g.edges).toContainEqual(expect.objectContaining({ from: 'fact:claim:1', to: 'fact:claim:2', kind: 'contradicts', confidence: 0.8 }));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('near-duplicates become edges unless the pair is an exact duplicate', () => {
|
|
93
|
+
expect(g.edges).toContainEqual(expect.objectContaining({ from: ref('a/one.md'), to: ref('a/two.md'), kind: 'near-duplicate' }));
|
|
94
|
+
expect(g.edges.some((e) => e.kind === 'near-duplicate' && e.to === ref('b/three.md'))).toBe(false);
|
|
95
|
+
expect(g.findings.some((f) => f.kind === 'clone-group')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('extracted items keep origin extracted, importance is normalized to 0–1', () => {
|
|
99
|
+
expect(node(ref('a/one.md'))?.origin).toBe('extracted');
|
|
100
|
+
expect(Math.max(...g.nodes.map((n) => n.importance))).toBe(1);
|
|
101
|
+
expect(g.nodes.every((n) => n.importance >= 0 && n.importance <= 1)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('live changes badge nodes and run files become co-changed edges', () => {
|
|
105
|
+
const live = buildGraph({
|
|
106
|
+
...fixture(),
|
|
107
|
+
live: { changes: [{ path: 'a/two.md', status: 'M', added: 3, removed: 1 }], runFiles: ['a/two.md', 'a/one.md'] },
|
|
108
|
+
});
|
|
109
|
+
expect(live.nodes.find((n) => n.ref === ref('a/two.md'))?.badges).toEqual({ added: 3, removed: 1, status: 'M' });
|
|
110
|
+
expect(live.edges).toContainEqual(expect.objectContaining({ from: ref('a/one.md'), to: ref('a/two.md'), kind: 'co-changed', origin: 'agent' }));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('buildGraph — truncation keep-rules', () => {
|
|
115
|
+
// A chain of 40 files in 4 folders; one broken link (a finding) far from the focus.
|
|
116
|
+
function chain(): BuildGraphInput {
|
|
117
|
+
const paths = Array.from({ length: 40 }, (_, i) => `d${i % 4}/f${String(i).padStart(2, '0')}.md`);
|
|
118
|
+
const extracted = paths.map((p, i) => extractedOf(fileNode(p), i + 1 < paths.length ? [edge(p, paths[i + 1]!)] : []));
|
|
119
|
+
extracted[30]!.edges.push(edge(paths[30]!, 'gone/missing.md'));
|
|
120
|
+
return { rootId: ROOT, packs: [PACK], extracted, facts: [] as StoredFact[], relations: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it('keeps focus, finding and changed nodes plus their 1-hop neighbours, and folds the rest into areas', () => {
|
|
124
|
+
const input = { ...chain(), live: { changes: [{ path: 'd2/f10.md', status: 'M', added: 1, removed: 0 }] } };
|
|
125
|
+
const g = buildGraph(input, { maxNodes: 8, focus: [ref('d0/f20.md')] });
|
|
126
|
+
const refs = new Set(g.nodes.map((n) => n.ref));
|
|
127
|
+
expect(g.stats.truncated).toBe(true);
|
|
128
|
+
for (const r of ['d0/f20.md', 'd3/f19.md', 'd1/f21.md', 'd2/f10.md', 'd1/f09.md', 'd3/f11.md', 'd2/f30.md', 'gone/missing.md']) {
|
|
129
|
+
expect(refs.has(ref(r))).toBe(true);
|
|
130
|
+
}
|
|
131
|
+
const areas = g.nodes.filter((n) => n.kind === 'area');
|
|
132
|
+
expect(areas.length).toBeGreaterThan(0);
|
|
133
|
+
expect(areas.every((a) => typeof a.badges?.['members'] === 'number')).toBe(true);
|
|
134
|
+
expect(g.edges.every((e) => refs.has(e.from) && refs.has(e.to))).toBe(true);
|
|
135
|
+
expect(g.edges.every((e) => e.from !== e.to)).toBe(true);
|
|
136
|
+
expect(g.stats.nodes).toBe(g.nodes.length);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not truncate under the budget', () => {
|
|
140
|
+
const g = buildGraph(chain());
|
|
141
|
+
expect(g.stats.truncated).toBe(false);
|
|
142
|
+
expect(g.nodes.some((n) => n.kind === 'area')).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('truncation is deterministic too', () => {
|
|
146
|
+
const a = JSON.stringify(buildGraph(chain(), { maxNodes: 6, focus: [ref('d0/f00.md')] }));
|
|
147
|
+
const b = JSON.stringify(buildGraph(reversed(chain()), { maxNodes: 6, focus: [ref('d0/f00.md')] }));
|
|
148
|
+
expect(a).toBe(b);
|
|
149
|
+
});
|
|
150
|
+
});
|