@tangle-network/agent-knowledge 20.0.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +8 -6
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +100 -118
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +242 -3
- package/dist/index.js.map +1 -1
- package/dist/{inspect-CMigdsD5.js → inspect-B1VI49Y9.js} +285 -299
- package/dist/inspect-B1VI49Y9.js.map +1 -0
- package/dist/search/index.d.ts +3 -0
- package/dist/search/index.js +2 -0
- package/dist/search-BBr5eUyT.js +275 -0
- package/dist/search-BBr5eUyT.js.map +1 -0
- package/dist/search-Dde0PEtY.d.ts +120 -0
- package/dist/search-Dde0PEtY.d.ts.map +1 -0
- package/docs/agent-tools.md +60 -0
- package/docs/architecture.md +40 -1
- package/docs/cli.md +19 -0
- package/docs/compatibility.md +12 -0
- package/docs/improvement.md +79 -0
- package/docs/memory.md +12 -0
- package/docs/provenance.md +50 -0
- package/docs/rag-evaluation.md +82 -0
- package/package.json +13 -7
- package/dist/inspect-CMigdsD5.js.map +0 -1
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { b as KnowledgeSearchResult, h as KnowledgePage } from "../types-BekdNwGD.js";
|
|
2
|
+
import { d as KnowledgeLexicalIndexOptions, n as KnowledgeSearchHit, o as searchKnowledgePages, p as buildKnowledgeLexicalIndex, r as SearchKnowledgeOptions, t as KNOWLEDGE_SEARCH_RETRIEVER_ID, u as KnowledgeLexicalIndex } from "../search-Dde0PEtY.js";
|
|
3
|
+
export { KNOWLEDGE_SEARCH_RETRIEVER_ID, type KnowledgeLexicalIndex, type KnowledgeLexicalIndexOptions, type KnowledgePage, type KnowledgeSearchHit, type KnowledgeSearchResult, type SearchKnowledgeOptions, buildKnowledgeLexicalIndex, searchKnowledgePages };
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
//#region src/lexical-postings.ts
|
|
2
|
+
const DEFAULT_FIELD_BOOSTS = Object.freeze({
|
|
3
|
+
title: 3,
|
|
4
|
+
path: 2,
|
|
5
|
+
text: 1
|
|
6
|
+
});
|
|
7
|
+
function lexicalPageTerms(page, tokenize, fieldBoosts) {
|
|
8
|
+
const frequencies = /* @__PURE__ */ new Map();
|
|
9
|
+
let length = 0;
|
|
10
|
+
for (const [text, boost] of [
|
|
11
|
+
[page.title, fieldBoosts.title],
|
|
12
|
+
[page.path.replace(/\.md$/, ""), fieldBoosts.path],
|
|
13
|
+
[page.text, fieldBoosts.text]
|
|
14
|
+
]) {
|
|
15
|
+
if (boost === 0) continue;
|
|
16
|
+
for (const token of tokenize(text)) {
|
|
17
|
+
frequencies.set(token, (frequencies.get(token) ?? 0) + boost);
|
|
18
|
+
length += boost;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
frequencies,
|
|
23
|
+
length
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Inverted index from per-page terms already computed with `tokenize` and `fieldBoosts`. */
|
|
27
|
+
function assembleLexicalIndex(pages, terms, tokenize, fieldBoosts) {
|
|
28
|
+
const postings = /* @__PURE__ */ new Map();
|
|
29
|
+
const documentLengths = [];
|
|
30
|
+
let totalLength = 0;
|
|
31
|
+
terms.forEach(({ frequencies, length }, ordinal) => {
|
|
32
|
+
documentLengths.push(length);
|
|
33
|
+
totalLength += length;
|
|
34
|
+
for (const [term, tf] of frequencies) {
|
|
35
|
+
let list = postings.get(term);
|
|
36
|
+
if (!list) {
|
|
37
|
+
list = [];
|
|
38
|
+
postings.set(term, list);
|
|
39
|
+
}
|
|
40
|
+
list.push({
|
|
41
|
+
ordinal,
|
|
42
|
+
tf
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
pages,
|
|
48
|
+
postings,
|
|
49
|
+
documentLengths,
|
|
50
|
+
averageDocumentLength: pages.length > 0 ? totalLength / pages.length : 0,
|
|
51
|
+
documentCount: pages.length,
|
|
52
|
+
tokenize,
|
|
53
|
+
fieldBoosts
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/lexical-index.ts
|
|
58
|
+
const STOP_WORDS = /* @__PURE__ */ new Set([
|
|
59
|
+
"the",
|
|
60
|
+
"is",
|
|
61
|
+
"a",
|
|
62
|
+
"an",
|
|
63
|
+
"what",
|
|
64
|
+
"how",
|
|
65
|
+
"are",
|
|
66
|
+
"was",
|
|
67
|
+
"were",
|
|
68
|
+
"to",
|
|
69
|
+
"for",
|
|
70
|
+
"of",
|
|
71
|
+
"with",
|
|
72
|
+
"by",
|
|
73
|
+
"in",
|
|
74
|
+
"on",
|
|
75
|
+
"and"
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* The token stream of one text: lower-cased, split on whitespace and
|
|
79
|
+
* punctuation, single characters and stop words removed, and a CJK run
|
|
80
|
+
* expanded into its bigrams and characters. Repeats are kept so a term
|
|
81
|
+
* frequency can be counted. Indexing and querying share this function, so the
|
|
82
|
+
* two vocabularies cannot drift.
|
|
83
|
+
*/
|
|
84
|
+
function tokenizeText(text) {
|
|
85
|
+
const raw = text.toLowerCase().split(/[\s,,。!?、;:""''()()\-_/\\·~~…]+/).filter((token) => token.length > 1 && !STOP_WORDS.has(token));
|
|
86
|
+
const tokens = [];
|
|
87
|
+
for (const token of raw) {
|
|
88
|
+
if (/[\u4e00-\u9fff\u3400-\u4dbf]/.test(token) && token.length > 2) {
|
|
89
|
+
const chars = [...token];
|
|
90
|
+
for (let i = 0; i < chars.length - 1; i++) tokens.push(chars[i] + chars[i + 1]);
|
|
91
|
+
tokens.push(...chars);
|
|
92
|
+
}
|
|
93
|
+
tokens.push(token);
|
|
94
|
+
}
|
|
95
|
+
return tokens;
|
|
96
|
+
}
|
|
97
|
+
/** The distinct query terms, in first-occurrence order. */
|
|
98
|
+
function tokenizeQuery(query) {
|
|
99
|
+
return [...new Set(tokenizeText(query))];
|
|
100
|
+
}
|
|
101
|
+
function buildKnowledgeLexicalIndex(pages, options = {}) {
|
|
102
|
+
const tokenize = options.tokenize ?? tokenizeText;
|
|
103
|
+
const fieldBoosts = resolveFieldBoosts(options.fieldBoosts);
|
|
104
|
+
return assembleLexicalIndex(pages, pages.map((page) => lexicalPageTerms(page, tokenize, fieldBoosts)), tokenize, fieldBoosts);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Okapi BM25 over the distinct query terms, with the Lucene inverse document
|
|
108
|
+
* frequency `ln(1 + (N - df + 0.5) / (df + 0.5))`, which is positive for every
|
|
109
|
+
* indexed term. Pages with no matching term are absent. The result is ordered
|
|
110
|
+
* by score, then by path, so it does not depend on page order.
|
|
111
|
+
*/
|
|
112
|
+
function scoreBm25(index, tokens, options = {}) {
|
|
113
|
+
const k1 = options.k1 ?? 1.2;
|
|
114
|
+
const b = options.b ?? .75;
|
|
115
|
+
if (!Number.isFinite(k1) || k1 < 0) throw new Error(`bm25 k1 must be >= 0, got ${String(k1)}`);
|
|
116
|
+
if (!Number.isFinite(b) || b < 0 || b > 1) throw new Error(`bm25 b must lie in [0, 1], got ${String(b)}`);
|
|
117
|
+
const scores = /* @__PURE__ */ new Map();
|
|
118
|
+
const { documentCount, averageDocumentLength, documentLengths } = index;
|
|
119
|
+
for (const term of new Set(tokens)) {
|
|
120
|
+
const list = index.postings.get(term);
|
|
121
|
+
if (!list) continue;
|
|
122
|
+
const df = list.length;
|
|
123
|
+
const idf = Math.log(1 + (documentCount - df + .5) / (df + .5));
|
|
124
|
+
for (const { ordinal, tf } of list) {
|
|
125
|
+
const lengthRatio = averageDocumentLength > 0 ? documentLengths[ordinal] / averageDocumentLength : 1;
|
|
126
|
+
const saturated = tf * (k1 + 1) / (tf + k1 * (1 - b + b * lengthRatio));
|
|
127
|
+
scores.set(ordinal, (scores.get(ordinal) ?? 0) + idf * saturated);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...scores.entries()].map(([ordinal, score]) => ({
|
|
131
|
+
page: index.pages[ordinal],
|
|
132
|
+
score
|
|
133
|
+
})).sort((left, right) => right.score - left.score || left.page.path.localeCompare(right.page.path));
|
|
134
|
+
}
|
|
135
|
+
function resolveFieldBoosts(boosts) {
|
|
136
|
+
const resolved = {
|
|
137
|
+
...DEFAULT_FIELD_BOOSTS,
|
|
138
|
+
...boosts
|
|
139
|
+
};
|
|
140
|
+
for (const [field, boost] of Object.entries(resolved)) if (!Number.isFinite(boost) || boost < 0) throw new Error(`lexical field boost ${field} must be >= 0, got ${String(boost)}`);
|
|
141
|
+
return Object.freeze(resolved);
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/search.ts
|
|
145
|
+
const RRF_K = 60;
|
|
146
|
+
/**
|
|
147
|
+
* Identity of the ranking `searchKnowledge` performs: BM25 over title, path,
|
|
148
|
+
* and body, exact-title and phrase matches ahead of bag-of-words matches, and
|
|
149
|
+
* reciprocal rank fusion with link and shared-source structure. Declare it as
|
|
150
|
+
* the retriever id of a retrieval receipt minted from these results.
|
|
151
|
+
*/
|
|
152
|
+
const KNOWLEDGE_SEARCH_RETRIEVER_ID = "bm25-rrf-v1";
|
|
153
|
+
function searchKnowledge(index, query, limitOrOptions = 10) {
|
|
154
|
+
return searchKnowledgePages(index.pages, query, limitOrOptions);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Rank a page set that is not a built index, such as the chain a run can see.
|
|
158
|
+
*
|
|
159
|
+
* `searchKnowledge` is this function over `index.pages`, so both entry points
|
|
160
|
+
* rank identically and neither can drift from the other.
|
|
161
|
+
*/
|
|
162
|
+
function searchKnowledgePages(pages, query, limitOrOptions = 10) {
|
|
163
|
+
const trimmed = query.trim();
|
|
164
|
+
if (trimmed === "") return [];
|
|
165
|
+
const options = typeof limitOrOptions === "number" ? { limit: limitOrOptions } : { ...limitOrOptions };
|
|
166
|
+
const limit = options.limit ?? 10;
|
|
167
|
+
if (!Number.isInteger(limit) || limit < 0) throw new Error(`search limit must be a non-negative integer, got ${String(limit)}`);
|
|
168
|
+
const matched = filterPages(pages, options);
|
|
169
|
+
const lexicalRanked = rankLexical(matched, trimmed, options.lexicalIndex ? assertLexicalIndexMatches(options.lexicalIndex, pages) : buildKnowledgeLexicalIndex(pages));
|
|
170
|
+
const ranked = [...fuseRanks([lexicalRanked, rankByGraph(matched, lexicalRanked, pages)]).entries()].map(([page, score]) => ({
|
|
171
|
+
page,
|
|
172
|
+
score
|
|
173
|
+
})).sort((a, b) => b.score - a.score || comparePages(a.page, b.page)).slice(0, limit);
|
|
174
|
+
const topScore = ranked[0]?.score ?? 0;
|
|
175
|
+
return ranked.map((item, i) => ({
|
|
176
|
+
citationId: item.page.id,
|
|
177
|
+
page: item.page,
|
|
178
|
+
score: item.score,
|
|
179
|
+
rrfScore: item.score,
|
|
180
|
+
normalizedScore: topScore > 0 ? item.score / topScore : 0,
|
|
181
|
+
rank: i + 1,
|
|
182
|
+
snippet: buildSnippet(item.page.text, trimmed),
|
|
183
|
+
reasons: reasonsFor(item.page, trimmed)
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
function reciprocalRankFusion(rankLists, k = RRF_K) {
|
|
187
|
+
return fuseRanks(rankLists, k);
|
|
188
|
+
}
|
|
189
|
+
function fuseRanks(rankLists, k = RRF_K) {
|
|
190
|
+
const scores = /* @__PURE__ */ new Map();
|
|
191
|
+
for (const list of rankLists) list.forEach((id, idx) => {
|
|
192
|
+
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + idx + 1));
|
|
193
|
+
});
|
|
194
|
+
return scores;
|
|
195
|
+
}
|
|
196
|
+
function filterPages(pages, options) {
|
|
197
|
+
const pageIds = options.pageIds ? new Set(options.pageIds) : null;
|
|
198
|
+
const tags = options.tags ? new Set(options.tags) : null;
|
|
199
|
+
const kinds = options.kinds ? new Set(options.kinds) : null;
|
|
200
|
+
return pages.filter((page) => {
|
|
201
|
+
if (options.excludeInvalidated && page.invalidation !== void 0) return false;
|
|
202
|
+
if (pageIds && !pageIds.has(page.id)) return false;
|
|
203
|
+
if (tags && !page.tags.some((tag) => tags.has(tag))) return false;
|
|
204
|
+
if (kinds) {
|
|
205
|
+
const kind = page.frontmatter.kind;
|
|
206
|
+
if (typeof kind !== "string" || !kinds.has(kind)) return false;
|
|
207
|
+
}
|
|
208
|
+
return options.predicate?.(page) ?? true;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function assertLexicalIndexMatches(lexicalIndex, pages) {
|
|
212
|
+
if (lexicalIndex.pages.length !== pages.length || lexicalIndex.pages.some((page, ordinal) => page !== pages[ordinal])) throw new Error("lexical index was not built from the searched pages");
|
|
213
|
+
return lexicalIndex;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The lexical rank list. An exact title or path match outranks a title that
|
|
217
|
+
* contains the query, which outranks a body that contains the query, which
|
|
218
|
+
* outranks a bag-of-words match; BM25 orders pages inside each of those tiers.
|
|
219
|
+
* The tiers are an ordering, not a score, so a strong bag-of-words page cannot
|
|
220
|
+
* overtake an exact match by term repetition alone.
|
|
221
|
+
*/
|
|
222
|
+
function rankLexical(pages, query, lexicalIndex) {
|
|
223
|
+
const tokens = [...new Set(lexicalIndex.tokenize(query))];
|
|
224
|
+
const bm25 = new Map(scoreBm25(lexicalIndex, tokens).map((hit) => [hit.page, hit.score]));
|
|
225
|
+
const phrase = query.toLowerCase();
|
|
226
|
+
return pages.flatMap((page) => {
|
|
227
|
+
const score = bm25.get(page) ?? 0;
|
|
228
|
+
if (score === 0 && tokens.length > 0) return [];
|
|
229
|
+
const tier = phraseTier(page, phrase);
|
|
230
|
+
if (score === 0 && tier === 0) return [];
|
|
231
|
+
return [{
|
|
232
|
+
page,
|
|
233
|
+
tier,
|
|
234
|
+
score
|
|
235
|
+
}];
|
|
236
|
+
}).sort((a, b) => b.tier - a.tier || b.score - a.score || comparePages(a.page, b.page)).map((item) => item.page);
|
|
237
|
+
}
|
|
238
|
+
function phraseTier(page, phrase) {
|
|
239
|
+
const title = page.title.toLowerCase();
|
|
240
|
+
if (title === phrase || page.path.toLowerCase().endsWith(`${phrase}.md`)) return 3;
|
|
241
|
+
if (title.includes(phrase)) return 2;
|
|
242
|
+
if (page.text.toLowerCase().includes(phrase)) return 1;
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
function rankByGraph(pages, lexicalRanked, visiblePages) {
|
|
246
|
+
if (lexicalRanked.length === 0) return [];
|
|
247
|
+
const counts = /* @__PURE__ */ new Map();
|
|
248
|
+
for (const page of visiblePages) counts.set(page.id, (counts.get(page.id) ?? 0) + 1);
|
|
249
|
+
const seeds = new Set(lexicalRanked.slice(0, 5).filter((page) => counts.get(page.id) === 1).map((page) => page.id));
|
|
250
|
+
return pages.map((page) => ({
|
|
251
|
+
page,
|
|
252
|
+
score: page.outLinks.filter((link) => seeds.has(link)).length + page.sourceIds.filter((source) => lexicalRanked.some((seed) => seed.sourceIds.includes(source))).length
|
|
253
|
+
})).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || comparePages(a.page, b.page)).map((item) => item.page);
|
|
254
|
+
}
|
|
255
|
+
function comparePages(a, b) {
|
|
256
|
+
return a.path.localeCompare(b.path) || a.id.localeCompare(b.id);
|
|
257
|
+
}
|
|
258
|
+
function buildSnippet(text, query) {
|
|
259
|
+
const compact = text.replace(/\s+/g, " ").trim();
|
|
260
|
+
const idx = compact.toLowerCase().indexOf(query.toLowerCase());
|
|
261
|
+
if (idx < 0) return compact.slice(0, 180);
|
|
262
|
+
return compact.slice(Math.max(0, idx - 80), Math.min(compact.length, idx + query.length + 100));
|
|
263
|
+
}
|
|
264
|
+
function reasonsFor(page, query) {
|
|
265
|
+
const lower = `${page.title}\n${page.text}`.toLowerCase();
|
|
266
|
+
const reasons = [];
|
|
267
|
+
if (lower.includes(query.toLowerCase())) reasons.push("phrase");
|
|
268
|
+
if (page.sourceIds.length > 0) reasons.push("sourced");
|
|
269
|
+
if (page.outLinks.length > 0) reasons.push("linked");
|
|
270
|
+
return reasons;
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
export { buildKnowledgeLexicalIndex as a, tokenizeText as c, lexicalPageTerms as d, searchKnowledgePages as i, DEFAULT_FIELD_BOOSTS as l, reciprocalRankFusion as n, scoreBm25 as o, searchKnowledge as r, tokenizeQuery as s, KNOWLEDGE_SEARCH_RETRIEVER_ID as t, assembleLexicalIndex as u };
|
|
274
|
+
|
|
275
|
+
//# sourceMappingURL=search-BBr5eUyT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-BBr5eUyT.js","names":[],"sources":["../src/lexical-postings.ts","../src/lexical-index.ts","../src/search.ts"],"sourcesContent":["import type {\n KnowledgeLexicalFieldBoosts,\n KnowledgeLexicalIndex,\n KnowledgeLexicalPosting,\n} from './lexical-index'\nimport type { KnowledgePage } from './types'\n\nexport const DEFAULT_FIELD_BOOSTS: Readonly<Required<KnowledgeLexicalFieldBoosts>> = Object.freeze({\n title: 3,\n path: 2,\n text: 1,\n})\n\n/** Field-boosted term frequencies and length of one page. */\nexport interface LexicalPageTerms {\n readonly frequencies: ReadonlyMap<string, number>\n readonly length: number\n}\n\nexport function lexicalPageTerms(\n page: KnowledgePage,\n tokenize: (text: string) => string[],\n fieldBoosts: Readonly<Required<KnowledgeLexicalFieldBoosts>>,\n): LexicalPageTerms {\n const frequencies = new Map<string, number>()\n let length = 0\n for (const [text, boost] of [\n [page.title, fieldBoosts.title],\n [page.path.replace(/\\.md$/, ''), fieldBoosts.path],\n [page.text, fieldBoosts.text],\n ] as const) {\n if (boost === 0) continue\n for (const token of tokenize(text)) {\n frequencies.set(token, (frequencies.get(token) ?? 0) + boost)\n length += boost\n }\n }\n return { frequencies, length }\n}\n\n/** Inverted index from per-page terms already computed with `tokenize` and `fieldBoosts`. */\nexport function assembleLexicalIndex(\n pages: readonly KnowledgePage[],\n terms: readonly LexicalPageTerms[],\n tokenize: (text: string) => string[],\n fieldBoosts: Readonly<Required<KnowledgeLexicalFieldBoosts>>,\n): KnowledgeLexicalIndex {\n const postings = new Map<string, KnowledgeLexicalPosting[]>()\n const documentLengths: number[] = []\n let totalLength = 0\n terms.forEach(({ frequencies, length }, ordinal) => {\n documentLengths.push(length)\n totalLength += length\n for (const [term, tf] of frequencies) {\n let list = postings.get(term)\n if (!list) {\n list = []\n postings.set(term, list)\n }\n list.push({ ordinal, tf })\n }\n })\n return {\n pages,\n postings,\n documentLengths,\n averageDocumentLength: pages.length > 0 ? totalLength / pages.length : 0,\n documentCount: pages.length,\n tokenize,\n fieldBoosts,\n }\n}\n","import { assembleLexicalIndex, DEFAULT_FIELD_BOOSTS, lexicalPageTerms } from './lexical-postings'\nimport type { KnowledgePage } from './types'\n\nconst STOP_WORDS = new Set([\n 'the',\n 'is',\n 'a',\n 'an',\n 'what',\n 'how',\n 'are',\n 'was',\n 'were',\n 'to',\n 'for',\n 'of',\n 'with',\n 'by',\n 'in',\n 'on',\n 'and',\n])\n\n/**\n * The token stream of one text: lower-cased, split on whitespace and\n * punctuation, single characters and stop words removed, and a CJK run\n * expanded into its bigrams and characters. Repeats are kept so a term\n * frequency can be counted. Indexing and querying share this function, so the\n * two vocabularies cannot drift.\n */\nexport function tokenizeText(text: string): string[] {\n const raw = text\n .toLowerCase()\n .split(/[\\s,,。!?、;:\"\"''()()\\-_/\\\\·~~…]+/)\n .filter((token) => token.length > 1 && !STOP_WORDS.has(token))\n const tokens: string[] = []\n for (const token of raw) {\n if (/[\\u4e00-\\u9fff\\u3400-\\u4dbf]/.test(token) && token.length > 2) {\n const chars = [...token]\n for (let i = 0; i < chars.length - 1; i++) tokens.push(chars[i]! + chars[i + 1]!)\n tokens.push(...chars)\n }\n tokens.push(token)\n }\n return tokens\n}\n\n/** The distinct query terms, in first-occurrence order. */\nexport function tokenizeQuery(query: string): string[] {\n return [...new Set(tokenizeText(query))]\n}\n\nexport interface KnowledgeLexicalFieldBoosts {\n /** Multiplier for a term occurrence in the page title. Defaults to 3. */\n title?: number\n /** Multiplier for a term occurrence in the page path without its extension. Defaults to 2. */\n path?: number\n /** Multiplier for a term occurrence in the page body. Defaults to 1. */\n text?: number\n}\n\nexport interface KnowledgeLexicalIndexOptions {\n /** Token stream of one text. Defaults to `tokenizeText`. */\n tokenize?: (text: string) => string[]\n fieldBoosts?: KnowledgeLexicalFieldBoosts\n}\n\nexport interface KnowledgeLexicalPosting {\n /** Position of the page in `KnowledgeLexicalIndex.pages`. */\n ordinal: number\n /** Field-boosted term frequency in that page. */\n tf: number\n}\n\n/**\n * Inverted index over a fixed page list.\n *\n * Ordinals are positions in `pages`. `documentLengths` are field-boosted token\n * counts, so length normalization and term frequency use one scale.\n */\nexport interface KnowledgeLexicalIndex {\n readonly pages: readonly KnowledgePage[]\n readonly postings: ReadonlyMap<string, readonly KnowledgeLexicalPosting[]>\n readonly documentLengths: readonly number[]\n readonly averageDocumentLength: number\n readonly documentCount: number\n readonly tokenize: (text: string) => string[]\n readonly fieldBoosts: Readonly<Required<KnowledgeLexicalFieldBoosts>>\n}\n\nexport interface Bm25Options {\n /** Term-frequency saturation. Defaults to 1.2. */\n k1?: number\n /** Length-normalization strength in [0, 1]. Defaults to 0.75. */\n b?: number\n}\n\nexport interface Bm25Hit {\n page: KnowledgePage\n score: number\n}\n\nexport function buildKnowledgeLexicalIndex(\n pages: readonly KnowledgePage[],\n options: KnowledgeLexicalIndexOptions = {},\n): KnowledgeLexicalIndex {\n const tokenize = options.tokenize ?? tokenizeText\n const fieldBoosts = resolveFieldBoosts(options.fieldBoosts)\n return assembleLexicalIndex(\n pages,\n pages.map((page) => lexicalPageTerms(page, tokenize, fieldBoosts)),\n tokenize,\n fieldBoosts,\n )\n}\n\n/**\n * Okapi BM25 over the distinct query terms, with the Lucene inverse document\n * frequency `ln(1 + (N - df + 0.5) / (df + 0.5))`, which is positive for every\n * indexed term. Pages with no matching term are absent. The result is ordered\n * by score, then by path, so it does not depend on page order.\n */\nexport function scoreBm25(\n index: KnowledgeLexicalIndex,\n tokens: readonly string[],\n options: Bm25Options = {},\n): Bm25Hit[] {\n const k1 = options.k1 ?? 1.2\n const b = options.b ?? 0.75\n if (!Number.isFinite(k1) || k1 < 0) throw new Error(`bm25 k1 must be >= 0, got ${String(k1)}`)\n if (!Number.isFinite(b) || b < 0 || b > 1) {\n throw new Error(`bm25 b must lie in [0, 1], got ${String(b)}`)\n }\n const scores = new Map<number, number>()\n const { documentCount, averageDocumentLength, documentLengths } = index\n for (const term of new Set(tokens)) {\n const list = index.postings.get(term)\n if (!list) continue\n const df = list.length\n const idf = Math.log(1 + (documentCount - df + 0.5) / (df + 0.5))\n for (const { ordinal, tf } of list) {\n const lengthRatio =\n averageDocumentLength > 0 ? documentLengths[ordinal]! / averageDocumentLength : 1\n const saturated = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * lengthRatio))\n scores.set(ordinal, (scores.get(ordinal) ?? 0) + idf * saturated)\n }\n }\n return [...scores.entries()]\n .map(([ordinal, score]) => ({ page: index.pages[ordinal]!, score }))\n .sort(\n (left, right) => right.score - left.score || left.page.path.localeCompare(right.page.path),\n )\n}\n\nfunction resolveFieldBoosts(\n boosts: KnowledgeLexicalFieldBoosts | undefined,\n): Readonly<Required<KnowledgeLexicalFieldBoosts>> {\n const resolved = { ...DEFAULT_FIELD_BOOSTS, ...boosts }\n for (const [field, boost] of Object.entries(resolved)) {\n if (!Number.isFinite(boost) || boost < 0) {\n throw new Error(`lexical field boost ${field} must be >= 0, got ${String(boost)}`)\n }\n }\n return Object.freeze(resolved)\n}\n","import { buildKnowledgeLexicalIndex, type KnowledgeLexicalIndex, scoreBm25 } from './lexical-index'\nimport type { KnowledgeId, KnowledgeIndex, KnowledgePage, KnowledgeSearchResult } from './types'\n\nconst RRF_K = 60\n\n/**\n * Identity of the ranking `searchKnowledge` performs: BM25 over title, path,\n * and body, exact-title and phrase matches ahead of bag-of-words matches, and\n * reciprocal rank fusion with link and shared-source structure. Declare it as\n * the retriever id of a retrieval receipt minted from these results.\n */\nexport const KNOWLEDGE_SEARCH_RETRIEVER_ID = 'bm25-rrf-v1'\n\nexport interface SearchKnowledgeOptions {\n /** Maximum results returned. Defaults to 10. */\n limit?: number\n /** Match pages whose stable id is one of these values. */\n pageIds?: readonly KnowledgeId[]\n /** Match pages carrying at least one of these tags. */\n tags?: readonly string[]\n /** Match the exact string stored in `frontmatter.kind`. */\n kinds?: readonly string[]\n /**\n * Drop pages whose own evidence refuted them. Defaults to false: a caller\n * that reads history needs them, and a silent change of what search returns\n * is worse than an explicit option.\n */\n excludeInvalidated?: boolean\n /** Additional caller-owned filter, applied before either ranking stage. */\n predicate?: (page: KnowledgePage) => boolean\n /**\n * A lexical index built from exactly the searched pages, supplied by a caller\n * that searches one page set repeatedly. When absent, one is built for this\n * call.\n */\n lexicalIndex?: KnowledgeLexicalIndex\n}\n\n/**\n * A retrieval result with an explicit citation handle.\n *\n * Unscoped search returns `page.id`; a run-scoped brief qualifies ambiguous ids\n * through the citation resolver. Later writes should persist the returned value. Keeping it at the result's top level prevents tool\n * renderers from accidentally hiding the only stable handle a model can copy.\n */\nexport interface KnowledgeSearchHit extends KnowledgeSearchResult {\n citationId: KnowledgeId\n}\n\nexport function searchKnowledge(\n index: KnowledgeIndex,\n query: string,\n limit?: number,\n): KnowledgeSearchHit[]\nexport function searchKnowledge(\n index: KnowledgeIndex,\n query: string,\n options?: SearchKnowledgeOptions,\n): KnowledgeSearchHit[]\nexport function searchKnowledge(\n index: KnowledgeIndex,\n query: string,\n limitOrOptions: number | SearchKnowledgeOptions = 10,\n): KnowledgeSearchHit[] {\n return searchKnowledgePages(index.pages, query, limitOrOptions)\n}\n\n/**\n * Rank a page set that is not a built index, such as the chain a run can see.\n *\n * `searchKnowledge` is this function over `index.pages`, so both entry points\n * rank identically and neither can drift from the other.\n */\nexport function searchKnowledgePages(\n pages: readonly KnowledgePage[],\n query: string,\n limitOrOptions: number | SearchKnowledgeOptions = 10,\n): KnowledgeSearchHit[] {\n const trimmed = query.trim()\n if (trimmed === '') return []\n const options =\n typeof limitOrOptions === 'number' ? { limit: limitOrOptions } : { ...limitOrOptions }\n const limit = options.limit ?? 10\n if (!Number.isInteger(limit) || limit < 0) {\n throw new Error(`search limit must be a non-negative integer, got ${String(limit)}`)\n }\n\n const matched = filterPages(pages, options)\n const lexicalIndex = options.lexicalIndex\n ? assertLexicalIndexMatches(options.lexicalIndex, pages)\n : buildKnowledgeLexicalIndex(pages)\n const lexicalRanked = rankLexical(matched, trimmed, lexicalIndex)\n const graphRanked = rankByGraph(matched, lexicalRanked, pages)\n // Stable ids are citation addresses, not unique document identities across stores.\n // Fuse the same page objects used by BM25, then return those exact pages.\n const scores = fuseRanks([lexicalRanked, graphRanked])\n\n const ranked = [...scores.entries()]\n .map(([page, score]) => ({ page, score }))\n .sort((a, b) => b.score - a.score || comparePages(a.page, b.page))\n .slice(0, limit)\n\n // Normalize against the top hit so callers can compare against natural\n // [0, 1] thresholds. Raw RRF values are typically ~0.016, which reads as\n // \"no relevance\" to humans even when the result is the best available.\n // The top hit becomes 1 by definition; lower-ranked hits scale linearly.\n const topScore = ranked[0]?.score ?? 0\n\n return ranked.map((item, i) => ({\n citationId: item.page.id,\n page: item.page,\n score: item.score,\n rrfScore: item.score,\n normalizedScore: topScore > 0 ? item.score / topScore : 0,\n rank: i + 1,\n snippet: buildSnippet(item.page.text, trimmed),\n reasons: reasonsFor(item.page, trimmed),\n }))\n}\n\nexport function reciprocalRankFusion(rankLists: string[][], k = RRF_K): Map<string, number> {\n return fuseRanks(rankLists, k)\n}\n\n// The public string-key helper and document retrieval share one ranking implementation.\nfunction fuseRanks<T>(rankLists: readonly (readonly T[])[], k = RRF_K): Map<T, number> {\n const scores = new Map<T, number>()\n for (const list of rankLists) {\n list.forEach((id, idx) => {\n scores.set(id, (scores.get(id) ?? 0) + 1 / (k + idx + 1))\n })\n }\n return scores\n}\n\nfunction filterPages(\n pages: readonly KnowledgePage[],\n options: SearchKnowledgeOptions,\n): KnowledgePage[] {\n const pageIds = options.pageIds ? new Set(options.pageIds) : null\n const tags = options.tags ? new Set(options.tags) : null\n const kinds = options.kinds ? new Set(options.kinds) : null\n\n return pages.filter((page) => {\n if (options.excludeInvalidated && page.invalidation !== undefined) return false\n if (pageIds && !pageIds.has(page.id)) return false\n if (tags && !page.tags.some((tag) => tags.has(tag))) return false\n if (kinds) {\n const kind = page.frontmatter.kind\n if (typeof kind !== 'string' || !kinds.has(kind)) return false\n }\n return options.predicate?.(page) ?? true\n })\n}\n\nfunction assertLexicalIndexMatches(\n lexicalIndex: KnowledgeLexicalIndex,\n pages: readonly KnowledgePage[],\n): KnowledgeLexicalIndex {\n if (\n lexicalIndex.pages.length !== pages.length ||\n lexicalIndex.pages.some((page, ordinal) => page !== pages[ordinal])\n ) {\n throw new Error('lexical index was not built from the searched pages')\n }\n return lexicalIndex\n}\n\n/**\n * The lexical rank list. An exact title or path match outranks a title that\n * contains the query, which outranks a body that contains the query, which\n * outranks a bag-of-words match; BM25 orders pages inside each of those tiers.\n * The tiers are an ordering, not a score, so a strong bag-of-words page cannot\n * overtake an exact match by term repetition alone.\n */\nfunction rankLexical(\n pages: KnowledgePage[],\n query: string,\n lexicalIndex: KnowledgeLexicalIndex,\n): KnowledgePage[] {\n const tokens = [...new Set(lexicalIndex.tokenize(query))]\n const bm25 = new Map(scoreBm25(lexicalIndex, tokens).map((hit) => [hit.page, hit.score]))\n const phrase = query.toLowerCase()\n return pages\n .flatMap((page) => {\n const score = bm25.get(page) ?? 0\n // A query that tokenizes to nothing (stop words, single characters) can\n // still match as a phrase; otherwise only pages with a scored term are\n // candidates, and every phrase match is one of them.\n if (score === 0 && tokens.length > 0) return []\n const tier = phraseTier(page, phrase)\n if (score === 0 && tier === 0) return []\n return [{ page, tier, score }]\n })\n .sort((a, b) => b.tier - a.tier || b.score - a.score || comparePages(a.page, b.page))\n .map((item) => item.page)\n}\n\nfunction phraseTier(page: KnowledgePage, phrase: string): number {\n const title = page.title.toLowerCase()\n if (title === phrase || page.path.toLowerCase().endsWith(`${phrase}.md`)) return 3\n if (title.includes(phrase)) return 2\n if (page.text.toLowerCase().includes(phrase)) return 1\n return 0\n}\n\nfunction rankByGraph(\n pages: KnowledgePage[],\n lexicalRanked: KnowledgePage[],\n visiblePages: readonly KnowledgePage[],\n): KnowledgePage[] {\n if (lexicalRanked.length === 0) return []\n // A bare link to a duplicate id has no unambiguous target. Scoped callers\n // qualify links before ranking; unscoped callers must not invent that edge.\n const counts = new Map<string, number>()\n for (const page of visiblePages) counts.set(page.id, (counts.get(page.id) ?? 0) + 1)\n const seeds = new Set(\n lexicalRanked\n .slice(0, 5)\n .filter((page) => counts.get(page.id) === 1)\n .map((page) => page.id),\n )\n return pages\n .map((page) => ({\n page,\n score:\n page.outLinks.filter((link) => seeds.has(link)).length +\n page.sourceIds.filter((source) =>\n lexicalRanked.some((seed) => seed.sourceIds.includes(source)),\n ).length,\n }))\n .filter((item) => item.score > 0)\n .sort((a, b) => b.score - a.score || comparePages(a.page, b.page))\n .map((item) => item.page)\n}\n\nfunction comparePages(a: KnowledgePage, b: KnowledgePage): number {\n return a.path.localeCompare(b.path) || a.id.localeCompare(b.id)\n}\n\nfunction buildSnippet(text: string, query: string): string {\n const compact = text.replace(/\\s+/g, ' ').trim()\n const idx = compact.toLowerCase().indexOf(query.toLowerCase())\n if (idx < 0) return compact.slice(0, 180)\n return compact.slice(Math.max(0, idx - 80), Math.min(compact.length, idx + query.length + 100))\n}\n\nfunction reasonsFor(page: KnowledgePage, query: string): string[] {\n const lower = `${page.title}\\n${page.text}`.toLowerCase()\n const reasons: string[] = []\n if (lower.includes(query.toLowerCase())) reasons.push('phrase')\n if (page.sourceIds.length > 0) reasons.push('sourced')\n if (page.outLinks.length > 0) reasons.push('linked')\n return reasons\n}\n"],"mappings":";AAOA,MAAa,uBAAwE,OAAO,OAAO;CACjG,OAAO;CACP,MAAM;CACN,MAAM;AACR,CAAC;AAQD,SAAgB,iBACd,MACA,UACA,aACkB;CAClB,MAAM,8BAAc,IAAI,IAAoB;CAC5C,IAAI,SAAS;CACb,KAAK,MAAM,CAAC,MAAM,UAAU;EAC1B,CAAC,KAAK,OAAO,YAAY,KAAK;EAC9B,CAAC,KAAK,KAAK,QAAQ,SAAS,EAAE,GAAG,YAAY,IAAI;EACjD,CAAC,KAAK,MAAM,YAAY,IAAI;CAC9B,GAAY;EACV,IAAI,UAAU,GAAG;EACjB,KAAK,MAAM,SAAS,SAAS,IAAI,GAAG;GAClC,YAAY,IAAI,QAAQ,YAAY,IAAI,KAAK,KAAK,KAAK,KAAK;GAC5D,UAAU;EACZ;CACF;CACA,OAAO;EAAE;EAAa;CAAO;AAC/B;;AAGA,SAAgB,qBACd,OACA,OACA,UACA,aACuB;CACvB,MAAM,2BAAW,IAAI,IAAuC;CAC5D,MAAM,kBAA4B,CAAC;CACnC,IAAI,cAAc;CAClB,MAAM,SAAS,EAAE,aAAa,UAAU,YAAY;EAClD,gBAAgB,KAAK,MAAM;EAC3B,eAAe;EACf,KAAK,MAAM,CAAC,MAAM,OAAO,aAAa;GACpC,IAAI,OAAO,SAAS,IAAI,IAAI;GAC5B,IAAI,CAAC,MAAM;IACT,OAAO,CAAC;IACR,SAAS,IAAI,MAAM,IAAI;GACzB;GACA,KAAK,KAAK;IAAE;IAAS;GAAG,CAAC;EAC3B;CACF,CAAC;CACD,OAAO;EACL;EACA;EACA;EACA,uBAAuB,MAAM,SAAS,IAAI,cAAc,MAAM,SAAS;EACvE,eAAe,MAAM;EACrB;EACA;CACF;AACF;;;ACpEA,MAAM,6BAAa,IAAI,IAAI;CACzB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;AASD,SAAgB,aAAa,MAAwB;CACnD,MAAM,MAAM,KACT,YAAY,CAAC,CACb,MAAM,iCAAiC,CAAC,CACxC,QAAQ,UAAU,MAAM,SAAS,KAAK,CAAC,WAAW,IAAI,KAAK,CAAC;CAC/D,MAAM,SAAmB,CAAC;CAC1B,KAAK,MAAM,SAAS,KAAK;EACvB,IAAI,+BAA+B,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG;GAClE,MAAM,QAAQ,CAAC,GAAG,KAAK;GACvB,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,SAAS,GAAG,KAAK,OAAO,KAAK,MAAM,KAAM,MAAM,IAAI,EAAG;GAChF,OAAO,KAAK,GAAG,KAAK;EACtB;EACA,OAAO,KAAK,KAAK;CACnB;CACA,OAAO;AACT;;AAGA,SAAgB,cAAc,OAAyB;CACrD,OAAO,CAAC,GAAG,IAAI,IAAI,aAAa,KAAK,CAAC,CAAC;AACzC;AAoDA,SAAgB,2BACd,OACA,UAAwC,CAAC,GAClB;CACvB,MAAM,WAAW,QAAQ,YAAY;CACrC,MAAM,cAAc,mBAAmB,QAAQ,WAAW;CAC1D,OAAO,qBACL,OACA,MAAM,KAAK,SAAS,iBAAiB,MAAM,UAAU,WAAW,CAAC,GACjE,UACA,WACF;AACF;;;;;;;AAQA,SAAgB,UACd,OACA,QACA,UAAuB,CAAC,GACb;CACX,MAAM,KAAK,QAAQ,MAAM;CACzB,MAAM,IAAI,QAAQ,KAAK;CACvB,IAAI,CAAC,OAAO,SAAS,EAAE,KAAK,KAAK,GAAG,MAAM,IAAI,MAAM,6BAA6B,OAAO,EAAE,GAAG;CAC7F,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,KAAK,IAAI,GACtC,MAAM,IAAI,MAAM,kCAAkC,OAAO,CAAC,GAAG;CAE/D,MAAM,yBAAS,IAAI,IAAoB;CACvC,MAAM,EAAE,eAAe,uBAAuB,oBAAoB;CAClE,KAAK,MAAM,QAAQ,IAAI,IAAI,MAAM,GAAG;EAClC,MAAM,OAAO,MAAM,SAAS,IAAI,IAAI;EACpC,IAAI,CAAC,MAAM;EACX,MAAM,KAAK,KAAK;EAChB,MAAM,MAAM,KAAK,IAAI,KAAK,gBAAgB,KAAK,OAAQ,KAAK,GAAI;EAChE,KAAK,MAAM,EAAE,SAAS,QAAQ,MAAM;GAClC,MAAM,cACJ,wBAAwB,IAAI,gBAAgB,WAAY,wBAAwB;GAClF,MAAM,YAAa,MAAM,KAAK,MAAO,KAAK,MAAM,IAAI,IAAI,IAAI;GAC5D,OAAO,IAAI,UAAU,OAAO,IAAI,OAAO,KAAK,KAAK,MAAM,SAAS;EAClE;CACF;CACA,OAAO,CAAC,GAAG,OAAO,QAAQ,CAAC,CAAC,CACzB,KAAK,CAAC,SAAS,YAAY;EAAE,MAAM,MAAM,MAAM;EAAW;CAAM,EAAE,CAAC,CACnE,MACE,MAAM,UAAU,MAAM,QAAQ,KAAK,SAAS,KAAK,KAAK,KAAK,cAAc,MAAM,KAAK,IAAI,CAC3F;AACJ;AAEA,SAAS,mBACP,QACiD;CACjD,MAAM,WAAW;EAAE,GAAG;EAAsB,GAAG;CAAO;CACtD,KAAK,MAAM,CAAC,OAAO,UAAU,OAAO,QAAQ,QAAQ,GAClD,IAAI,CAAC,OAAO,SAAS,KAAK,KAAK,QAAQ,GACrC,MAAM,IAAI,MAAM,uBAAuB,MAAM,qBAAqB,OAAO,KAAK,GAAG;CAGrF,OAAO,OAAO,OAAO,QAAQ;AAC/B;;;ACjKA,MAAM,QAAQ;;;;;;;AAQd,MAAa,gCAAgC;AAgD7C,SAAgB,gBACd,OACA,OACA,iBAAkD,IAC5B;CACtB,OAAO,qBAAqB,MAAM,OAAO,OAAO,cAAc;AAChE;;;;;;;AAQA,SAAgB,qBACd,OACA,OACA,iBAAkD,IAC5B;CACtB,MAAM,UAAU,MAAM,KAAK;CAC3B,IAAI,YAAY,IAAI,OAAO,CAAC;CAC5B,MAAM,UACJ,OAAO,mBAAmB,WAAW,EAAE,OAAO,eAAe,IAAI,EAAE,GAAG,eAAe;CACvF,MAAM,QAAQ,QAAQ,SAAS;CAC/B,IAAI,CAAC,OAAO,UAAU,KAAK,KAAK,QAAQ,GACtC,MAAM,IAAI,MAAM,oDAAoD,OAAO,KAAK,GAAG;CAGrF,MAAM,UAAU,YAAY,OAAO,OAAO;CAI1C,MAAM,gBAAgB,YAAY,SAAS,SAHtB,QAAQ,eACzB,0BAA0B,QAAQ,cAAc,KAAK,IACrD,2BAA2B,KAAK,CAC4B;CAMhE,MAAM,SAAS,CAAC,GAFD,UAAU,CAAC,eAHN,YAAY,SAAS,eAAe,KAGL,CAAC,CAE5B,CAAC,CAAC,QAAQ,CAAC,CAAC,CACjC,KAAK,CAAC,MAAM,YAAY;EAAE;EAAM;CAAM,EAAE,CAAC,CACzC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,aAAa,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CACjE,MAAM,GAAG,KAAK;CAMjB,MAAM,WAAW,OAAO,EAAE,EAAE,SAAS;CAErC,OAAO,OAAO,KAAK,MAAM,OAAO;EAC9B,YAAY,KAAK,KAAK;EACtB,MAAM,KAAK;EACX,OAAO,KAAK;EACZ,UAAU,KAAK;EACf,iBAAiB,WAAW,IAAI,KAAK,QAAQ,WAAW;EACxD,MAAM,IAAI;EACV,SAAS,aAAa,KAAK,KAAK,MAAM,OAAO;EAC7C,SAAS,WAAW,KAAK,MAAM,OAAO;CACxC,EAAE;AACJ;AAEA,SAAgB,qBAAqB,WAAuB,IAAI,OAA4B;CAC1F,OAAO,UAAU,WAAW,CAAC;AAC/B;AAGA,SAAS,UAAa,WAAsC,IAAI,OAAuB;CACrF,MAAM,yBAAS,IAAI,IAAe;CAClC,KAAK,MAAM,QAAQ,WACjB,KAAK,SAAS,IAAI,QAAQ;EACxB,OAAO,IAAI,KAAK,OAAO,IAAI,EAAE,KAAK,KAAK,KAAK,IAAI,MAAM,EAAE;CAC1D,CAAC;CAEH,OAAO;AACT;AAEA,SAAS,YACP,OACA,SACiB;CACjB,MAAM,UAAU,QAAQ,UAAU,IAAI,IAAI,QAAQ,OAAO,IAAI;CAC7D,MAAM,OAAO,QAAQ,OAAO,IAAI,IAAI,QAAQ,IAAI,IAAI;CACpD,MAAM,QAAQ,QAAQ,QAAQ,IAAI,IAAI,QAAQ,KAAK,IAAI;CAEvD,OAAO,MAAM,QAAQ,SAAS;EAC5B,IAAI,QAAQ,sBAAsB,KAAK,iBAAiB,KAAA,GAAW,OAAO;EAC1E,IAAI,WAAW,CAAC,QAAQ,IAAI,KAAK,EAAE,GAAG,OAAO;EAC7C,IAAI,QAAQ,CAAC,KAAK,KAAK,MAAM,QAAQ,KAAK,IAAI,GAAG,CAAC,GAAG,OAAO;EAC5D,IAAI,OAAO;GACT,MAAM,OAAO,KAAK,YAAY;GAC9B,IAAI,OAAO,SAAS,YAAY,CAAC,MAAM,IAAI,IAAI,GAAG,OAAO;EAC3D;EACA,OAAO,QAAQ,YAAY,IAAI,KAAK;CACtC,CAAC;AACH;AAEA,SAAS,0BACP,cACA,OACuB;CACvB,IACE,aAAa,MAAM,WAAW,MAAM,UACpC,aAAa,MAAM,MAAM,MAAM,YAAY,SAAS,MAAM,QAAQ,GAElE,MAAM,IAAI,MAAM,qDAAqD;CAEvE,OAAO;AACT;;;;;;;;AASA,SAAS,YACP,OACA,OACA,cACiB;CACjB,MAAM,SAAS,CAAC,GAAG,IAAI,IAAI,aAAa,SAAS,KAAK,CAAC,CAAC;CACxD,MAAM,OAAO,IAAI,IAAI,UAAU,cAAc,MAAM,CAAC,CAAC,KAAK,QAAQ,CAAC,IAAI,MAAM,IAAI,KAAK,CAAC,CAAC;CACxF,MAAM,SAAS,MAAM,YAAY;CACjC,OAAO,MACJ,SAAS,SAAS;EACjB,MAAM,QAAQ,KAAK,IAAI,IAAI,KAAK;EAIhC,IAAI,UAAU,KAAK,OAAO,SAAS,GAAG,OAAO,CAAC;EAC9C,MAAM,OAAO,WAAW,MAAM,MAAM;EACpC,IAAI,UAAU,KAAK,SAAS,GAAG,OAAO,CAAC;EACvC,OAAO,CAAC;GAAE;GAAM;GAAM;EAAM,CAAC;CAC/B,CAAC,CAAC,CACD,MAAM,GAAG,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,aAAa,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CACpF,KAAK,SAAS,KAAK,IAAI;AAC5B;AAEA,SAAS,WAAW,MAAqB,QAAwB;CAC/D,MAAM,QAAQ,KAAK,MAAM,YAAY;CACrC,IAAI,UAAU,UAAU,KAAK,KAAK,YAAY,CAAC,CAAC,SAAS,GAAG,OAAO,IAAI,GAAG,OAAO;CACjF,IAAI,MAAM,SAAS,MAAM,GAAG,OAAO;CACnC,IAAI,KAAK,KAAK,YAAY,CAAC,CAAC,SAAS,MAAM,GAAG,OAAO;CACrD,OAAO;AACT;AAEA,SAAS,YACP,OACA,eACA,cACiB;CACjB,IAAI,cAAc,WAAW,GAAG,OAAO,CAAC;CAGxC,MAAM,yBAAS,IAAI,IAAoB;CACvC,KAAK,MAAM,QAAQ,cAAc,OAAO,IAAI,KAAK,KAAK,OAAO,IAAI,KAAK,EAAE,KAAK,KAAK,CAAC;CACnF,MAAM,QAAQ,IAAI,IAChB,cACG,MAAM,GAAG,CAAC,CAAC,CACX,QAAQ,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,CAAC,CAAC,CAC3C,KAAK,SAAS,KAAK,EAAE,CAC1B;CACA,OAAO,MACJ,KAAK,UAAU;EACd;EACA,OACE,KAAK,SAAS,QAAQ,SAAS,MAAM,IAAI,IAAI,CAAC,CAAC,CAAC,SAChD,KAAK,UAAU,QAAQ,WACrB,cAAc,MAAM,SAAS,KAAK,UAAU,SAAS,MAAM,CAAC,CAC9D,CAAC,CAAC;CACN,EAAE,CAAC,CACF,QAAQ,SAAS,KAAK,QAAQ,CAAC,CAAC,CAChC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,aAAa,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC,CACjE,KAAK,SAAS,KAAK,IAAI;AAC5B;AAEA,SAAS,aAAa,GAAkB,GAA0B;CAChE,OAAO,EAAE,KAAK,cAAc,EAAE,IAAI,KAAK,EAAE,GAAG,cAAc,EAAE,EAAE;AAChE;AAEA,SAAS,aAAa,MAAc,OAAuB;CACzD,MAAM,UAAU,KAAK,QAAQ,QAAQ,GAAG,CAAC,CAAC,KAAK;CAC/C,MAAM,MAAM,QAAQ,YAAY,CAAC,CAAC,QAAQ,MAAM,YAAY,CAAC;CAC7D,IAAI,MAAM,GAAG,OAAO,QAAQ,MAAM,GAAG,GAAG;CACxC,OAAO,QAAQ,MAAM,KAAK,IAAI,GAAG,MAAM,EAAE,GAAG,KAAK,IAAI,QAAQ,QAAQ,MAAM,MAAM,SAAS,GAAG,CAAC;AAChG;AAEA,SAAS,WAAW,MAAqB,OAAyB;CAChE,MAAM,QAAQ,GAAG,KAAK,MAAM,IAAI,KAAK,OAAO,YAAY;CACxD,MAAM,UAAoB,CAAC;CAC3B,IAAI,MAAM,SAAS,MAAM,YAAY,CAAC,GAAG,QAAQ,KAAK,QAAQ;CAC9D,IAAI,KAAK,UAAU,SAAS,GAAG,QAAQ,KAAK,SAAS;CACrD,IAAI,KAAK,SAAS,SAAS,GAAG,QAAQ,KAAK,QAAQ;CACnD,OAAO;AACT"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { b as KnowledgeSearchResult, f as KnowledgeId, h as KnowledgePage, p as KnowledgeIndex } from "./types-BekdNwGD.js";
|
|
2
|
+
//#region src/lexical-index.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The token stream of one text: lower-cased, split on whitespace and
|
|
5
|
+
* punctuation, single characters and stop words removed, and a CJK run
|
|
6
|
+
* expanded into its bigrams and characters. Repeats are kept so a term
|
|
7
|
+
* frequency can be counted. Indexing and querying share this function, so the
|
|
8
|
+
* two vocabularies cannot drift.
|
|
9
|
+
*/
|
|
10
|
+
declare function tokenizeText(text: string): string[];
|
|
11
|
+
/** The distinct query terms, in first-occurrence order. */
|
|
12
|
+
declare function tokenizeQuery(query: string): string[];
|
|
13
|
+
interface KnowledgeLexicalFieldBoosts {
|
|
14
|
+
/** Multiplier for a term occurrence in the page title. Defaults to 3. */
|
|
15
|
+
title?: number;
|
|
16
|
+
/** Multiplier for a term occurrence in the page path without its extension. Defaults to 2. */
|
|
17
|
+
path?: number;
|
|
18
|
+
/** Multiplier for a term occurrence in the page body. Defaults to 1. */
|
|
19
|
+
text?: number;
|
|
20
|
+
}
|
|
21
|
+
interface KnowledgeLexicalIndexOptions {
|
|
22
|
+
/** Token stream of one text. Defaults to `tokenizeText`. */
|
|
23
|
+
tokenize?: (text: string) => string[];
|
|
24
|
+
fieldBoosts?: KnowledgeLexicalFieldBoosts;
|
|
25
|
+
}
|
|
26
|
+
interface KnowledgeLexicalPosting {
|
|
27
|
+
/** Position of the page in `KnowledgeLexicalIndex.pages`. */
|
|
28
|
+
ordinal: number;
|
|
29
|
+
/** Field-boosted term frequency in that page. */
|
|
30
|
+
tf: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Inverted index over a fixed page list.
|
|
34
|
+
*
|
|
35
|
+
* Ordinals are positions in `pages`. `documentLengths` are field-boosted token
|
|
36
|
+
* counts, so length normalization and term frequency use one scale.
|
|
37
|
+
*/
|
|
38
|
+
interface KnowledgeLexicalIndex {
|
|
39
|
+
readonly pages: readonly KnowledgePage[];
|
|
40
|
+
readonly postings: ReadonlyMap<string, readonly KnowledgeLexicalPosting[]>;
|
|
41
|
+
readonly documentLengths: readonly number[];
|
|
42
|
+
readonly averageDocumentLength: number;
|
|
43
|
+
readonly documentCount: number;
|
|
44
|
+
readonly tokenize: (text: string) => string[];
|
|
45
|
+
readonly fieldBoosts: Readonly<Required<KnowledgeLexicalFieldBoosts>>;
|
|
46
|
+
}
|
|
47
|
+
interface Bm25Options {
|
|
48
|
+
/** Term-frequency saturation. Defaults to 1.2. */
|
|
49
|
+
k1?: number;
|
|
50
|
+
/** Length-normalization strength in [0, 1]. Defaults to 0.75. */
|
|
51
|
+
b?: number;
|
|
52
|
+
}
|
|
53
|
+
interface Bm25Hit {
|
|
54
|
+
page: KnowledgePage;
|
|
55
|
+
score: number;
|
|
56
|
+
}
|
|
57
|
+
declare function buildKnowledgeLexicalIndex(pages: readonly KnowledgePage[], options?: KnowledgeLexicalIndexOptions): KnowledgeLexicalIndex;
|
|
58
|
+
/**
|
|
59
|
+
* Okapi BM25 over the distinct query terms, with the Lucene inverse document
|
|
60
|
+
* frequency `ln(1 + (N - df + 0.5) / (df + 0.5))`, which is positive for every
|
|
61
|
+
* indexed term. Pages with no matching term are absent. The result is ordered
|
|
62
|
+
* by score, then by path, so it does not depend on page order.
|
|
63
|
+
*/
|
|
64
|
+
declare function scoreBm25(index: KnowledgeLexicalIndex, tokens: readonly string[], options?: Bm25Options): Bm25Hit[];
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/search.d.ts
|
|
67
|
+
/**
|
|
68
|
+
* Identity of the ranking `searchKnowledge` performs: BM25 over title, path,
|
|
69
|
+
* and body, exact-title and phrase matches ahead of bag-of-words matches, and
|
|
70
|
+
* reciprocal rank fusion with link and shared-source structure. Declare it as
|
|
71
|
+
* the retriever id of a retrieval receipt minted from these results.
|
|
72
|
+
*/
|
|
73
|
+
declare const KNOWLEDGE_SEARCH_RETRIEVER_ID = "bm25-rrf-v1";
|
|
74
|
+
interface SearchKnowledgeOptions {
|
|
75
|
+
/** Maximum results returned. Defaults to 10. */
|
|
76
|
+
limit?: number;
|
|
77
|
+
/** Match pages whose stable id is one of these values. */
|
|
78
|
+
pageIds?: readonly KnowledgeId[];
|
|
79
|
+
/** Match pages carrying at least one of these tags. */
|
|
80
|
+
tags?: readonly string[];
|
|
81
|
+
/** Match the exact string stored in `frontmatter.kind`. */
|
|
82
|
+
kinds?: readonly string[];
|
|
83
|
+
/**
|
|
84
|
+
* Drop pages whose own evidence refuted them. Defaults to false: a caller
|
|
85
|
+
* that reads history needs them, and a silent change of what search returns
|
|
86
|
+
* is worse than an explicit option.
|
|
87
|
+
*/
|
|
88
|
+
excludeInvalidated?: boolean;
|
|
89
|
+
/** Additional caller-owned filter, applied before either ranking stage. */
|
|
90
|
+
predicate?: (page: KnowledgePage) => boolean;
|
|
91
|
+
/**
|
|
92
|
+
* A lexical index built from exactly the searched pages, supplied by a caller
|
|
93
|
+
* that searches one page set repeatedly. When absent, one is built for this
|
|
94
|
+
* call.
|
|
95
|
+
*/
|
|
96
|
+
lexicalIndex?: KnowledgeLexicalIndex;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A retrieval result with an explicit citation handle.
|
|
100
|
+
*
|
|
101
|
+
* Unscoped search returns `page.id`; a run-scoped brief qualifies ambiguous ids
|
|
102
|
+
* through the citation resolver. Later writes should persist the returned value. Keeping it at the result's top level prevents tool
|
|
103
|
+
* renderers from accidentally hiding the only stable handle a model can copy.
|
|
104
|
+
*/
|
|
105
|
+
interface KnowledgeSearchHit extends KnowledgeSearchResult {
|
|
106
|
+
citationId: KnowledgeId;
|
|
107
|
+
}
|
|
108
|
+
declare function searchKnowledge(index: KnowledgeIndex, query: string, limit?: number): KnowledgeSearchHit[];
|
|
109
|
+
declare function searchKnowledge(index: KnowledgeIndex, query: string, options?: SearchKnowledgeOptions): KnowledgeSearchHit[];
|
|
110
|
+
/**
|
|
111
|
+
* Rank a page set that is not a built index, such as the chain a run can see.
|
|
112
|
+
*
|
|
113
|
+
* `searchKnowledge` is this function over `index.pages`, so both entry points
|
|
114
|
+
* rank identically and neither can drift from the other.
|
|
115
|
+
*/
|
|
116
|
+
declare function searchKnowledgePages(pages: readonly KnowledgePage[], query: string, limitOrOptions?: number | SearchKnowledgeOptions): KnowledgeSearchHit[];
|
|
117
|
+
declare function reciprocalRankFusion(rankLists: string[][], k?: number): Map<string, number>;
|
|
118
|
+
//#endregion
|
|
119
|
+
export { searchKnowledge as a, Bm25Options as c, KnowledgeLexicalIndexOptions as d, KnowledgeLexicalPosting as f, tokenizeText as g, tokenizeQuery as h, reciprocalRankFusion as i, KnowledgeLexicalFieldBoosts as l, scoreBm25 as m, KnowledgeSearchHit as n, searchKnowledgePages as o, buildKnowledgeLexicalIndex as p, SearchKnowledgeOptions as r, Bm25Hit as s, KNOWLEDGE_SEARCH_RETRIEVER_ID as t, KnowledgeLexicalIndex as u };
|
|
120
|
+
//# sourceMappingURL=search-Dde0PEtY.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-Dde0PEtY.d.ts","names":[],"sources":["../src/lexical-index.ts","../src/search.ts"],"mappings":";;;;;;;;;iBA8BgB,aAAa;;iBAkBb,cAAc;UAIb;;EAEf;;EAEA;;EAEA;;UAGe;;EAEf,YAAY;EACZ,cAAc;;UAGC;;EAEf;;EAEA;;;;;;;;UASe;WACN,gBAAgB;WAChB,UAAU,6BAA6B;WACvC;WACA;WACA;WACA,WAAW;WACX,aAAa,SAAS,SAAS;;UAGzB;;EAEf;;EAEA;;UAGe;EACf,MAAM;EACN;;iBAGc,2BACd,gBAAgB,iBAChB,UAAS,+BACR;;;;;;;iBAiBa,UACd,OAAO,uBACP,2BACA,UAAS,cACR;;;;;;;;;cCnHU;UAEI;;EAEf;;EAEA,mBAAmB;;EAEnB;;EAEA;;;;;;EAMA;;EAEA,aAAa,MAAM;;;;;;EAMnB,eAAe;;;;;;;;;UAUA,2BAA2B;EAC1C,YAAY;;iBAGE,gBACd,OAAO,gBACP,eACA,iBACC;iBACa,gBACd,OAAO,gBACP,eACA,UAAU,yBACT;;;;;;;iBAea,qBACd,gBAAgB,iBAChB,eACA,0BAAyB,yBACxB;iBA2Ca,qBAAqB,uBAAuB,aAAY"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Give an agent the tools
|
|
2
|
+
|
|
3
|
+
> Extracted from the README — see the [README](../README.md) for the package overview.
|
|
4
|
+
|
|
5
|
+
## Give an agent the tools
|
|
6
|
+
|
|
7
|
+
`createKnowledgeTools` returns provider-neutral `ToolDefinition[]` for search, read, record, and resolve. Knowledge owns what each tool does; a runtime only transports the definitions and the calls.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const tools = createKnowledgeTools({
|
|
11
|
+
stores,
|
|
12
|
+
runId,
|
|
13
|
+
retrieverVersion,
|
|
14
|
+
actorId,
|
|
15
|
+
intake: {},
|
|
16
|
+
recordRetrieval: (receipt) => ledger.append(receipt),
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`knowledge_search` builds a brief and mints a retrieval receipt on every call, so retrieval is recorded by the infrastructure rather than claimed by the run.
|
|
21
|
+
`knowledge_read` reports an id visible at two origins as `ambiguous` with both candidates, and never chooses one.
|
|
22
|
+
`knowledge_record` writes into this run's store through the intake gate.
|
|
23
|
+
Each proposal must contain complete `---FILE: <page-path>---` / `---END FILE---` blocks, with delimiters on separate lines.
|
|
24
|
+
The tool rejects malformed, unsafe, or empty proposals before writing any pages.
|
|
25
|
+
Read an existing page before editing it.
|
|
26
|
+
Pass expectedPageDigests with the page path and pageDigest returned by knowledge_read.
|
|
27
|
+
The write checks each digest under the store lock.
|
|
28
|
+
A stale edit refuses the whole proposal and names the current digest.
|
|
29
|
+
New pages need no digest; an explicit null requires the path to be absent.
|
|
30
|
+
Retrying an identical completed write is safe.
|
|
31
|
+
It does not report a partial write as tool success.
|
|
32
|
+
The lower-level `applyKnowledgeWriteBlocks` API retains its explicit `written` and `warnings` result for callers that inspect partial proposals.
|
|
33
|
+
`knowledge_resolve` returns the resolution status of each reference.
|
|
34
|
+
Every tool result carries `timing` in milliseconds: `viewMs` to obtain the current page view for search, read and resolve, `visibilityMs` to persist a search's snapshot, and `lockWaitMs` and `lockHoldMs` for a record.
|
|
35
|
+
Tools re-read only the page files whose identity changed since the previous call, and concurrent calls share one refresh.
|
|
36
|
+
|
|
37
|
+
Knowledge tools retain write history by default.
|
|
38
|
+
Each transaction records the host actorId, runId, and exact prior and new bytes.
|
|
39
|
+
An absent author remains explicitly null.
|
|
40
|
+
An application can disable history with retainHistory: false.
|
|
41
|
+
Lower-level writers opt into history with retainHistory: true and conditional edits with expectedPageDigests.
|
|
42
|
+
Completed write transactions then retain their existing manifest and before/after snapshots under `.agent-knowledge/history/<transactionId>`.
|
|
43
|
+
The move is atomic and a repeated finish after a lost acknowledgement is idempotent.
|
|
44
|
+
The default remains cleanup after completion, so callers choose retention deliberately and account for its unbounded storage growth.
|
|
45
|
+
Retained bytes inherit the store's access boundary; this option does not publish or merge them.
|
|
46
|
+
Use the existing transaction and candidate-snapshot readers to inspect or restore them.
|
|
47
|
+
|
|
48
|
+
Supply `retrieverVersion` yourself: a bundled build cannot read its own manifest, and a receipt that guessed the version would be a receipt that lies about what ranked the results.
|
|
49
|
+
|
|
50
|
+
When a retrieval influences nothing, record that too:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
createKnowledgeRetrievalDisposition({
|
|
54
|
+
retrieval: receipt,
|
|
55
|
+
relation: 'no-use',
|
|
56
|
+
consumer: { kind: 'decision', uri },
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A use receipt requires a selected rank, so without this record a retrieval that influenced nothing is indistinguishable from a retrieval nobody bothered to record. `irrelevant` means the results did not bear on the question; `no-use` means they did and the consumer still used none of them.
|
package/docs/architecture.md
CHANGED
|
@@ -37,7 +37,8 @@ When that string is the canonical `<root>/.agent-knowledge` directory, both form
|
|
|
37
37
|
| Path | Record |
|
|
38
38
|
| --- | --- |
|
|
39
39
|
| `.agent-knowledge/index.json` | the built knowledge index (`writeKnowledgeIndex` writes it through this store) |
|
|
40
|
-
| `.agent-knowledge/
|
|
40
|
+
| `.agent-knowledge/event-log/` | immutable event payload segments, bounded metadata buckets, and sequence state |
|
|
41
|
+
| `.agent-knowledge/events.json` | legacy event array; migrated transactionally on the first event write |
|
|
41
42
|
| `.agent-knowledge/claim-ledgers/<id>.json` | one research run's claim ledger: corroboration counts, contradiction edges, open deep questions |
|
|
42
43
|
| `.agent-knowledge/sources.json` | the immutable source registry |
|
|
43
44
|
| `.agent-knowledge/mutation.lock.durable`, `mutation-epoch.json`, `file-transactions/` | the cross-process mutation lock and its crash-recovery state |
|
|
@@ -50,6 +51,44 @@ Other processes still meet the file lock and the epoch.
|
|
|
50
51
|
Retrieval visibility snapshots under `.agent-knowledge/retrieval-visibility/` are content-addressed evidence, written without the lock and without moving the epoch.
|
|
51
52
|
There is exactly one writer per file: a second index writer alongside this one is a defect, not a variation.
|
|
52
53
|
|
|
54
|
+
### Event persistence
|
|
55
|
+
|
|
56
|
+
Event writes append a content-addressed payload under `event-log/records/` and replace only the affected metadata bucket under `event-log/index/` plus `state.json`. All changes use the existing recoverable file transaction under the store mutation lock. Transaction preparation, snapshot writes and full event reads admit at most eight filesystem operations per batch and drain admitted operations before reporting an error, so a large migration cannot exhaust ordinary descriptor limits or race cleanup. A repeated id replaces its visible value; older payload segments remain as retained history. Repeated writes replace the visible id without creating duplicate results. Backdated events and non-ISO `createdAt` strings retain the published `localeCompare` ordering; ties use insertion sequence.
|
|
57
|
+
|
|
58
|
+
Metadata is partitioned by event-id hash prefix. A leaf holds at most 256 entries; an overflowing leaf splits into narrower hash prefixes under the same transaction. Upserts read only the matching branch and leaf, so index writes are bounded independently of the total event count.
|
|
59
|
+
|
|
60
|
+
A limited query inventories and sorts the compact metadata buckets, then reads only the selected payloads. Filters apply before the limit. This is O(n) index metadata work and O(k) payload reads, not an O(1) tail lookup. Event writes do not read, sort, or rewrite the prior payload history. Retained payloads grow with distinct event versions; automatic compaction is intentionally absent because deleting history requires an explicit retention policy.
|
|
61
|
+
|
|
62
|
+
**Version 21 changes the on-disk event format. Older package versions cannot read migrated roots and may report an empty event history. Do not downgrade in place.**
|
|
63
|
+
|
|
64
|
+
Local comparison against `fdce05e` on Linux/Node 24 (three timed repetitions, median; fixtures preloaded, no ingestion timed):
|
|
65
|
+
|
|
66
|
+
| Events | Payload | Old append | New append | Old filtered tail(5) | New filtered tail(5) |
|
|
67
|
+
| --- | --- | --- | --- | --- | --- |
|
|
68
|
+
| 1,000 | research iteration | 16 ms | 33 ms | 8 ms | 14 ms |
|
|
69
|
+
| 10,000 | research iteration | 66 ms | 30 ms | 33 ms | 40 ms |
|
|
70
|
+
| 10,000 | 8 KiB metadata | 552 ms | 24 ms | 207 ms | 40 ms |
|
|
71
|
+
| 10,000 | 32 B metadata | 38 ms | 28 ms | 18 ms | 37 ms |
|
|
72
|
+
|
|
73
|
+
The representative fixture follows `src/research-loop.ts`: a goal, iteration, done flag, source count, three written paths, warning count and error count. This is the package's current event producer, and its events are typically compact summaries; 8 KiB is a stress control, not the expected workload. These observations show the tradeoff, not a latency guarantee: transaction overhead makes small-store writes slower, and scanning the index can make compact-payload reads slower. At 10,000 representative events the old array is about 4.88 MB and the metadata index 1.86 MB; only five full payloads are read. The prior per-id-file design was rejected after a 7× small-payload tail regression.
|
|
74
|
+
|
|
75
|
+
No claim is made of constant-time indexed tail access. Reproduce after building both revisions with `node scripts/benchmark-event-store.mjs /path/to/previous/dist/index.js`; the script emits JSONL measurements. The metadata scan uses anchored, no-follow descriptors in bounded 64-file slices, yielding between slices, and no process-global cache whose identity could go stale on promotion.
|
|
76
|
+
|
|
77
|
+
Read-only access to an unmigrated `events.json` remains supported. The first write migrates it and removes it atomically; interrupted migrations use the same recovery journal as other writes. Stop older writers before upgrading. If an older writer recreates `events.json` after migration, reads and writes refuse rather than silently combining divergent histories. A missing state record alongside log files is also refused. `KB_EVENTS_PATH` continues to identify the legacy import path, not a live array to edit.
|
|
78
|
+
|
|
79
|
+
For a quiesced rollback, stop all readers and writers, retain a backup of the migrated root, and export the current visible events into a separate, fresh legacy root. Use the current package to read, then the public durable writer to save the legacy array:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const events = await new FileSystemKbStore({ root: migratedRoot }).listEvents()
|
|
83
|
+
await writeJsonDurableWithinRoot(freshLegacyRoot, '.agent-knowledge/events.json', events)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Copy the other authoritative KB content (pages, raw sources, source registry and claim ledgers) to that fresh root without `event-log/`, locks, or transaction journals, while the source remains quiescent. Validate the exported array before directing older code at the fresh root. For a direct-directory store, use its string constructor and export to `events.json`. This exports the latest value per id, which is the old array's contract; historical superseded segments remain in the backup. Never write the export into a migrated root. The original root and its snapshots remain intact, and switching back to them is possible without rebuilding lost history.
|
|
87
|
+
|
|
88
|
+
The direct-directory string constructor stores `event-log/` beside its former `events.json`; the explicit root constructor stores `.agent-knowledge/event-log/`. The canonical `.agent-knowledge` string alias shares the explicit root's layout and lock. Distinct direct and root layouts remain isolated even when their roots are the same directory.
|
|
89
|
+
|
|
90
|
+
Research-state candidate snapshots include the entire canonical event log, including retained payloads, so hashing, promotion, rollback and recovery preserve the exact declared event history. Default candidates still exclude research state.
|
|
91
|
+
|
|
53
92
|
A claim ledger is the one record several writers legitimately share, such as a resumed run beside a live one or several workers researching one goal in parallel.
|
|
54
93
|
They reach it through `mergeClaimLedger(id, merge)`, which holds the mutation lock across the read, the merge, and the write, so no writer can build its record from a value another writer has already replaced.
|
|
55
94
|
`putClaimLedger` writes the whole record and is correct only for a single writer.
|
package/docs/cli.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Use the CLI
|
|
2
|
+
|
|
3
|
+
> Extracted from the README — see the [README](../README.md) for the package overview.
|
|
4
|
+
|
|
5
|
+
## Use the CLI
|
|
6
|
+
|
|
7
|
+
The CLI exposes the same file-backed workflow.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm exec agent-knowledge init --root ./support-kb
|
|
11
|
+
pnpm exec agent-knowledge source-add ./refund-policy.md --root ./support-kb
|
|
12
|
+
pnpm exec agent-knowledge apply-write-blocks ./proposal.txt --root ./support-kb
|
|
13
|
+
pnpm exec agent-knowledge index --root ./support-kb
|
|
14
|
+
pnpm exec agent-knowledge search "refund window" --root ./support-kb
|
|
15
|
+
pnpm exec agent-knowledge validate --strict --root ./support-kb
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Run `pnpm exec agent-knowledge help` for every command.
|
|
19
|
+
Pass `--pages-dir <dir>` to `apply-write-blocks`, `index`, `search`, and the other index-reading commands when the pages live outside `knowledge/`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Compatibility and peer versions
|
|
2
|
+
|
|
3
|
+
Knowledge 22 requires Node.js 22.12 or later and Interface 5.
|
|
4
|
+
|
|
5
|
+
Eval 0.213 is the supported minor. Development and packed-package checks use its exact
|
|
6
|
+
0.213.0 floor, with one physical copy each of Eval, Core 0.12, Interface 5 and Zod 4.6.5.
|
|
7
|
+
Official optimizer checks require the matching `agent-eval-rpc==0.213.0` Python bridge.
|
|
8
|
+
The new cohort retains Trace Contract 2.
|
|
9
|
+
|
|
10
|
+
Use Knowledge 21 with Interface 4 and Eval 0.212. Use Knowledge 20 with Interface 3
|
|
11
|
+
and Eval 0.209 through 0.211. Use Knowledge 19 with Interface 2 and Eval 0.201
|
|
12
|
+
through 0.208, and Knowledge 17 with older Eval releases.
|