markindex 0.0.0-stage → 0.1.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 +54 -0
- package/LICENSE +21 -0
- package/README.md +281 -2
- package/README.zh-CN.md +164 -0
- package/THIRD_PARTY_NOTICES.md +36 -0
- package/dist/adapters/ai-sdk.d.ts +8 -0
- package/dist/adapters/ai-sdk.js +20 -0
- package/dist/adapters/mcp.d.ts +6 -0
- package/dist/adapters/mcp.js +28 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +297 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +18 -0
- package/dist/eval/questions.d.ts +34 -0
- package/dist/eval/questions.js +80 -0
- package/dist/eval/run.d.ts +62 -0
- package/dist/eval/run.js +163 -0
- package/dist/eval/score.d.ts +74 -0
- package/dist/eval/score.js +104 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/library/indexer.d.ts +89 -0
- package/dist/library/indexer.js +199 -0
- package/dist/library/scan.d.ts +29 -0
- package/dist/library/scan.js +106 -0
- package/dist/library.d.ts +30 -0
- package/dist/library.js +42 -0
- package/dist/llm/provider.d.ts +10 -0
- package/dist/llm/provider.js +32 -0
- package/dist/llm/rate-limit.d.ts +33 -0
- package/dist/llm/rate-limit.js +95 -0
- package/dist/llm/summarize.d.ts +67 -0
- package/dist/llm/summarize.js +193 -0
- package/dist/parse/markdown.d.ts +38 -0
- package/dist/parse/markdown.js +58 -0
- package/dist/retrieve.d.ts +65 -0
- package/dist/retrieve.js +174 -0
- package/dist/store/db.d.ts +50 -0
- package/dist/store/db.js +399 -0
- package/dist/store/search.d.ts +27 -0
- package/dist/store/search.js +116 -0
- package/dist/text/segment.d.ts +4 -0
- package/dist/text/segment.js +37 -0
- package/dist/text/tokens.d.ts +3 -0
- package/dist/text/tokens.js +13 -0
- package/dist/tools/contract.d.ts +51 -0
- package/dist/tools/contract.js +159 -0
- package/dist/tools/envelope.d.ts +31 -0
- package/dist/tools/envelope.js +62 -0
- package/dist/tools/impl.d.ts +7 -0
- package/dist/tools/impl.js +441 -0
- package/dist/tools/similar.d.ts +8 -0
- package/dist/tools/similar.js +40 -0
- package/dist/tree/build.d.ts +27 -0
- package/dist/tree/build.js +139 -0
- package/dist/tree/optimize.d.ts +39 -0
- package/dist/tree/optimize.js +185 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +1 -0
- package/package.json +87 -4
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { RESPONSE_CHAR_BUDGET, SIMILAR_NAMES_LIMIT, STRUCTURE_FIRST_TOKEN_THRESHOLD } from '../config.js';
|
|
2
|
+
import { searchNodes } from '../store/search.js';
|
|
3
|
+
import { walk } from '../tree/build.js';
|
|
4
|
+
import { TOOL_CONTRACT } from './contract.js';
|
|
5
|
+
import { failure, jsonSize, splitByBudget, success } from './envelope.js';
|
|
6
|
+
import { similarNames } from './similar.js';
|
|
7
|
+
const TOOL_NAMES = Object.keys(TOOL_CONTRACT);
|
|
8
|
+
/** Placeholder for part numbers when measuring envelope overhead (no real document has a million parts). */
|
|
9
|
+
const MAX_PART = 999_999;
|
|
10
|
+
/** Minimum room for one content segment, even if a huge title path eats the budget (best effort). */
|
|
11
|
+
const MIN_SEGMENT = 256;
|
|
12
|
+
/** A label for an untrusted tool name that cannot throw (no String() on arbitrary objects). */
|
|
13
|
+
const nameLabel = (name) => (typeof name === 'string' ? name : typeof name);
|
|
14
|
+
/** `String(e)` / `e.message` can run user code (getters, toString); never let that escape. */
|
|
15
|
+
function describeError(e) {
|
|
16
|
+
try {
|
|
17
|
+
if (e instanceof Error && typeof e.message === 'string')
|
|
18
|
+
return e.message;
|
|
19
|
+
return typeof e === 'string' ? e : typeof e;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return 'unknown error';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const internalError = (name, message) => failure('INTERNAL_ERROR', `Tool failed: ${message}`, {
|
|
26
|
+
summary: 'The library could not be read.',
|
|
27
|
+
options: [
|
|
28
|
+
'Try the request again',
|
|
29
|
+
'If it keeps failing, the index may be closed, missing or corrupt: ask the user to re-run `markindex index`',
|
|
30
|
+
],
|
|
31
|
+
}, { tool: nameLabel(name) });
|
|
32
|
+
const TOO_LARGE = failure('INTERNAL_ERROR', 'Response too large for the response budget', {
|
|
33
|
+
summary: 'The result does not fit in one response.',
|
|
34
|
+
options: ['Narrow the request: use a smaller `limit`, a deeper `folder`, or fewer `node_ids`'],
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Runs one tool against `store` and returns its JSON envelope. Never throws: invalid input, unknown names,
|
|
38
|
+
* internal failures and unserializable values all come back as error envelopes. The result is at most
|
|
39
|
+
* RESPONSE_CHAR_BUDGET characters.
|
|
40
|
+
*/
|
|
41
|
+
export function runTool(store, name, args) {
|
|
42
|
+
let json;
|
|
43
|
+
try {
|
|
44
|
+
json = JSON.stringify(dispatch(store, name, args));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
// every value in an internal-error envelope is a plain string, so this stringify cannot throw
|
|
48
|
+
json = JSON.stringify(internalError(name, describeError(e).slice(0, 2_000)));
|
|
49
|
+
}
|
|
50
|
+
return json.length > RESPONSE_CHAR_BUDGET ? JSON.stringify(TOO_LARGE) : json;
|
|
51
|
+
}
|
|
52
|
+
function dispatch(store, name, args) {
|
|
53
|
+
if (typeof name !== 'string' || !TOOL_NAMES.includes(name)) {
|
|
54
|
+
return failure('INVALID_INPUT', `Unknown tool: ${nameLabel(name)}`, { summary: 'No tool with this name exists.', options: [`Use one of: ${TOOL_NAMES.join(', ')}`] }, { tool: nameLabel(name) });
|
|
55
|
+
}
|
|
56
|
+
const tool = name;
|
|
57
|
+
const parsed = TOOL_CONTRACT[tool].input.safeParse(args === undefined ? {} : args);
|
|
58
|
+
if (!parsed.success)
|
|
59
|
+
return invalidArgs(tool, parsed.error);
|
|
60
|
+
const input = parsed.data;
|
|
61
|
+
switch (tool) {
|
|
62
|
+
case 'browse_documents':
|
|
63
|
+
return browse(store, input);
|
|
64
|
+
case 'get_document':
|
|
65
|
+
return getDocument(store, input);
|
|
66
|
+
case 'get_document_structure':
|
|
67
|
+
return getStructure(store, input);
|
|
68
|
+
case 'get_node_content':
|
|
69
|
+
return getContent(store, input);
|
|
70
|
+
case 'search_documents':
|
|
71
|
+
return search(store, input);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function invalidArgs(tool, error) {
|
|
75
|
+
const issues = error.issues.map((i) => ({ path: i.path.map(String).join('.') || '(arguments)', message: i.message }));
|
|
76
|
+
return failure('INVALID_INPUT', `Invalid arguments for ${tool}: ${issues.map((i) => `${i.path}: ${i.message}`).join('; ')}`, {
|
|
77
|
+
summary: 'The arguments do not match the tool schema.',
|
|
78
|
+
options: [
|
|
79
|
+
`Call ${tool}() with a JSON object matching its input schema`,
|
|
80
|
+
'Copy doc_path, folder and node_id values verbatim from earlier tool results',
|
|
81
|
+
],
|
|
82
|
+
}, { issues });
|
|
83
|
+
}
|
|
84
|
+
// ── lookup helpers ──
|
|
85
|
+
const normFolder = (f) => f.trim().replace(/^\/+|\/+$/g, '');
|
|
86
|
+
function allFolders(paths) {
|
|
87
|
+
const out = new Set();
|
|
88
|
+
for (const p of paths) {
|
|
89
|
+
const segs = p.split('/');
|
|
90
|
+
for (let i = 1; i < segs.length; i++)
|
|
91
|
+
out.add(segs.slice(0, i).join('/'));
|
|
92
|
+
}
|
|
93
|
+
return [...out].sort();
|
|
94
|
+
}
|
|
95
|
+
function resolveDoc(store, path) {
|
|
96
|
+
const doc = store.getDoc(path);
|
|
97
|
+
if (doc)
|
|
98
|
+
return { doc };
|
|
99
|
+
const similar = similarNames(path, store.allDocPaths(), SIMILAR_NAMES_LIMIT);
|
|
100
|
+
return {
|
|
101
|
+
error: failure('NOT_FOUND', similar.length
|
|
102
|
+
? `Document not found. Did you mean: ${similar.map((s) => `"${s}"`).join(', ')}?`
|
|
103
|
+
: 'Document not found', {
|
|
104
|
+
summary: 'The requested document does not exist in the library index.',
|
|
105
|
+
options: [
|
|
106
|
+
...similar.map((s) => `Retry with doc_path: "${s}"`),
|
|
107
|
+
'Copy doc_path verbatim from a browse_documents() or search_documents() result (case-sensitive, include the extension)',
|
|
108
|
+
'Use browse_documents() to list the library',
|
|
109
|
+
'Use search_documents() to find the document by keyword',
|
|
110
|
+
],
|
|
111
|
+
}, { doc_path: path, similar_paths: similar }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function folderNotFound(store, folder) {
|
|
115
|
+
const similar = similarNames(folder, allFolders(store.allDocPaths()), SIMILAR_NAMES_LIMIT);
|
|
116
|
+
return failure('NOT_FOUND', similar.length
|
|
117
|
+
? `Folder not found. Did you mean: ${similar.map((s) => `"${s}"`).join(', ')}?`
|
|
118
|
+
: 'Folder not found', {
|
|
119
|
+
summary: 'No indexed documents live under this folder.',
|
|
120
|
+
options: [
|
|
121
|
+
...similar.map((s) => `Retry with folder: "${s}"`),
|
|
122
|
+
'Copy folder verbatim from the `folders` of a browse_documents() response',
|
|
123
|
+
'Call browse_documents() without `folder` to start at the library root',
|
|
124
|
+
],
|
|
125
|
+
}, { folder, similar_folders: similar });
|
|
126
|
+
}
|
|
127
|
+
const folderExists = (store, folder) => folder === '' || store.allDocPaths().some((p) => p.startsWith(`${folder}/`));
|
|
128
|
+
function findNode(tree, nodeId) {
|
|
129
|
+
for (const e of walk(tree))
|
|
130
|
+
if (e.node.nodeId === nodeId)
|
|
131
|
+
return e;
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
function staleNodes(docPath, unknown) {
|
|
135
|
+
return failure('NOT_FOUND', `Unknown node_id for this document: ${unknown.join(', ')}`, {
|
|
136
|
+
summary: 'These node ids do not exist in the current index of the document; it may have been re-indexed since you read its structure.',
|
|
137
|
+
options: [
|
|
138
|
+
`Call get_document_structure({ doc_path: "${docPath}" }) again and copy the current node ids verbatim`,
|
|
139
|
+
'Use search_documents() with doc_path to locate the section by keyword',
|
|
140
|
+
],
|
|
141
|
+
}, { doc_path: docPath, unknown_node_ids: unknown });
|
|
142
|
+
}
|
|
143
|
+
function partOutOfRange(tool, part, total) {
|
|
144
|
+
return failure('INVALID_INPUT', `part ${part} is out of range: this response has ${total} part${total === 1 ? '' : 's'}`, {
|
|
145
|
+
summary: 'The requested part does not exist.',
|
|
146
|
+
options: [`Request a part between 1 and ${total} (last part: part: ${total})`, `Call ${tool}() with part: 1 to start over`],
|
|
147
|
+
}, { part, total_parts: total });
|
|
148
|
+
}
|
|
149
|
+
const pagination = (part, total) => ({ part, total_parts: total, has_more: part < total });
|
|
150
|
+
/**
|
|
151
|
+
* Budget for the paginated payload: RESPONSE_CHAR_BUDGET minus the envelope around it, measured with an empty
|
|
152
|
+
* payload (`[]`) and placeholder part numbers in every next_steps variant.
|
|
153
|
+
*/
|
|
154
|
+
function payloadBudget(make) {
|
|
155
|
+
const overhead = Math.max(jsonSize(make([], MAX_PART - 1, MAX_PART)), jsonSize(make([], MAX_PART, MAX_PART))) - 2;
|
|
156
|
+
return RESPONSE_CHAR_BUDGET - overhead;
|
|
157
|
+
}
|
|
158
|
+
/** Drops trailing items until the envelope fits the budget (keeps at least one). */
|
|
159
|
+
function fitList(items, make) {
|
|
160
|
+
let n = items.length;
|
|
161
|
+
let env = make(items);
|
|
162
|
+
while (n > 1 && jsonSize(env) > RESPONSE_CHAR_BUDGET) {
|
|
163
|
+
n--;
|
|
164
|
+
env = make(items.slice(0, n));
|
|
165
|
+
}
|
|
166
|
+
return { env, kept: n };
|
|
167
|
+
}
|
|
168
|
+
// ── browse_documents ──
|
|
169
|
+
function browse(store, input) {
|
|
170
|
+
const folder = normFolder(input.folder);
|
|
171
|
+
if (!folderExists(store, folder))
|
|
172
|
+
return folderNotFound(store, folder);
|
|
173
|
+
const { offset, limit } = input;
|
|
174
|
+
const listing = store.listDocs(folder, offset, limit);
|
|
175
|
+
if (offset > 0 && offset >= listing.total) {
|
|
176
|
+
return failure('INVALID_INPUT', `offset ${offset} is past the end of this folder (${listing.total} documents)`, {
|
|
177
|
+
summary: 'There are no documents at this offset.',
|
|
178
|
+
options: ['Call browse_documents() with offset: 0', 'Use `next_offset` from the previous response, not a guessed value'],
|
|
179
|
+
}, { folder, offset, total: listing.total });
|
|
180
|
+
}
|
|
181
|
+
const docs = listing.docs.map((d) => ({
|
|
182
|
+
path: d.path,
|
|
183
|
+
title: d.title,
|
|
184
|
+
...(d.description === undefined ? {} : { description: d.description }),
|
|
185
|
+
tokens: d.tokens,
|
|
186
|
+
}));
|
|
187
|
+
const make = (page) => {
|
|
188
|
+
const next = offset + page.length < listing.total ? offset + page.length : null;
|
|
189
|
+
const options = [
|
|
190
|
+
'Use get_document() / get_document_structure() with a doc_path copied from `docs`',
|
|
191
|
+
'Pass a `folders` entry as `folder` to drill into a sub-folder',
|
|
192
|
+
];
|
|
193
|
+
if (next !== null)
|
|
194
|
+
options.unshift(`More documents: call browse_documents() with offset: ${next}`);
|
|
195
|
+
return success({ folder, folders: listing.folders, docs: page, total: listing.total, next_offset: next, has_more: next !== null }, {
|
|
196
|
+
summary: `Showing documents ${listing.total ? offset + 1 : 0}-${offset + page.length} of ${listing.total} in ${folder === '' ? 'the library root' : `"${folder}"`}, plus ${listing.folders.length} sub-folder(s).`,
|
|
197
|
+
options,
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
return fitList(docs, make).env;
|
|
201
|
+
}
|
|
202
|
+
// ── get_document ──
|
|
203
|
+
function getDocument(store, input) {
|
|
204
|
+
const r = resolveDoc(store, input.doc_path);
|
|
205
|
+
if ('error' in r)
|
|
206
|
+
return r.error;
|
|
207
|
+
const d = r.doc;
|
|
208
|
+
const readStructureFirst = d.tokens > STRUCTURE_FIRST_TOKEN_THRESHOLD;
|
|
209
|
+
return success({
|
|
210
|
+
doc_path: d.path,
|
|
211
|
+
title: d.title,
|
|
212
|
+
...(d.description === undefined ? {} : { description: d.description }),
|
|
213
|
+
line_count: d.lineCount,
|
|
214
|
+
tokens: d.tokens,
|
|
215
|
+
metadata: d.metadata,
|
|
216
|
+
indexed_at: d.indexedAt,
|
|
217
|
+
read_structure_first: readStructureFirst,
|
|
218
|
+
}, readStructureFirst
|
|
219
|
+
? {
|
|
220
|
+
summary: `Large document (${d.tokens} tokens > ${STRUCTURE_FIRST_TOKEN_THRESHOLD}): read its structure first.`,
|
|
221
|
+
options: [
|
|
222
|
+
`Call get_document_structure({ doc_path: "${d.path}" }) to locate the relevant sections`,
|
|
223
|
+
'Then read only those nodes with get_node_content()',
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
: {
|
|
227
|
+
summary: `Small document (${d.tokens} tokens): it can be read directly, no outline needed.`,
|
|
228
|
+
options: [
|
|
229
|
+
`Read its top-level nodes with get_node_content() (ids from get_document_structure({ doc_path: "${d.path}", depth: 1 }))`,
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function formatStructure(node, depth) {
|
|
234
|
+
const out = {
|
|
235
|
+
node_id: node.nodeId,
|
|
236
|
+
title: node.title,
|
|
237
|
+
kind: node.kind,
|
|
238
|
+
start_line: node.startLine,
|
|
239
|
+
end_line: node.endLine,
|
|
240
|
+
tokens: node.tokens,
|
|
241
|
+
};
|
|
242
|
+
const summary = node.summary ?? node.prefixSummary;
|
|
243
|
+
if (summary !== undefined)
|
|
244
|
+
out.summary = summary;
|
|
245
|
+
if (node.children.length) {
|
|
246
|
+
if (depth > 1)
|
|
247
|
+
out.nodes = node.children.map((c) => formatStructure(c, depth - 1));
|
|
248
|
+
else
|
|
249
|
+
out.child_count = node.children.length;
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
function getStructure(store, input) {
|
|
254
|
+
const r = resolveDoc(store, input.doc_path);
|
|
255
|
+
if ('error' in r)
|
|
256
|
+
return r.error;
|
|
257
|
+
const docPath = r.doc.path;
|
|
258
|
+
const tree = store.getTree(docPath) ?? [];
|
|
259
|
+
let roots = tree;
|
|
260
|
+
let titlePath;
|
|
261
|
+
if (input.node_id !== undefined) {
|
|
262
|
+
const found = findNode(tree, input.node_id);
|
|
263
|
+
if (!found)
|
|
264
|
+
return staleNodes(docPath, [input.node_id]);
|
|
265
|
+
roots = [found.node];
|
|
266
|
+
titlePath = found.titlePath;
|
|
267
|
+
}
|
|
268
|
+
const structure = roots.map((n) => formatStructure(n, input.depth));
|
|
269
|
+
const make = (chunk, part, total) => success({
|
|
270
|
+
doc_path: docPath,
|
|
271
|
+
...(titlePath === undefined ? {} : { node_id: input.node_id, title_path: titlePath }),
|
|
272
|
+
structure: chunk,
|
|
273
|
+
pagination: pagination(part, total),
|
|
274
|
+
}, part < total
|
|
275
|
+
? {
|
|
276
|
+
summary: `Showing part ${part} of ${total} of the outline.`,
|
|
277
|
+
options: [
|
|
278
|
+
`Request the next part with part: ${part + 1}`,
|
|
279
|
+
'Read relevant nodes with get_node_content() using node_id values copied verbatim',
|
|
280
|
+
],
|
|
281
|
+
}
|
|
282
|
+
: {
|
|
283
|
+
summary: total > 1 ? `Showing the last part (${part} of ${total}) of the outline.` : 'Document outline retrieved.',
|
|
284
|
+
options: [
|
|
285
|
+
'Read relevant nodes with get_node_content() using node_id values copied verbatim',
|
|
286
|
+
'Pass the node_id of a node with child_count as `node_id` to expand it',
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const chunks = splitByBudget(structure, payloadBudget(make));
|
|
290
|
+
if (input.part > chunks.length)
|
|
291
|
+
return partOutOfRange('get_document_structure', input.part, chunks.length);
|
|
292
|
+
return make(chunks[input.part - 1], input.part, chunks.length);
|
|
293
|
+
}
|
|
294
|
+
/** JSON-escaped length of a string's content (without the quotes). */
|
|
295
|
+
const escLen = (s) => JSON.stringify(s).length - 2;
|
|
296
|
+
/** `n│ line`, split into several segments with the same prefix when one would exceed `max` escaped chars. */
|
|
297
|
+
function segments(n, line, max) {
|
|
298
|
+
const prefix = `${n}│ `;
|
|
299
|
+
const whole = prefix + line;
|
|
300
|
+
if (escLen(whole) <= max)
|
|
301
|
+
return [whole];
|
|
302
|
+
const out = [];
|
|
303
|
+
let cur = '';
|
|
304
|
+
let curLen = escLen(prefix);
|
|
305
|
+
for (const ch of line) {
|
|
306
|
+
const l = escLen(ch);
|
|
307
|
+
if (cur !== '' && curLen + l > max) {
|
|
308
|
+
out.push(prefix + cur);
|
|
309
|
+
cur = '';
|
|
310
|
+
curLen = escLen(prefix);
|
|
311
|
+
}
|
|
312
|
+
cur += ch;
|
|
313
|
+
curLen += l;
|
|
314
|
+
}
|
|
315
|
+
out.push(prefix + cur);
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
/** Packs the numbered lines of `targets` into parts whose JSON array fits `budget`, splitting nodes as needed. */
|
|
319
|
+
function packContent(store, docPath, targets, budget) {
|
|
320
|
+
const parts = [];
|
|
321
|
+
let part = [];
|
|
322
|
+
let used = 2; // "[]" plus closed entries and their separating commas
|
|
323
|
+
const flushPart = () => {
|
|
324
|
+
if (part.length)
|
|
325
|
+
parts.push(part);
|
|
326
|
+
part = [];
|
|
327
|
+
used = 2;
|
|
328
|
+
};
|
|
329
|
+
for (const { node, titlePath } of targets) {
|
|
330
|
+
const meta = { node_id: node.nodeId, title_path: titlePath, start_line: node.startLine, end_line: node.endLine };
|
|
331
|
+
const entryBase = jsonSize({ ...meta, complete: false, text: '' });
|
|
332
|
+
const maxSegment = Math.max(MIN_SEGMENT, budget - 2 - entryBase);
|
|
333
|
+
let lines = [];
|
|
334
|
+
let textLen = 0;
|
|
335
|
+
let split = false;
|
|
336
|
+
const closeEntry = (complete) => {
|
|
337
|
+
const entry = { ...meta, complete, text: lines.join('\n') };
|
|
338
|
+
used += jsonSize(entry) + (part.length ? 1 : 0);
|
|
339
|
+
part.push(entry);
|
|
340
|
+
lines = [];
|
|
341
|
+
textLen = 0;
|
|
342
|
+
};
|
|
343
|
+
const projected = (add) => used + (part.length ? 1 : 0) + entryBase + textLen + add;
|
|
344
|
+
const raw = store.getLines(docPath, node.startLine, node.endLine);
|
|
345
|
+
raw.forEach((line, i) => {
|
|
346
|
+
for (const seg of segments(node.startLine + i, line, maxSegment)) {
|
|
347
|
+
const add = escLen(seg) + (lines.length ? 2 : 0); // "\n" separator is 2 escaped chars
|
|
348
|
+
if (projected(add) > budget && (lines.length || part.length)) {
|
|
349
|
+
if (lines.length) {
|
|
350
|
+
split = true;
|
|
351
|
+
closeEntry(false);
|
|
352
|
+
}
|
|
353
|
+
flushPart();
|
|
354
|
+
lines = [];
|
|
355
|
+
}
|
|
356
|
+
textLen += escLen(seg) + (lines.length ? 2 : 0);
|
|
357
|
+
lines.push(seg);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
closeEntry(!split);
|
|
361
|
+
}
|
|
362
|
+
flushPart();
|
|
363
|
+
return parts.length ? parts : [[]];
|
|
364
|
+
}
|
|
365
|
+
function getContent(store, input) {
|
|
366
|
+
const r = resolveDoc(store, input.doc_path);
|
|
367
|
+
if ('error' in r)
|
|
368
|
+
return r.error;
|
|
369
|
+
const docPath = r.doc.path;
|
|
370
|
+
const tree = store.getTree(docPath) ?? [];
|
|
371
|
+
const byId = new Map();
|
|
372
|
+
for (const e of walk(tree))
|
|
373
|
+
byId.set(e.node.nodeId, e);
|
|
374
|
+
const ids = [...new Set(input.node_ids)];
|
|
375
|
+
const unknown = ids.filter((id) => !byId.has(id));
|
|
376
|
+
if (unknown.length)
|
|
377
|
+
return staleNodes(docPath, unknown);
|
|
378
|
+
const make = (content, part, total) => success({ doc_path: docPath, content, pagination: pagination(part, total) }, part < total
|
|
379
|
+
? {
|
|
380
|
+
summary: `Showing part ${part} of ${total} of the requested content.`,
|
|
381
|
+
options: [
|
|
382
|
+
`Request the next part with part: ${part + 1}`,
|
|
383
|
+
'Or read a more specific (child) node: get_document_structure() with node_id lists them',
|
|
384
|
+
],
|
|
385
|
+
}
|
|
386
|
+
: {
|
|
387
|
+
summary: total > 1 ? `Showing the last part (${part} of ${total}) of the requested content.` : 'Content retrieved.',
|
|
388
|
+
options: [
|
|
389
|
+
`Cite as [${docPath}, L<start>-<end>] using the line numbers shown`,
|
|
390
|
+
'Use get_document_structure() to find related sections',
|
|
391
|
+
],
|
|
392
|
+
});
|
|
393
|
+
const parts = packContent(store, docPath, ids.map((id) => byId.get(id)), payloadBudget(make));
|
|
394
|
+
if (input.part > parts.length)
|
|
395
|
+
return partOutOfRange('get_node_content', input.part, parts.length);
|
|
396
|
+
return make(parts[input.part - 1], input.part, parts.length);
|
|
397
|
+
}
|
|
398
|
+
// ── search_documents ──
|
|
399
|
+
function search(store, input) {
|
|
400
|
+
let docPath;
|
|
401
|
+
let folder;
|
|
402
|
+
if (input.doc_path !== undefined) {
|
|
403
|
+
const r = resolveDoc(store, input.doc_path);
|
|
404
|
+
if ('error' in r)
|
|
405
|
+
return r.error;
|
|
406
|
+
docPath = r.doc.path;
|
|
407
|
+
}
|
|
408
|
+
else if (input.folder !== undefined) {
|
|
409
|
+
folder = normFolder(input.folder);
|
|
410
|
+
if (!folderExists(store, folder))
|
|
411
|
+
return folderNotFound(store, folder);
|
|
412
|
+
}
|
|
413
|
+
const hits = searchNodes(store, input.query, { docPath, folder, limit: input.limit }).map((h) => ({
|
|
414
|
+
doc_path: h.docPath,
|
|
415
|
+
node_id: h.nodeId,
|
|
416
|
+
title_path: h.titlePath,
|
|
417
|
+
start_line: h.startLine,
|
|
418
|
+
end_line: h.endLine,
|
|
419
|
+
snippet: h.snippet,
|
|
420
|
+
score: h.score,
|
|
421
|
+
via: h.via,
|
|
422
|
+
}));
|
|
423
|
+
const scope = docPath !== undefined ? { doc_path: docPath } : folder ? { folder } : {};
|
|
424
|
+
const make = (results) => success({ query: input.query, ...scope, results }, results.length
|
|
425
|
+
? {
|
|
426
|
+
summary: `Found ${results.length} matching node(s).`,
|
|
427
|
+
options: [
|
|
428
|
+
'Read the most relevant hits with get_node_content() using doc_path and node_id copied verbatim',
|
|
429
|
+
'Use get_document_structure() with node_id to see the surrounding sections',
|
|
430
|
+
],
|
|
431
|
+
}
|
|
432
|
+
: {
|
|
433
|
+
summary: 'No matching nodes.',
|
|
434
|
+
options: [
|
|
435
|
+
'Rephrase with synonyms, alternative terms or the other language (Chinese/English)',
|
|
436
|
+
'Widen the scope: drop `doc_path` / `folder`',
|
|
437
|
+
'Browse with browse_documents() and read document structures instead',
|
|
438
|
+
],
|
|
439
|
+
});
|
|
440
|
+
return fitList(hits, make).env;
|
|
441
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Optimal string alignment distance (Levenshtein + adjacent transposition), by code point. */
|
|
2
|
+
export declare function editDistance(a: string, b: string): number;
|
|
3
|
+
/**
|
|
4
|
+
* Up to `limit` candidates most similar to `query` (difflib `get_close_matches`-like, cutoff 0.5). A candidate is
|
|
5
|
+
* compared both as a whole and by its last path segment against the query's last segment, so `annual.md` finds
|
|
6
|
+
* `reports/annual.md`. Ties keep the candidates' order.
|
|
7
|
+
*/
|
|
8
|
+
export declare function similarNames(query: string, candidates: readonly string[], limit: number): string[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Optimal string alignment distance (Levenshtein + adjacent transposition), by code point. */
|
|
2
|
+
export function editDistance(a, b) {
|
|
3
|
+
const s = [...a];
|
|
4
|
+
const t = [...b];
|
|
5
|
+
const rows = Array.from({ length: s.length + 1 }, (_, i) => Array.from({ length: t.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
6
|
+
for (let i = 1; i <= s.length; i++) {
|
|
7
|
+
for (let j = 1; j <= t.length; j++) {
|
|
8
|
+
const cost = s[i - 1] === t[j - 1] ? 0 : 1;
|
|
9
|
+
let d = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
|
|
10
|
+
if (i > 1 && j > 1 && s[i - 1] === t[j - 2] && s[i - 2] === t[j - 1])
|
|
11
|
+
d = Math.min(d, rows[i - 2][j - 2] + 1);
|
|
12
|
+
rows[i][j] = d;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return rows[s.length][t.length];
|
|
16
|
+
}
|
|
17
|
+
const basename = (p) => p.slice(p.lastIndexOf('/') + 1);
|
|
18
|
+
/** 1 = identical, 0 = nothing in common; like difflib's ratio, based on edit distance. */
|
|
19
|
+
function similarity(a, b) {
|
|
20
|
+
const len = Math.max([...a].length, [...b].length);
|
|
21
|
+
return len === 0 ? 1 : 1 - editDistance(a, b) / len;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Up to `limit` candidates most similar to `query` (difflib `get_close_matches`-like, cutoff 0.5). A candidate is
|
|
25
|
+
* compared both as a whole and by its last path segment against the query's last segment, so `annual.md` finds
|
|
26
|
+
* `reports/annual.md`. Ties keep the candidates' order.
|
|
27
|
+
*/
|
|
28
|
+
export function similarNames(query, candidates, limit) {
|
|
29
|
+
const q = query.trim().toLowerCase();
|
|
30
|
+
const qBase = basename(q);
|
|
31
|
+
return candidates
|
|
32
|
+
.map((c, i) => {
|
|
33
|
+
const lc = c.toLowerCase();
|
|
34
|
+
return { c, i, score: Math.max(similarity(q, lc), similarity(qBase, basename(lc))) };
|
|
35
|
+
})
|
|
36
|
+
.filter((x) => x.score >= 0.5)
|
|
37
|
+
.sort((x, y) => y.score - x.score || x.i - y.i)
|
|
38
|
+
.slice(0, limit)
|
|
39
|
+
.map((x) => x.c);
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ParsedDoc } from '../parse/markdown.js';
|
|
2
|
+
import { type TokenCounter } from '../text/tokens.js';
|
|
3
|
+
import type { TreeNode } from '../types.js';
|
|
4
|
+
export interface BuildTreeOptions {
|
|
5
|
+
/** Title for a doc without headings (root node). */
|
|
6
|
+
fileTitle: string;
|
|
7
|
+
countTokens?: TokenCounter;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the section tree (stack algorithm, like PageIndex `build_tree_from_nodes`).
|
|
11
|
+
*
|
|
12
|
+
* Conventions:
|
|
13
|
+
* - `level` is 0 for the preface and for the title-less root of a headless doc.
|
|
14
|
+
* - Blank-only lines before the first heading are absorbed into the first heading's node
|
|
15
|
+
* (no spurious preface); non-blank text produces a `preface` node.
|
|
16
|
+
* - A doc without headings yields one root `section` titled `fileTitle`, covering all non-frontmatter lines.
|
|
17
|
+
* - A doc with no lines outside frontmatter yields `[]`.
|
|
18
|
+
* - Every line outside frontmatter belongs to exactly one node's own range [startLine, ownEndLine].
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildTree(doc: ParsedDoc, opts: BuildTreeOptions): TreeNode[];
|
|
21
|
+
/** Depth-first pre-order traversal with the title path of each node. */
|
|
22
|
+
export declare function walk(roots: TreeNode[]): Generator<{
|
|
23
|
+
node: TreeNode;
|
|
24
|
+
titlePath: string[];
|
|
25
|
+
}>;
|
|
26
|
+
/** Throws when spec §5.2 invariants are violated. Test / debug helper. */
|
|
27
|
+
export declare function assertCoverage(doc: ParsedDoc, roots: TreeNode[]): void;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { approxTokens } from '../text/tokens.js';
|
|
3
|
+
const SEP = '\u001f';
|
|
4
|
+
const PREFACE_TITLE = 'Preface';
|
|
5
|
+
/**
|
|
6
|
+
* Build the section tree (stack algorithm, like PageIndex `build_tree_from_nodes`).
|
|
7
|
+
*
|
|
8
|
+
* Conventions:
|
|
9
|
+
* - `level` is 0 for the preface and for the title-less root of a headless doc.
|
|
10
|
+
* - Blank-only lines before the first heading are absorbed into the first heading's node
|
|
11
|
+
* (no spurious preface); non-blank text produces a `preface` node.
|
|
12
|
+
* - A doc without headings yields one root `section` titled `fileTitle`, covering all non-frontmatter lines.
|
|
13
|
+
* - A doc with no lines outside frontmatter yields `[]`.
|
|
14
|
+
* - Every line outside frontmatter belongs to exactly one node's own range [startLine, ownEndLine].
|
|
15
|
+
*/
|
|
16
|
+
export function buildTree(doc, opts) {
|
|
17
|
+
const count = opts.countTokens ?? approxTokens;
|
|
18
|
+
const total = doc.lines.length;
|
|
19
|
+
const first = doc.firstContentLine;
|
|
20
|
+
if (first > total)
|
|
21
|
+
return [];
|
|
22
|
+
const text = (from, to) => doc.lines.slice(from - 1, to).join('\n');
|
|
23
|
+
const make = (title, kind, level, startLine) => ({
|
|
24
|
+
nodeId: '',
|
|
25
|
+
title,
|
|
26
|
+
kind,
|
|
27
|
+
level,
|
|
28
|
+
startLine,
|
|
29
|
+
endLine: total,
|
|
30
|
+
ownEndLine: total,
|
|
31
|
+
tokens: 0,
|
|
32
|
+
ownTokens: 0,
|
|
33
|
+
children: [],
|
|
34
|
+
});
|
|
35
|
+
const roots = [];
|
|
36
|
+
const headings = doc.headings;
|
|
37
|
+
if (headings.length === 0) {
|
|
38
|
+
roots.push(make(opts.fileTitle, 'section', 0, first));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const firstHeading = headings[0].line;
|
|
42
|
+
let hasPreface = false;
|
|
43
|
+
for (let i = first; i < firstHeading; i++) {
|
|
44
|
+
if (doc.lines[i - 1].trim() !== '') {
|
|
45
|
+
hasPreface = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (hasPreface) {
|
|
50
|
+
const p = make(PREFACE_TITLE, 'preface', 0, first);
|
|
51
|
+
p.endLine = p.ownEndLine = firstHeading - 1;
|
|
52
|
+
roots.push(p);
|
|
53
|
+
}
|
|
54
|
+
const stack = [];
|
|
55
|
+
headings.forEach((h, idx) => {
|
|
56
|
+
const node = make(h.title, 'section', h.level, !hasPreface && idx === 0 ? first : h.line);
|
|
57
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= h.level) {
|
|
58
|
+
stack.pop().endLine = h.line - 1;
|
|
59
|
+
}
|
|
60
|
+
const parent = stack[stack.length - 1];
|
|
61
|
+
if (parent)
|
|
62
|
+
parent.children.push(node);
|
|
63
|
+
else
|
|
64
|
+
roots.push(node);
|
|
65
|
+
stack.push(node);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
finalize(roots, [], [], new Set(), text, count);
|
|
69
|
+
return roots;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Fills ownEndLine, tokens and nodeId, in document (pre-)order.
|
|
73
|
+
*
|
|
74
|
+
* Primary id = hash(title path + the node's ordinal among same-title siblings). Only when that id is already taken
|
|
75
|
+
* in the document (two same-title parents with same-title children) does the node fall back to a disambiguated key:
|
|
76
|
+
* the chain of (title, same-title ordinal) pairs of every ancestor, JSON-encoded (unambiguous for any title), plus
|
|
77
|
+
* a counter in the (practically impossible) case that this collides too. Unique ids therefore never change.
|
|
78
|
+
*/
|
|
79
|
+
function finalize(siblings, parentPath, parentChain, used, text, count) {
|
|
80
|
+
const seen = new Map();
|
|
81
|
+
for (const node of siblings) {
|
|
82
|
+
const ordinal = seen.get(node.title) ?? 0;
|
|
83
|
+
seen.set(node.title, ordinal + 1);
|
|
84
|
+
const path = [...parentPath, node.title];
|
|
85
|
+
const chain = [...parentChain, [node.title, ordinal]];
|
|
86
|
+
let id = makeId(path, ordinal);
|
|
87
|
+
for (let n = 0; used.has(id); n++)
|
|
88
|
+
id = hashHex(JSON.stringify([chain, n]));
|
|
89
|
+
used.add(id);
|
|
90
|
+
node.nodeId = id;
|
|
91
|
+
const firstChild = node.children[0];
|
|
92
|
+
node.ownEndLine = firstChild ? firstChild.startLine - 1 : node.endLine;
|
|
93
|
+
node.ownTokens = count(text(node.startLine, node.ownEndLine));
|
|
94
|
+
node.tokens = firstChild ? count(text(node.startLine, node.endLine)) : node.ownTokens;
|
|
95
|
+
finalize(node.children, path, chain, used, text, count);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const hashHex = (s) => createHash('sha1').update(s).digest('hex').slice(0, 10);
|
|
99
|
+
function makeId(titlePath, ordinal) {
|
|
100
|
+
return hashHex(titlePath.join(SEP) + SEP + ordinal);
|
|
101
|
+
}
|
|
102
|
+
/** Depth-first pre-order traversal with the title path of each node. */
|
|
103
|
+
export function* walk(roots) {
|
|
104
|
+
function* rec(nodes, parent) {
|
|
105
|
+
for (const node of nodes) {
|
|
106
|
+
const titlePath = [...parent, node.title];
|
|
107
|
+
yield { node, titlePath };
|
|
108
|
+
yield* rec(node.children, titlePath);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
yield* rec(roots, []);
|
|
112
|
+
}
|
|
113
|
+
/** Throws when spec §5.2 invariants are violated. Test / debug helper. */
|
|
114
|
+
export function assertCoverage(doc, roots) {
|
|
115
|
+
const total = doc.lines.length;
|
|
116
|
+
const first = doc.firstContentLine;
|
|
117
|
+
let expected = first;
|
|
118
|
+
for (const { node, titlePath } of walk(roots)) {
|
|
119
|
+
const label = titlePath.join(' > ');
|
|
120
|
+
if (node.startLine !== expected) {
|
|
121
|
+
throw new Error(`coverage: "${label}" starts at ${node.startLine}, expected ${expected}`);
|
|
122
|
+
}
|
|
123
|
+
if (node.ownEndLine < node.startLine || node.ownEndLine > node.endLine) {
|
|
124
|
+
throw new Error(`coverage: "${label}" has invalid own range ${node.startLine}-${node.ownEndLine} (end ${node.endLine})`);
|
|
125
|
+
}
|
|
126
|
+
const firstChild = node.children[0];
|
|
127
|
+
if (firstChild && firstChild.startLine !== node.ownEndLine + 1) {
|
|
128
|
+
throw new Error(`coverage: "${label}" ownEndLine ${node.ownEndLine} does not precede first child at ${firstChild.startLine}`);
|
|
129
|
+
}
|
|
130
|
+
const lastChild = node.children[node.children.length - 1];
|
|
131
|
+
if (lastChild && lastChild.endLine !== node.endLine) {
|
|
132
|
+
throw new Error(`coverage: "${label}" endLine ${node.endLine} != last child endLine ${lastChild.endLine}`);
|
|
133
|
+
}
|
|
134
|
+
expected = node.ownEndLine + 1;
|
|
135
|
+
}
|
|
136
|
+
if (expected !== Math.max(first, total + 1)) {
|
|
137
|
+
throw new Error(`coverage: lines ${expected}..${total} are not covered`);
|
|
138
|
+
}
|
|
139
|
+
}
|