markindex 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/README.md +288 -2
  4. package/README.zh-CN.md +164 -0
  5. package/THIRD_PARTY_NOTICES.md +36 -0
  6. package/dist/adapters/ai-sdk.d.ts +8 -0
  7. package/dist/adapters/ai-sdk.js +20 -0
  8. package/dist/adapters/mcp.d.ts +6 -0
  9. package/dist/adapters/mcp.js +28 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.js +297 -0
  12. package/dist/config.d.ts +18 -0
  13. package/dist/config.js +18 -0
  14. package/dist/eval/questions.d.ts +34 -0
  15. package/dist/eval/questions.js +80 -0
  16. package/dist/eval/run.d.ts +62 -0
  17. package/dist/eval/run.js +163 -0
  18. package/dist/eval/score.d.ts +74 -0
  19. package/dist/eval/score.js +104 -0
  20. package/dist/index.d.ts +23 -0
  21. package/dist/index.js +23 -0
  22. package/dist/library/indexer.d.ts +89 -0
  23. package/dist/library/indexer.js +199 -0
  24. package/dist/library/scan.d.ts +29 -0
  25. package/dist/library/scan.js +106 -0
  26. package/dist/library.d.ts +30 -0
  27. package/dist/library.js +42 -0
  28. package/dist/llm/provider.d.ts +10 -0
  29. package/dist/llm/provider.js +32 -0
  30. package/dist/llm/rate-limit.d.ts +33 -0
  31. package/dist/llm/rate-limit.js +95 -0
  32. package/dist/llm/summarize.d.ts +67 -0
  33. package/dist/llm/summarize.js +193 -0
  34. package/dist/parse/markdown.d.ts +38 -0
  35. package/dist/parse/markdown.js +58 -0
  36. package/dist/retrieve.d.ts +65 -0
  37. package/dist/retrieve.js +174 -0
  38. package/dist/store/db.d.ts +50 -0
  39. package/dist/store/db.js +399 -0
  40. package/dist/store/search.d.ts +27 -0
  41. package/dist/store/search.js +116 -0
  42. package/dist/text/segment.d.ts +4 -0
  43. package/dist/text/segment.js +37 -0
  44. package/dist/text/tokens.d.ts +3 -0
  45. package/dist/text/tokens.js +13 -0
  46. package/dist/tools/contract.d.ts +51 -0
  47. package/dist/tools/contract.js +159 -0
  48. package/dist/tools/envelope.d.ts +31 -0
  49. package/dist/tools/envelope.js +62 -0
  50. package/dist/tools/impl.d.ts +7 -0
  51. package/dist/tools/impl.js +441 -0
  52. package/dist/tools/similar.d.ts +8 -0
  53. package/dist/tools/similar.js +40 -0
  54. package/dist/tree/build.d.ts +27 -0
  55. package/dist/tree/build.js +139 -0
  56. package/dist/tree/optimize.d.ts +39 -0
  57. package/dist/tree/optimize.js +185 -0
  58. package/dist/types.d.ts +27 -0
  59. package/dist/types.js +1 -0
  60. package/package.json +88 -4
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ export declare function buildProgram(): Command;
package/dist/cli.js ADDED
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { Command } from 'commander';
6
+ import { createMcpServer, packageVersion } from './adapters/mcp.js';
7
+ import { parseQuestions, QuestionFileError } from './eval/questions.js';
8
+ import { checkGold, runEval } from './eval/run.js';
9
+ import { indexLibrary } from './library/indexer.js';
10
+ import { assertLibraryRoot } from './library/scan.js';
11
+ import { defaultDbFile, openLibrary } from './library.js';
12
+ import { estimateSummaryCost } from './llm/summarize.js';
13
+ import { ProviderError, resolveSummaryModel } from './llm/provider.js';
14
+ import { parseRateLimit } from './llm/rate-limit.js';
15
+ import { RETRIEVE_MAX_INPUT_TOKENS, RETRIEVE_MAX_STEPS } from './retrieve.js';
16
+ import { searchNodes } from './store/search.js';
17
+ import { openStore, StoreLockedError } from './store/db.js';
18
+ const out = (s) => process.stdout.write(`${s}\n`);
19
+ const err = (s) => process.stderr.write(`${s}\n`);
20
+ const json = (v) => out(JSON.stringify(v, null, 2));
21
+ function printReport(r) {
22
+ if (r.rebuilt)
23
+ out(`full rebuild: ${r.rebuilt.reason}`);
24
+ out(`added ${r.added.length}, updated ${r.updated.length}, removed ${r.removed.length}, unchanged ${r.unchanged.length}, skipped ${r.skipped.length}`);
25
+ for (const s of r.skipped)
26
+ out(` skipped ${s.path}: ${s.reason}`);
27
+ for (const s of r.summaryErrors)
28
+ out(` summary failed ${s.path}${s.nodeId ? ` [${s.nodeId}]` : ''}: ${s.message}`);
29
+ for (const w of r.warnings)
30
+ out(` warning ${w.path}: ${w.message}`);
31
+ if (r.warnings.some((w) => w.message.includes('setext'))) {
32
+ out(' hint: if your converter writes `---` page breaks, re-run with --no-setext (rebuilds the whole index)');
33
+ }
34
+ }
35
+ async function indexCommand(root, o) {
36
+ root = resolve(root);
37
+ assertLibraryRoot(root);
38
+ const rateLimit = parseRateLimit({ rpm: o.summaryRpm, tpm: o.summaryTpm }, '--summary-rpm', '--summary-tpm');
39
+ if (rateLimit && (!o.summaryModel || o.estimate)) {
40
+ err(`markindex: ${[o.summaryRpm && '--summary-rpm', o.summaryTpm && '--summary-tpm'].filter(Boolean).join(', ')} ignored (needs --summary-model and no --estimate)`);
41
+ }
42
+ const model = o.summaryModel && !o.estimate ? await resolveSummaryModel(o.summaryModel) : undefined;
43
+ const store = openStore(resolve(o.db ?? defaultDbFile(root)));
44
+ try {
45
+ const report = await indexLibrary({
46
+ root,
47
+ store,
48
+ setext: o.setext,
49
+ ignore: o.ignore,
50
+ summary: model ? { model, ...(rateLimit ? { rateLimit } : {}) } : undefined,
51
+ onProgress: o.json ? undefined : (e) => err(`[${e.done}/${e.total}] ${e.status} ${e.path}`),
52
+ });
53
+ if (o.estimate) {
54
+ const est = estimateSummaryCost(store);
55
+ if (o.json)
56
+ json({ report, estimate: est });
57
+ else {
58
+ printReport(report);
59
+ out(`Summary estimate: ${est.nodes} nodes to summarize, ${est.llmCalls} LLM calls, ~${est.inputTokens} input tokens (no LLM calls were made).`);
60
+ }
61
+ return;
62
+ }
63
+ if (o.json)
64
+ json(report);
65
+ else
66
+ printReport(report);
67
+ }
68
+ finally {
69
+ store.close();
70
+ }
71
+ }
72
+ function renderTree(nodes, depth, level = 1, lines = []) {
73
+ for (const n of nodes) {
74
+ const more = n.children.length > 0 && level >= depth ? ` (+${n.children.length} children)` : '';
75
+ lines.push(`${' '.repeat(level - 1)}${n.title} [${n.nodeId}] L${n.startLine}-${n.endLine} ${n.tokens}t${more}`);
76
+ if (level < depth)
77
+ renderTree(n.children, depth, level + 1, lines);
78
+ }
79
+ return lines;
80
+ }
81
+ function pruneDepth(nodes, depth) {
82
+ return nodes.map((n) => ({ ...n, children: depth > 1 ? pruneDepth(n.children, depth - 1) : [] }));
83
+ }
84
+ function treeCommand(root, doc, o) {
85
+ const lib = openLibrary({ root, dbFile: o.db });
86
+ try {
87
+ const tree = lib.store.getTree(doc);
88
+ if (!tree)
89
+ throw new Error(`Document not found in index: ${doc}. Use \`markindex search\` or check the path (library-relative, case-sensitive).`);
90
+ const depth = Math.max(1, Number.parseInt(o.depth, 10) || 1);
91
+ if (o.json)
92
+ json(pruneDepth(tree, depth));
93
+ else
94
+ out(renderTree(tree, depth).join('\n'));
95
+ }
96
+ finally {
97
+ lib.close();
98
+ }
99
+ }
100
+ function searchCommand(root, query, o) {
101
+ const lib = openLibrary({ root, dbFile: o.db });
102
+ try {
103
+ const hits = searchNodes(lib.store, query, { folder: o.folder, docPath: o.doc, limit: Number.parseInt(o.limit, 10) || 10 });
104
+ if (o.json) {
105
+ json(hits);
106
+ return;
107
+ }
108
+ if (hits.length === 0) {
109
+ out('No matches.');
110
+ return;
111
+ }
112
+ for (const h of hits) {
113
+ out(`${h.docPath} > ${h.titlePath.join(' > ')} [${h.nodeId}] L${h.startLine}-${h.endLine}`);
114
+ out(` ${h.snippet.replace(/\s+/g, ' ')}`);
115
+ }
116
+ }
117
+ finally {
118
+ lib.close();
119
+ }
120
+ }
121
+ async function mcpCommand(root, o) {
122
+ root = resolve(root);
123
+ assertLibraryRoot(root);
124
+ const dbFile = resolve(o.db ?? defaultDbFile(root));
125
+ if (o.reindexOnStart) {
126
+ const store = openStore(dbFile);
127
+ try {
128
+ const report = await indexLibrary({ root, store, setext: o.setext, ignore: o.ignore });
129
+ for (const w of report.warnings)
130
+ err(`markindex: warning ${w.path}: ${w.message} (--no-setext)`);
131
+ }
132
+ finally {
133
+ store.close();
134
+ }
135
+ }
136
+ else {
137
+ const unused = [...(o.ignore.length > 0 ? ['--ignore'] : []), ...(o.setext === undefined ? [] : [o.setext ? '--setext' : '--no-setext'])];
138
+ if (unused.length > 0)
139
+ err(`markindex: ${unused.join(', ')} only apply when indexing; ignored without --reindex-on-start`);
140
+ }
141
+ if (!o.reindexOnStart && !existsSync(dbFile)) {
142
+ throw new Error(`No MarkIndex index at ${dbFile}. Run \`markindex index ${root}\` first (or pass --reindex-on-start).`);
143
+ }
144
+ const lib = openLibrary({ root, dbFile });
145
+ const server = createMcpServer(lib);
146
+ process.on('exit', () => lib.close());
147
+ await server.connect(new StdioServerTransport());
148
+ err(`markindex MCP server ready (stdio), library: ${root}`);
149
+ }
150
+ const collect = (value, previous) => [...previous, value];
151
+ const EVAL_MODES = { bm25: 'bm25', agent: 'agent', tools: 'agent', retrieve: 'agent' };
152
+ const int = (v, name, min = 1) => {
153
+ const n = Number(v);
154
+ if (!Number.isInteger(n) || n < min)
155
+ throw new Error(`${name} must be an integer >= ${min}, got "${v}"`);
156
+ return n;
157
+ };
158
+ function printSummary(report) {
159
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
160
+ const row = (label, s) => s &&
161
+ out(`${label.padEnd(10)}${String(s.questions).padStart(5)}${String(s.hits).padStart(6)}${pct(s.hitRate).padStart(9)}` +
162
+ `${pct(s.hitAt1).padStart(9)}${s.mrr.toFixed(3).padStart(8)}${String(Math.round(s.avgTokens)).padStart(11)}` +
163
+ `${s.avgSteps.toFixed(1).padStart(7)}${String(s.errors).padStart(7)}` +
164
+ `${s.meanEvidenceLines.toFixed(1).padStart(10)}${String(s.medianEvidenceLines).padStart(7)}` +
165
+ `${(s.meanPrecision === null ? '-' : pct(s.meanPrecision)).padStart(8)}`);
166
+ const table = (title, a) => {
167
+ out(title);
168
+ out(`${'set'.padEnd(10)}${'n'.padStart(5)}${'hits'.padStart(6)}${'hit'.padStart(9)}${'hit@1'.padStart(9)}${'mrr'.padStart(8)}${'avg tok'.padStart(11)}${'steps'.padStart(7)}${'errors'.padStart(7)}${'ev lines'.padStart(10)}${'med'.padStart(7)}${'prec'.padStart(8)}`);
169
+ row('overall', a.overall);
170
+ for (const lang of ['zh', 'en', 'mixed'])
171
+ row(lang, a.byLang[lang]);
172
+ };
173
+ const maxLines = report.options.maxEvidenceLines ? `, hits need evidence <= ${report.options.maxEvidenceLines} lines` : '';
174
+ table(`mode ${report.mode}${report.model ? ` (${report.model})` : ''}${report.options.topK ? `, top-${report.options.topK}` : ''}${maxLines}`, report.summary);
175
+ if (report.baseline) {
176
+ out('');
177
+ table(`baseline bm25 top-${report.baseline.topK}`, report.baseline.summary);
178
+ }
179
+ }
180
+ async function evalCommand(root, o) {
181
+ const rateLimit = parseRateLimit(o, '--rpm', '--tpm');
182
+ const mode = EVAL_MODES[o.mode];
183
+ if (!mode)
184
+ throw new Error(`--mode must be bm25 or agent (aliases: tools, retrieve), got "${o.mode}"`);
185
+ if (mode === 'agent' && !o.model)
186
+ throw new Error('agent mode needs --model <provider:model>, e.g. --model anthropic:claude-sonnet-5-5');
187
+ if (rateLimit && mode !== 'agent')
188
+ err('markindex: --rpm, --tpm only apply to agent mode; ignored');
189
+ const maxEvidenceLines = o.maxEvidenceLines === undefined ? undefined : int(o.maxEvidenceLines, '--max-evidence-lines');
190
+ const questions = parseQuestions(readFileSync(o.questions, 'utf8'), o.questions);
191
+ const lib = openLibrary({ root, dbFile: o.db });
192
+ try {
193
+ const goldIssues = checkGold(lib, questions);
194
+ if (goldIssues.length > 0)
195
+ throw new QuestionFileError(goldIssues, o.questions);
196
+ const model = mode === 'agent'
197
+ ? await resolveSummaryModel(o.model).catch((e) => {
198
+ throw e instanceof ProviderError ? new ProviderError(e.message.replace('--summary-model', '--model')) : e;
199
+ })
200
+ : undefined;
201
+ const report = await runEval(lib, questions, {
202
+ mode,
203
+ topK: int(o.topK, '--top-k'),
204
+ maxSteps: int(o.maxSteps, '--max-steps'),
205
+ maxInputTokens: int(o.maxInputTokens, '--max-input-tokens'),
206
+ concurrency: int(o.concurrency, '--concurrency'),
207
+ baseline: o.baseline,
208
+ ...(maxEvidenceLines !== undefined ? { maxEvidenceLines } : {}),
209
+ ...(model ? { model, modelId: o.model } : {}),
210
+ ...(model && rateLimit ? { rateLimit } : {}),
211
+ onProgress: o.json
212
+ ? undefined
213
+ : (done, total, r) => err(`[${done}/${total}] ${r.id} ${r.hit ? 'HIT' : 'miss'}${r.error ? ` (error: ${r.error})` : ''}`),
214
+ });
215
+ if (o.out)
216
+ writeFileSync(o.out, `${JSON.stringify(report, null, 2)}\n`);
217
+ if (o.json)
218
+ json(report);
219
+ else {
220
+ printSummary(report);
221
+ if (o.out)
222
+ out(`\nFull report: ${resolve(o.out)}`);
223
+ }
224
+ }
225
+ finally {
226
+ lib.close();
227
+ }
228
+ }
229
+ export function buildProgram() {
230
+ const program = new Command('markindex').description('Section-tree index of Markdown libraries for agent retrieval').version(packageVersion());
231
+ program
232
+ .command('index <root>')
233
+ .description('Build or update the index of a Markdown folder')
234
+ .option('--db <file>', 'index database file (default <root>/.markindex/index.db)')
235
+ .option('--summary-model <provider:model>', 'summarize with an LLM, e.g. anthropic:claude-sonnet-5-5 (needs @ai-sdk/<provider> installed)')
236
+ .option('--summary-rpm <n>', 'limit summary LLM requests per minute (sliding 60 s window)')
237
+ .option('--summary-tpm <n>', 'limit estimated summary input tokens per minute (sliding 60 s window)')
238
+ .option('--estimate', 'index, then print the summarization cost estimate and exit without LLM calls')
239
+ .option('--ignore <pattern>', 'gitignore-syntax pattern relative to <root> to leave out (repeatable; adds to .gitignore / .markindexignore)', collect, [])
240
+ .option('--setext', 'treat `Text` + `---` / `===` as headings (CommonMark; the default for a new index); rebuilds when it was off')
241
+ .option('--no-setext', 'do not treat `---` / `===` under a paragraph as a heading (converter page breaks); stored in the index, rebuilds when changed')
242
+ .option('--json', 'machine-readable output')
243
+ .action(indexCommand);
244
+ program
245
+ .command('tree <root> <doc>')
246
+ .description('Print the section tree of one document (library-relative path)')
247
+ .option('--db <file>', 'index database file')
248
+ .option('--depth <n>', 'outline levels to show', '3')
249
+ .option('--json', 'machine-readable output')
250
+ .action(treeCommand);
251
+ program
252
+ .command('search <root> <query>')
253
+ .description('Keyword search over the index')
254
+ .option('--db <file>', 'index database file')
255
+ .option('--folder <folder>', 'restrict to a library-relative folder')
256
+ .option('--doc <path>', 'restrict to one document')
257
+ .option('--limit <n>', 'maximum hits', '10')
258
+ .option('--json', 'machine-readable output')
259
+ .action(searchCommand);
260
+ program
261
+ .command('mcp <root>')
262
+ .description('Serve the library to agents as an MCP server over stdio (read-only)')
263
+ .option('--db <file>', 'index database file')
264
+ .option('--reindex-on-start', 'update the index before serving')
265
+ .option('--ignore <pattern>', 'with --reindex-on-start: pattern to leave out (repeatable, see `index --ignore`)', collect, [])
266
+ .option('--setext', 'with --reindex-on-start: turn setext headings back on (see `index --setext`)')
267
+ .option('--no-setext', 'with --reindex-on-start: index without setext headings (see `index --no-setext`)')
268
+ .action(mcpCommand);
269
+ program
270
+ .command('eval <root>')
271
+ .description('Measure retrieval quality on a JSONL question file (see eval/README.md for the format)')
272
+ .requiredOption('--questions <file>', 'JSONL question file: {"id","question","lang":"zh|en|mixed","gold":[{"doc_path","start_line"?,"end_line"?,"title_path"?}]}')
273
+ .option('--mode <mode>', 'bm25 (keyword baseline, no LLM) or agent (built-in retrieve() loop over the agent tools; "tools" and "retrieve" are aliases)', 'bm25')
274
+ .option('--model <provider:model>', 'agent mode: model, e.g. anthropic:claude-sonnet-5-5 (needs @ai-sdk/<provider> installed)')
275
+ .option('--top-k <n>', 'bm25: hits per question', '5')
276
+ .option('--max-steps <n>', 'agent: model steps per question', String(RETRIEVE_MAX_STEPS))
277
+ .option('--max-input-tokens <n>', 'agent: input-token budget per question', String(RETRIEVE_MAX_INPUT_TOKENS))
278
+ .option('--concurrency <n>', 'agent: questions run in parallel', '1')
279
+ .option('--rpm <n>', 'agent: limit model requests per minute, shared by all questions (sliding 60 s window)')
280
+ .option('--tpm <n>', 'agent: limit estimated input tokens per minute, shared by all questions (sliding 60 s window)')
281
+ .option('--no-baseline', 'agent: skip the bm25 baseline')
282
+ .option('--max-evidence-lines <n>', 'count a hit only if the matching evidence item spans at most n lines')
283
+ .option('--db <file>', 'index database file')
284
+ .option('--out <file>', 'write the full JSON report (per-question results) to this file')
285
+ .option('--json', 'print the full JSON report instead of the summary table')
286
+ .action(evalCommand);
287
+ return program;
288
+ }
289
+ try {
290
+ await buildProgram().parseAsync(process.argv);
291
+ }
292
+ catch (e) {
293
+ err(e instanceof StoreLockedError || e instanceof ProviderError || e instanceof Error
294
+ ? `markindex: ${e.message}`
295
+ : `markindex: ${String(e)}`);
296
+ process.exitCode = 1;
297
+ }
@@ -0,0 +1,18 @@
1
+ /** Per-response character budget (PageIndex 100_000 x 0.95). */
2
+ export declare const RESPONSE_CHAR_BUDGET = 95000;
3
+ export declare const DEFAULT_STRUCTURE_DEPTH = 2;
4
+ /** PageIndex `--summary-token-threshold` default. */
5
+ export declare const SUMMARY_TOKEN_THRESHOLD = 200;
6
+ export declare const MAX_NODE_TOKENS = 8000;
7
+ export declare const ROUTING_COST_TOKENS = 150;
8
+ /** PageIndex 20 pages x ~750 tokens; to be calibrated in Task 12. */
9
+ export declare const STRUCTURE_FIRST_TOKEN_THRESHOLD = 15000;
10
+ /** PageIndex `_SIMILAR_NAMES_LIMIT`. */
11
+ export declare const SIMILAR_NAMES_LIMIT = 3;
12
+ /** Node titles longer than this many code points are cut and end with an ellipsis; the body keeps the full heading. */
13
+ export declare const MAX_TITLE_CHARS = 300;
14
+ /**
15
+ * Bump whenever parsing, tree building or segmentation changes what gets stored, so existing indexes are rebuilt.
16
+ * 2: CR / CRLF / LF line endings, NFKC segmentation, capped heading titles.
17
+ */
18
+ export declare const INDEX_FORMAT_VERSION = 2;
package/dist/config.js ADDED
@@ -0,0 +1,18 @@
1
+ /** Per-response character budget (PageIndex 100_000 x 0.95). */
2
+ export const RESPONSE_CHAR_BUDGET = 95_000;
3
+ export const DEFAULT_STRUCTURE_DEPTH = 2;
4
+ /** PageIndex `--summary-token-threshold` default. */
5
+ export const SUMMARY_TOKEN_THRESHOLD = 200;
6
+ export const MAX_NODE_TOKENS = 8_000;
7
+ export const ROUTING_COST_TOKENS = 150;
8
+ /** PageIndex 20 pages x ~750 tokens; to be calibrated in Task 12. */
9
+ export const STRUCTURE_FIRST_TOKEN_THRESHOLD = 15_000;
10
+ /** PageIndex `_SIMILAR_NAMES_LIMIT`. */
11
+ export const SIMILAR_NAMES_LIMIT = 3;
12
+ /** Node titles longer than this many code points are cut and end with an ellipsis; the body keeps the full heading. */
13
+ export const MAX_TITLE_CHARS = 300;
14
+ /**
15
+ * Bump whenever parsing, tree building or segmentation changes what gets stored, so existing indexes are rebuilt.
16
+ * 2: CR / CRLF / LF line endings, NFKC segmentation, capped heading titles.
17
+ */
18
+ export const INDEX_FORMAT_VERSION = 2;
@@ -0,0 +1,34 @@
1
+ /** One acceptable answer location. Lines are 1-based, inclusive, in the original file. */
2
+ export interface Gold {
3
+ /** Library-relative document path (same form as `markindex search` prints). */
4
+ doc_path: string;
5
+ /** Titles from the top-level section down to the answer node (used when no line range is given). */
6
+ title_path?: string[];
7
+ start_line?: number;
8
+ end_line?: number;
9
+ }
10
+ export interface EvalQuestion {
11
+ id: string;
12
+ question: string;
13
+ lang: 'zh' | 'en' | 'mixed';
14
+ /** Any one gold location counts as a hit. */
15
+ gold: Gold[];
16
+ note?: string;
17
+ /** 1-based line of the question file this question came from. */
18
+ line: number;
19
+ }
20
+ export interface QuestionIssue {
21
+ line: number;
22
+ message: string;
23
+ }
24
+ export declare class QuestionFileError extends Error {
25
+ readonly issues: QuestionIssue[];
26
+ constructor(issues: QuestionIssue[], file?: string);
27
+ }
28
+ /**
29
+ * Parses a JSONL question file (one JSON object per line; blank lines ignored; BOM / CRLF tolerated).
30
+ * Collects every problem and throws one `QuestionFileError` listing them with line numbers.
31
+ *
32
+ * Line format: {"id", "question", "lang": "zh"|"en"|"mixed", "gold": [{"doc_path", "title_path"?, "start_line"?, "end_line"?}], "note"?}
33
+ */
34
+ export declare function parseQuestions(text: string, file?: string): EvalQuestion[];
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ export class QuestionFileError extends Error {
3
+ issues;
4
+ constructor(issues, file = 'questions') {
5
+ super(`${issues.length} problem(s) in the question file:\n` +
6
+ issues.map((i) => `${file}:${i.line}: ${i.message}`).join('\n'));
7
+ this.issues = issues;
8
+ }
9
+ }
10
+ const lineNo = z.int().min(1);
11
+ const goldSchema = z
12
+ .strictObject({
13
+ doc_path: z.string().min(1),
14
+ title_path: z.array(z.string().min(1)).min(1).optional(),
15
+ start_line: lineNo.optional(),
16
+ end_line: lineNo.optional(),
17
+ })
18
+ .superRefine((g, ctx) => {
19
+ if ((g.start_line === undefined) !== (g.end_line === undefined)) {
20
+ ctx.addIssue({ code: 'custom', message: 'start_line and end_line must be given together' });
21
+ }
22
+ else if (g.start_line !== undefined && g.end_line !== undefined && g.end_line < g.start_line) {
23
+ ctx.addIssue({ code: 'custom', path: ['end_line'], message: 'end_line must be >= start_line' });
24
+ }
25
+ });
26
+ const questionSchema = z.strictObject({
27
+ id: z.string().trim().min(1),
28
+ question: z.string().trim().min(1),
29
+ lang: z.enum(['zh', 'en', 'mixed']),
30
+ gold: z.array(goldSchema).min(1, 'gold needs at least one location'),
31
+ note: z.string().optional(),
32
+ });
33
+ const describeIssue = (i) => {
34
+ const path = i.path.length ? `${i.path.join('.')}: ` : '';
35
+ if (i.code === 'unrecognized_keys')
36
+ return `${path}unknown field(s) ${i.keys.map((k) => `"${k}"`).join(', ')}`;
37
+ return `${path}${i.message}`;
38
+ };
39
+ /**
40
+ * Parses a JSONL question file (one JSON object per line; blank lines ignored; BOM / CRLF tolerated).
41
+ * Collects every problem and throws one `QuestionFileError` listing them with line numbers.
42
+ *
43
+ * Line format: {"id", "question", "lang": "zh"|"en"|"mixed", "gold": [{"doc_path", "title_path"?, "start_line"?, "end_line"?}], "note"?}
44
+ */
45
+ export function parseQuestions(text, file) {
46
+ const issues = [];
47
+ const out = [];
48
+ const seen = new Map();
49
+ const lines = text.replace(/^/, '').split(/\r?\n/);
50
+ lines.forEach((raw, i) => {
51
+ const line = i + 1;
52
+ if (raw.trim() === '')
53
+ return;
54
+ let value;
55
+ try {
56
+ value = JSON.parse(raw);
57
+ }
58
+ catch (e) {
59
+ issues.push({ line, message: `invalid JSON (${e.message})` });
60
+ return;
61
+ }
62
+ const parsed = questionSchema.safeParse(value);
63
+ if (!parsed.success) {
64
+ issues.push({ line, message: parsed.error.issues.map(describeIssue).join('; ') });
65
+ return;
66
+ }
67
+ const prev = seen.get(parsed.data.id);
68
+ if (prev !== undefined) {
69
+ issues.push({ line, message: `duplicate id "${parsed.data.id}" (first used on line ${prev})` });
70
+ return;
71
+ }
72
+ seen.set(parsed.data.id, line);
73
+ out.push({ ...parsed.data, line });
74
+ });
75
+ if (issues.length === 0 && out.length === 0)
76
+ issues.push({ line: 1, message: 'no questions found (expected one JSON object per line)' });
77
+ if (issues.length > 0)
78
+ throw new QuestionFileError(issues, file);
79
+ return out;
80
+ }
@@ -0,0 +1,62 @@
1
+ import type { LanguageModel } from 'ai';
2
+ import type { Library } from '../library.js';
3
+ import { type RateLimiter, type RateLimitOptions } from '../llm/rate-limit.js';
4
+ import type { EvalQuestion, QuestionIssue } from './questions.js';
5
+ import { type Aggregate, type QuestionResult } from './score.js';
6
+ /**
7
+ * - `bm25`: baseline — `searchNodes(question)` top-k, no LLM. Evidence = each hit node's own text range.
8
+ * - `agent`: the built-in `retrieve()` loop (generateText + the five library tools + AGENT_INSTRUCTIONS +
9
+ * select_evidence). This is also what an external agent does in "tools" mode, so the two are one mode here.
10
+ */
11
+ export type EvalMode = 'bm25' | 'agent';
12
+ export interface RunEvalOptions {
13
+ mode: EvalMode;
14
+ /** bm25: hits per question (default 5). */
15
+ topK?: number;
16
+ /** agent: the model driving retrieve(). */
17
+ model?: LanguageModel;
18
+ /** agent: label recorded in the report (e.g. "anthropic:claude-sonnet-5-5"). */
19
+ modelId?: string;
20
+ /** agent: retrieve() maxSteps (default RETRIEVE_MAX_STEPS). */
21
+ maxSteps?: number;
22
+ /** agent: retrieve() maxInputTokens per question (default RETRIEVE_MAX_INPUT_TOKENS). */
23
+ maxInputTokens?: number;
24
+ /** agent: questions run in parallel (default 1). */
25
+ concurrency?: number;
26
+ /** agent: client-side rate limit shared by every model step of every question (see `RetrieveOptions.rateLimit`). */
27
+ rateLimit?: RateLimitOptions;
28
+ /** agent: advanced, use this limiter instead of building one from `rateLimit`. */
29
+ rateLimiter?: RateLimiter;
30
+ /** agent: also run the bm25 baseline on the same questions (default true). */
31
+ baseline?: boolean;
32
+ /** When set, a matching evidence item only counts as a hit if it spans at most this many lines. */
33
+ maxEvidenceLines?: number;
34
+ onProgress?: (done: number, total: number, result: QuestionResult) => void;
35
+ }
36
+ export interface EvalReport {
37
+ mode: EvalMode;
38
+ model?: string;
39
+ createdAt: string;
40
+ options: {
41
+ topK?: number;
42
+ maxSteps?: number;
43
+ maxInputTokens?: number;
44
+ maxEvidenceLines?: number;
45
+ rateLimit?: RateLimitOptions;
46
+ };
47
+ summary: Aggregate;
48
+ /** agent mode: the bm25 top-k baseline on the same questions. */
49
+ baseline?: {
50
+ mode: 'bm25';
51
+ topK: number;
52
+ summary: Aggregate;
53
+ };
54
+ results: QuestionResult[];
55
+ }
56
+ /**
57
+ * Checks every gold location against the index: the document exists, the line range is inside the file and a
58
+ * title_path names an existing node. Returns issues keyed by question-file line (empty = all good).
59
+ */
60
+ export declare function checkGold(lib: Library, questions: EvalQuestion[]): QuestionIssue[];
61
+ /** Runs every question in the given mode and aggregates the results (overall + by language). */
62
+ export declare function runEval(lib: Library, questions: EvalQuestion[], opts: RunEvalOptions): Promise<EvalReport>;