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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +288 -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 +88 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { INDEX_FORMAT_VERSION, MAX_NODE_TOKENS, ROUTING_COST_TOKENS } from '../config.js';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, extname } from 'node:path';
|
|
5
|
+
import { parseMarkdown } from '../parse/markdown.js';
|
|
6
|
+
import { approxTokens } from '../text/tokens.js';
|
|
7
|
+
import { buildTree } from '../tree/build.js';
|
|
8
|
+
import { optimizeTree } from '../tree/optimize.js';
|
|
9
|
+
import { summarizeDocument } from '../llm/summarize.js';
|
|
10
|
+
import { assertLibraryRoot, scanLibrary } from './scan.js';
|
|
11
|
+
const FORMAT_KEY = 'index_format';
|
|
12
|
+
/** Fingerprint of everything besides file content that shapes the stored trees (INDEX_FORMAT_VERSION + build parameters). */
|
|
13
|
+
export function indexFormat(opts) {
|
|
14
|
+
const custom = opts.countTokens !== undefined && opts.countTokens !== approxTokens;
|
|
15
|
+
return JSON.stringify({
|
|
16
|
+
version: INDEX_FORMAT_VERSION,
|
|
17
|
+
maxNodeTokens: opts.maxNodeTokens ?? MAX_NODE_TOKENS,
|
|
18
|
+
routingCost: opts.routingCost ?? ROUTING_COST_TOKENS,
|
|
19
|
+
tokens: custom ? `custom${opts.tokenCounterId === undefined ? '' : `:${opts.tokenCounterId}`}` : 'approx',
|
|
20
|
+
// only recorded when off, so indexes built before the option existed (setext always on) are not rebuilt
|
|
21
|
+
...(opts.setext === false ? { setext: false } : {}),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** A setext h2 whose heading text is longer than this many code points (or spans several lines) looks like a page. */
|
|
25
|
+
export const SUSPECT_SETEXT_CHARS = 120;
|
|
26
|
+
/**
|
|
27
|
+
* Setext h2 headings that are probably a converter's page break (`---` right under a paragraph) rather than a real
|
|
28
|
+
* heading: heading text over SUSPECT_SETEXT_CHARS code points or spread over more than one source line.
|
|
29
|
+
*/
|
|
30
|
+
function suspectSetextHeadings(doc) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const h of doc.headings) {
|
|
33
|
+
if (h.level !== 2 || h.endLine === h.line)
|
|
34
|
+
continue; // ATX headings are one line; setext ones span text + underline
|
|
35
|
+
const textLines = h.endLine - h.line;
|
|
36
|
+
const raw = doc.lines.slice(h.line - 1, h.endLine - 1).join('\n').trim();
|
|
37
|
+
if (textLines > 1 || Array.from(raw).length > SUSPECT_SETEXT_CHARS)
|
|
38
|
+
out.push(h.line);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** The setext setting recorded in a stored fingerprint (true when absent or unreadable). */
|
|
43
|
+
function storedSetext(stored) {
|
|
44
|
+
if (stored === undefined)
|
|
45
|
+
return true;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(stored).setext !== false;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
54
|
+
/**
|
|
55
|
+
* Incrementally indexes the Markdown files under `root` (spec §6): unchanged content hashes are skipped,
|
|
56
|
+
* new / changed files are rebuilt one transaction per document, and documents missing on disk are deleted.
|
|
57
|
+
* A file that cannot be read, decoded or indexed is reported in `skipped`; any previous version of it is
|
|
58
|
+
* dropped so the index never serves content that no longer matches the disk. An unreadable subdirectory is
|
|
59
|
+
* reported in `skipped` (path with trailing `/`) and the documents previously indexed under it are kept.
|
|
60
|
+
* When the stored index format (see `indexFormat`) differs from this run's, every document is re-indexed
|
|
61
|
+
* (summaries are reused through the content-hash cache) and the report carries `rebuilt`.
|
|
62
|
+
*/
|
|
63
|
+
export async function indexLibrary(opts) {
|
|
64
|
+
assertLibraryRoot(opts.root);
|
|
65
|
+
const { store } = opts;
|
|
66
|
+
const countTokens = opts.countTokens ?? approxTokens;
|
|
67
|
+
const report = { added: [], updated: [], removed: [], unchanged: [], skipped: [], summaryErrors: [], warnings: [] };
|
|
68
|
+
const storedFormat = store.getMeta(FORMAT_KEY);
|
|
69
|
+
const setext = opts.setext ?? storedSetext(storedFormat);
|
|
70
|
+
const format = indexFormat({ ...opts, setext });
|
|
71
|
+
const rebuild = storedFormat !== format && store.allDocPaths().length > 0;
|
|
72
|
+
if (rebuild) {
|
|
73
|
+
report.rebuilt = {
|
|
74
|
+
reason: `index format changed (stored ${storedFormat ?? 'none'}, now ${format}): re-indexing every document`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const unreadableDirs = [];
|
|
78
|
+
const files = scanLibrary(opts.root, {
|
|
79
|
+
ignore: opts.ignore,
|
|
80
|
+
onError: (dir, error) => {
|
|
81
|
+
unreadableDirs.push(dir);
|
|
82
|
+
report.skipped.push({ path: dir, reason: `directory not readable: ${error.message}` });
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const total = files.length;
|
|
86
|
+
let done = 0;
|
|
87
|
+
const emit = (path, status, reason) => opts.onProgress?.(reason === undefined ? { path, status, done, total } : { path, status, reason, done, total });
|
|
88
|
+
const summarize = async (path) => {
|
|
89
|
+
if (!opts.summary)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
const res = await summarizeDocument(store, path, opts.summary);
|
|
93
|
+
for (const f of res.failures)
|
|
94
|
+
report.summaryErrors.push({ path, ...f });
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
report.summaryErrors.push({ path, nodeId: null, message: errorMessage(e) });
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
done++;
|
|
102
|
+
const existing = store.getDoc(file.path);
|
|
103
|
+
try {
|
|
104
|
+
const bytes = readFileSync(file.abs);
|
|
105
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
106
|
+
if (!rebuild && existing?.contentHash === hash) {
|
|
107
|
+
report.unchanged.push(file.path);
|
|
108
|
+
emit(file.path, 'unchanged');
|
|
109
|
+
await summarize(file.path); // enables summaries on an existing index and retries earlier failures (cache-assisted)
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
let src;
|
|
113
|
+
try {
|
|
114
|
+
src = utf8.decode(bytes);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error('not valid utf-8 text');
|
|
118
|
+
}
|
|
119
|
+
const doc = parseMarkdown(src, { setext });
|
|
120
|
+
const suspects = setext ? suspectSetextHeadings(doc) : [];
|
|
121
|
+
const title = docTitle(doc, file.path);
|
|
122
|
+
const tree = optimizeTree(buildTree(doc, { fileTitle: title, countTokens }), doc, {
|
|
123
|
+
countTokens,
|
|
124
|
+
maxNodeTokens: opts.maxNodeTokens,
|
|
125
|
+
routingCost: opts.routingCost,
|
|
126
|
+
});
|
|
127
|
+
const record = {
|
|
128
|
+
docId: '',
|
|
129
|
+
path: file.path,
|
|
130
|
+
title,
|
|
131
|
+
metadata: doc.frontmatter?.data ?? {},
|
|
132
|
+
contentHash: hash,
|
|
133
|
+
lineCount: doc.lines.length,
|
|
134
|
+
tokens: tree.reduce((sum, n) => sum + n.tokens, 0),
|
|
135
|
+
indexedAt: new Date().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
store.replaceDoc(record, tree, doc.lines);
|
|
138
|
+
if (suspects.length > 0) {
|
|
139
|
+
report.warnings.push({
|
|
140
|
+
path: file.path,
|
|
141
|
+
message: `${suspects.length} setext h2 heading(s) look like page breaks (a \`---\` line under a paragraph that spans ` +
|
|
142
|
+
`several lines or exceeds ${SUSPECT_SETEXT_CHARS} characters), first at line ${suspects[0]}; if \`---\` ` +
|
|
143
|
+
'marks page breaks in this file, index with setext headings off',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const status = existing ? 'updated' : 'added';
|
|
147
|
+
report[status].push(file.path);
|
|
148
|
+
emit(file.path, status);
|
|
149
|
+
await summarize(file.path);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
let reason = errorMessage(e);
|
|
153
|
+
if (existing) {
|
|
154
|
+
try {
|
|
155
|
+
store.deleteDoc(file.path);
|
|
156
|
+
}
|
|
157
|
+
catch (e2) {
|
|
158
|
+
reason += `; previous version could not be removed: ${errorMessage(e2)}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
report.skipped.push({ path: file.path, reason });
|
|
162
|
+
emit(file.path, 'skipped', reason);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const onDisk = new Set(files.map((f) => f.path));
|
|
166
|
+
for (const path of store.allDocPaths()) {
|
|
167
|
+
if (onDisk.has(path))
|
|
168
|
+
continue;
|
|
169
|
+
// Contents of an unreadable directory are unknown: keep their previous versions.
|
|
170
|
+
if (unreadableDirs.some((dir) => path.startsWith(dir)))
|
|
171
|
+
continue;
|
|
172
|
+
try {
|
|
173
|
+
store.deleteDoc(path);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
const reason = `could not remove deleted document: ${errorMessage(e)}`;
|
|
177
|
+
report.skipped.push({ path, reason });
|
|
178
|
+
emit(path, 'skipped', reason);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
report.removed.push(path);
|
|
182
|
+
emit(path, 'removed');
|
|
183
|
+
}
|
|
184
|
+
// Written last: an interrupted rebuild keeps the old fingerprint and is redone in full next time.
|
|
185
|
+
if (storedFormat !== format)
|
|
186
|
+
store.setMeta(FORMAT_KEY, format);
|
|
187
|
+
return report;
|
|
188
|
+
}
|
|
189
|
+
const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
190
|
+
/** frontmatter `title` > first h1 > file name without extension. */
|
|
191
|
+
function docTitle(doc, path) {
|
|
192
|
+
const fm = doc.frontmatter?.data.title;
|
|
193
|
+
if (typeof fm === 'string' && fm.trim() !== '')
|
|
194
|
+
return fm.trim();
|
|
195
|
+
const h1 = doc.headings.find((h) => h.level === 1 && h.title !== '');
|
|
196
|
+
if (h1)
|
|
197
|
+
return h1.title;
|
|
198
|
+
return basename(path, extname(path));
|
|
199
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ScannedFile {
|
|
2
|
+
/** Library-relative path with posix separators. */
|
|
3
|
+
path: string;
|
|
4
|
+
abs: string;
|
|
5
|
+
}
|
|
6
|
+
/** Optional ignore file at the library root (gitignore syntax), for rules that should not live in .gitignore. */
|
|
7
|
+
export declare const IGNORE_FILE = ".markindexignore";
|
|
8
|
+
export interface ScanOptions {
|
|
9
|
+
/** Called for a subdirectory that cannot be read (e.g. EACCES); it is skipped and the scan continues. */
|
|
10
|
+
onError?: (path: string, error: Error) => void;
|
|
11
|
+
/** Extra gitignore-syntax patterns, relative to `root`, applied on top of `.gitignore` and `.markindexignore`. */
|
|
12
|
+
ignore?: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Makes `ignore` option patterns usable as root-level gitignore patterns: a leading `./` (also after `!`) is
|
|
16
|
+
* stripped, since gitignore would never match it. A leading `/` stays a gitignore anchor (`/drafts/` = only the
|
|
17
|
+
* top-level folder). Throws for filesystem paths — a pattern starting with the library root's absolute path, a
|
|
18
|
+
* drive letter or `~` — and for `../` patterns, which can never match inside the library.
|
|
19
|
+
*/
|
|
20
|
+
export declare function normalizeIgnorePatterns(root: string, patterns: string[]): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Lists `.md` / `.markdown` files under `root` (spec §6), sorted by path.
|
|
23
|
+
* Skips `.mdx`, symbolic links (files and directories), dot-directories (`.git`, `.markindex`, ...)
|
|
24
|
+
* and anything matched by `.gitignore` files at any level, a `.markindexignore` file at the root, or `opts.ignore`. An unreadable root throws; an unreadable
|
|
25
|
+
* subdirectory is skipped and reported through `opts.onError` with its posix path plus a trailing `/`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function scanLibrary(root: string, opts?: ScanOptions): ScannedFile[];
|
|
28
|
+
/** Throws a clear error unless `root` is an existing directory. Never creates anything. */
|
|
29
|
+
export declare function assertLibraryRoot(root: string): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
const EXTENSIONS = ['.md', '.markdown'];
|
|
5
|
+
/** Holds the index database. */
|
|
6
|
+
const STORE_DIR = '.markindex';
|
|
7
|
+
/** Optional ignore file at the library root (gitignore syntax), for rules that should not live in .gitignore. */
|
|
8
|
+
export const IGNORE_FILE = '.markindexignore';
|
|
9
|
+
/**
|
|
10
|
+
* Makes `ignore` option patterns usable as root-level gitignore patterns: a leading `./` (also after `!`) is
|
|
11
|
+
* stripped, since gitignore would never match it. A leading `/` stays a gitignore anchor (`/drafts/` = only the
|
|
12
|
+
* top-level folder). Throws for filesystem paths — a pattern starting with the library root's absolute path, a
|
|
13
|
+
* drive letter or `~` — and for `../` patterns, which can never match inside the library.
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeIgnorePatterns(root, patterns) {
|
|
16
|
+
const absRoot = resolve(root).split(sep).join('/');
|
|
17
|
+
return patterns.map((raw) => {
|
|
18
|
+
const neg = raw.startsWith('!') ? '!' : '';
|
|
19
|
+
let p = raw.slice(neg.length); // backslashes are gitignore escapes, so they are left alone
|
|
20
|
+
if (p.startsWith(`${absRoot}/`) || p === absRoot || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('~')) {
|
|
21
|
+
const rel = p.startsWith(`${absRoot}/`) ? p.slice(absRoot.length + 1) : '<path relative to the library root>';
|
|
22
|
+
throw new Error(`ignore pattern "${raw}" is an absolute path; patterns are gitignore syntax relative to the library root, e.g. "${neg}${rel}"`);
|
|
23
|
+
}
|
|
24
|
+
while (p.startsWith('./'))
|
|
25
|
+
p = p.slice(2);
|
|
26
|
+
if (p === '..' || p.startsWith('../'))
|
|
27
|
+
throw new Error(`ignore pattern "${raw}" points outside the library root`);
|
|
28
|
+
return neg + p;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Lists `.md` / `.markdown` files under `root` (spec §6), sorted by path.
|
|
33
|
+
* Skips `.mdx`, symbolic links (files and directories), dot-directories (`.git`, `.markindex`, ...)
|
|
34
|
+
* and anything matched by `.gitignore` files at any level, a `.markindexignore` file at the root, or `opts.ignore`. An unreadable root throws; an unreadable
|
|
35
|
+
* subdirectory is skipped and reported through `opts.onError` with its posix path plus a trailing `/`.
|
|
36
|
+
*/
|
|
37
|
+
export function scanLibrary(root, opts = {}) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const ignored = (rel, isDir, rules) => rules.some(({ base, ig }) => {
|
|
40
|
+
const sub = base === '' ? rel : rel.slice(base.length + 1);
|
|
41
|
+
return ig.ignores(isDir ? `${sub}/` : sub);
|
|
42
|
+
});
|
|
43
|
+
const visit = (dirRel, inherited) => {
|
|
44
|
+
const abs = dirRel === '' ? root : join(root, dirRel);
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
if (dirRel === '')
|
|
51
|
+
throw e;
|
|
52
|
+
opts.onError?.(`${dirRel}/`, e);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const rules = [...inherited];
|
|
56
|
+
try {
|
|
57
|
+
rules.push({ base: dirRel, ig: ignore().add(readFileSync(join(abs, '.gitignore'), 'utf8')) });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// no .gitignore here
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.isSymbolicLink())
|
|
64
|
+
continue;
|
|
65
|
+
const rel = dirRel === '' ? entry.name : `${dirRel}/${entry.name}`;
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (entry.name.startsWith('.') || entry.name === STORE_DIR)
|
|
68
|
+
continue;
|
|
69
|
+
if (ignored(rel, true, rules))
|
|
70
|
+
continue;
|
|
71
|
+
visit(rel, rules);
|
|
72
|
+
}
|
|
73
|
+
else if (entry.isFile()) {
|
|
74
|
+
const lower = entry.name.toLowerCase();
|
|
75
|
+
if (!EXTENSIONS.some((ext) => lower.endsWith(ext)))
|
|
76
|
+
continue;
|
|
77
|
+
if (ignored(rel, false, rules))
|
|
78
|
+
continue;
|
|
79
|
+
out.push({ path: rel, abs: join(root, rel) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const rootRules = [];
|
|
84
|
+
try {
|
|
85
|
+
rootRules.push({ base: '', ig: ignore().add(readFileSync(join(root, IGNORE_FILE), 'utf8')) });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// no .markindexignore
|
|
89
|
+
}
|
|
90
|
+
if (opts.ignore && opts.ignore.length > 0)
|
|
91
|
+
rootRules.push({ base: '', ig: ignore().add(normalizeIgnorePatterns(root, opts.ignore)) });
|
|
92
|
+
visit('', rootRules);
|
|
93
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
94
|
+
}
|
|
95
|
+
/** Throws a clear error unless `root` is an existing directory. Never creates anything. */
|
|
96
|
+
export function assertLibraryRoot(root) {
|
|
97
|
+
let st;
|
|
98
|
+
try {
|
|
99
|
+
st = statSync(root);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Error(`Library root does not exist: ${root}`);
|
|
103
|
+
}
|
|
104
|
+
if (!st.isDirectory())
|
|
105
|
+
throw new Error(`Library root is not a directory: ${root}`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type IndexOptions, type IndexReport } from './library/indexer.js';
|
|
2
|
+
import { type Store } from './store/db.js';
|
|
3
|
+
import type { ToolName } from './tools/contract.js';
|
|
4
|
+
export interface OpenLibraryOptions {
|
|
5
|
+
/** Library root directory (the folder of Markdown files). */
|
|
6
|
+
root: string;
|
|
7
|
+
/** Index database; defaults to `${root}/.markindex/index.db`. */
|
|
8
|
+
dbFile?: string;
|
|
9
|
+
/** Defaults to true: agents only read, and a read-only store never waits on (or blocks) an indexer. */
|
|
10
|
+
readonly?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface Library {
|
|
13
|
+
readonly store: Store;
|
|
14
|
+
/** Absolute library root. */
|
|
15
|
+
readonly root: string;
|
|
16
|
+
/** Runs an agent tool and returns its JSON envelope. Never rejects, whatever the name or arguments. */
|
|
17
|
+
callTool(name: ToolName, args: unknown): Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Incrementally (re)indexes the library into this library's database. A read-only library opens a separate
|
|
20
|
+
* writable store for the duration of the call (throws StoreLockedError if another writer holds the index).
|
|
21
|
+
*/
|
|
22
|
+
index(opts?: Omit<IndexOptions, 'root' | 'store'>): Promise<IndexReport>;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
export declare const defaultDbFile: (root: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Opens an existing library index for agent use. Does not index: run `markindex index` (or `indexLibrary`)
|
|
28
|
+
* first. Throws if a read-only open finds no index.
|
|
29
|
+
*/
|
|
30
|
+
export declare function openLibrary(opts: OpenLibraryOptions): Library;
|
package/dist/library.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { assertLibraryRoot } from './library/scan.js';
|
|
4
|
+
import { indexLibrary } from './library/indexer.js';
|
|
5
|
+
import { openStore } from './store/db.js';
|
|
6
|
+
import { runTool } from './tools/impl.js';
|
|
7
|
+
export const defaultDbFile = (root) => join(root, '.markindex', 'index.db');
|
|
8
|
+
/**
|
|
9
|
+
* Opens an existing library index for agent use. Does not index: run `markindex index` (or `indexLibrary`)
|
|
10
|
+
* first. Throws if a read-only open finds no index.
|
|
11
|
+
*/
|
|
12
|
+
export function openLibrary(opts) {
|
|
13
|
+
const root = resolve(opts.root);
|
|
14
|
+
assertLibraryRoot(root);
|
|
15
|
+
const dbFile = resolve(opts.dbFile ?? defaultDbFile(root));
|
|
16
|
+
const readonly = opts.readonly ?? true;
|
|
17
|
+
if (readonly && !existsSync(dbFile)) {
|
|
18
|
+
throw new Error(`No MarkIndex index at ${dbFile}. Build it first with \`markindex index ${root}\`.`);
|
|
19
|
+
}
|
|
20
|
+
const store = openStore(dbFile, { readonly });
|
|
21
|
+
return {
|
|
22
|
+
store,
|
|
23
|
+
root,
|
|
24
|
+
async callTool(name, args) {
|
|
25
|
+
return runTool(store, name, args);
|
|
26
|
+
},
|
|
27
|
+
async index(indexOpts = {}) {
|
|
28
|
+
if (!readonly)
|
|
29
|
+
return indexLibrary({ ...indexOpts, root, store });
|
|
30
|
+
const writer = openStore(dbFile);
|
|
31
|
+
try {
|
|
32
|
+
return await indexLibrary({ ...indexOpts, root, store: writer });
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
writer.close();
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
close() {
|
|
39
|
+
store.close();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LanguageModel } from 'ai';
|
|
2
|
+
/** Thrown when a `provider:model` spec cannot be resolved; the message is user-facing. */
|
|
3
|
+
export declare class ProviderError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Resolves `<provider>:<model>` (e.g. `anthropic:claude-sonnet-5-5`) to a language model by dynamically importing
|
|
7
|
+
* `@ai-sdk/<provider>` and calling its `<provider>(model)` export. The provider packages are not dependencies of
|
|
8
|
+
* MarkIndex; a missing one yields a `ProviderError` with an install hint.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveSummaryModel(spec: string, importer?: (specifier: string) => Promise<Record<string, unknown>>): Promise<LanguageModel>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Thrown when a `provider:model` spec cannot be resolved; the message is user-facing. */
|
|
2
|
+
export class ProviderError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Resolves `<provider>:<model>` (e.g. `anthropic:claude-sonnet-5-5`) to a language model by dynamically importing
|
|
6
|
+
* `@ai-sdk/<provider>` and calling its `<provider>(model)` export. The provider packages are not dependencies of
|
|
7
|
+
* MarkIndex; a missing one yields a `ProviderError` with an install hint.
|
|
8
|
+
*/
|
|
9
|
+
export async function resolveSummaryModel(spec, importer = (s) => import(s)) {
|
|
10
|
+
const i = spec.indexOf(':');
|
|
11
|
+
const provider = i > 0 ? spec.slice(0, i) : '';
|
|
12
|
+
const modelId = i > 0 ? spec.slice(i + 1) : '';
|
|
13
|
+
if (!/^[a-z][a-z0-9-]*$/.test(provider) || modelId === '') {
|
|
14
|
+
throw new ProviderError(`Invalid --summary-model "${spec}". Expected <provider>:<model>, e.g. anthropic:claude-sonnet-5-5.`);
|
|
15
|
+
}
|
|
16
|
+
const pkg = `@ai-sdk/${provider}`;
|
|
17
|
+
let mod;
|
|
18
|
+
try {
|
|
19
|
+
mod = await importer(pkg);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
if (e.code === 'ERR_MODULE_NOT_FOUND' || e.code === 'MODULE_NOT_FOUND') {
|
|
23
|
+
throw new ProviderError(`Provider package ${pkg} is not installed. Install it with: npm install ${pkg}`);
|
|
24
|
+
}
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
const factory = mod[provider];
|
|
28
|
+
if (typeof factory !== 'function') {
|
|
29
|
+
throw new ProviderError(`${pkg} does not export a "${provider}" model factory; cannot resolve "${spec}".`);
|
|
30
|
+
}
|
|
31
|
+
return factory(modelId);
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface RateLimitOptions {
|
|
2
|
+
/** Max LLM requests started in any 60 s window. */
|
|
3
|
+
requestsPerMinute?: number;
|
|
4
|
+
/** Max estimated input tokens sent in any 60 s window (estimated with `approxTokens`). */
|
|
5
|
+
inputTokensPerMinute?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface RateLimiter {
|
|
8
|
+
/**
|
|
9
|
+
* Resolves once a request of `inputTokens` estimated tokens may start, and reserves its slot. Callers are served
|
|
10
|
+
* strictly in arrival order. A single request larger than `inputTokensPerMinute` is let through when no other
|
|
11
|
+
* tokens are in the window (it then occupies the whole window), so it can never deadlock.
|
|
12
|
+
*/
|
|
13
|
+
acquire(inputTokens: number): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/** Injectable for tests. */
|
|
16
|
+
export interface RateLimiterClock {
|
|
17
|
+
now?: () => number;
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Sliding-window limiter (requests and estimated input tokens over the last 60 s) with a FIFO queue. No dependencies. */
|
|
21
|
+
export declare function createRateLimiter(opts: RateLimitOptions, clock?: RateLimiterClock): RateLimiter;
|
|
22
|
+
/**
|
|
23
|
+
* One limiter per options object, so every call that receives the same `rateLimit` object (all documents of an index
|
|
24
|
+
* run, all questions of an eval run) draws from the same budget. Undefined when no limit is set.
|
|
25
|
+
*/
|
|
26
|
+
export declare function rateLimiterFor(opts: RateLimitOptions | undefined): RateLimiter | undefined;
|
|
27
|
+
/** Estimated input tokens of anything that will be sent to the model (strings as-is, other values as JSON). */
|
|
28
|
+
export declare const estimateInputTokens: (value: unknown) => number;
|
|
29
|
+
/** CLI helper: `{ rpm, tpm }` strings from the option flags -> `RateLimitOptions` (undefined when neither is set). */
|
|
30
|
+
export declare function parseRateLimit(o: {
|
|
31
|
+
rpm?: string;
|
|
32
|
+
tpm?: string;
|
|
33
|
+
}, rpmFlag: string, tpmFlag: string): RateLimitOptions | undefined;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { approxTokens } from '../text/tokens.js';
|
|
2
|
+
const WINDOW_MS = 60_000;
|
|
3
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
function checkLimit(name, value) {
|
|
5
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 1))
|
|
6
|
+
throw new Error(`${name} must be an integer >= 1, got ${value}`);
|
|
7
|
+
}
|
|
8
|
+
/** Sliding-window limiter (requests and estimated input tokens over the last 60 s) with a FIFO queue. No dependencies. */
|
|
9
|
+
export function createRateLimiter(opts, clock = {}) {
|
|
10
|
+
checkLimit('requestsPerMinute', opts.requestsPerMinute);
|
|
11
|
+
checkLimit('inputTokensPerMinute', opts.inputTokensPerMinute);
|
|
12
|
+
const rpm = opts.requestsPerMinute;
|
|
13
|
+
const tpm = opts.inputTokensPerMinute;
|
|
14
|
+
const now = clock.now ?? (() => performance.now());
|
|
15
|
+
const sleep = clock.sleep ?? defaultSleep;
|
|
16
|
+
let log = [];
|
|
17
|
+
let tail = Promise.resolve();
|
|
18
|
+
const fits = (at, tokens) => {
|
|
19
|
+
let requests = 0;
|
|
20
|
+
let used = 0;
|
|
21
|
+
for (const r of log) {
|
|
22
|
+
if (r.t + WINDOW_MS > at) {
|
|
23
|
+
requests++;
|
|
24
|
+
used += r.tokens;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (rpm !== undefined && requests + 1 > rpm)
|
|
28
|
+
return false;
|
|
29
|
+
if (tpm !== undefined && used > 0 && used + tokens > tpm)
|
|
30
|
+
return false;
|
|
31
|
+
return true;
|
|
32
|
+
};
|
|
33
|
+
/** Earliest time at which the request fits: now, or the moment an entry leaves the window. */
|
|
34
|
+
const waitMs = (tokens) => {
|
|
35
|
+
const t = now();
|
|
36
|
+
if (fits(t, tokens))
|
|
37
|
+
return 0;
|
|
38
|
+
for (const r of log) {
|
|
39
|
+
const at = r.t + WINDOW_MS;
|
|
40
|
+
if (at > t && fits(at, tokens))
|
|
41
|
+
return at - t;
|
|
42
|
+
}
|
|
43
|
+
throw new Error('rate limiter: no admissible time found (internal error)'); // unreachable: the window empties after 60 s
|
|
44
|
+
};
|
|
45
|
+
const take = async (tokens) => {
|
|
46
|
+
for (;;) {
|
|
47
|
+
const wait = waitMs(tokens);
|
|
48
|
+
if (wait <= 0)
|
|
49
|
+
break;
|
|
50
|
+
await sleep(wait);
|
|
51
|
+
}
|
|
52
|
+
const t = now();
|
|
53
|
+
log = log.filter((r) => r.t + WINDOW_MS > t);
|
|
54
|
+
log.push({ t, tokens });
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
acquire(inputTokens) {
|
|
58
|
+
if (rpm === undefined && tpm === undefined)
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
const run = tail.then(() => take(Math.max(0, inputTokens)));
|
|
61
|
+
tail = run.catch(() => undefined);
|
|
62
|
+
return run;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const shared = new WeakMap();
|
|
67
|
+
/**
|
|
68
|
+
* One limiter per options object, so every call that receives the same `rateLimit` object (all documents of an index
|
|
69
|
+
* run, all questions of an eval run) draws from the same budget. Undefined when no limit is set.
|
|
70
|
+
*/
|
|
71
|
+
export function rateLimiterFor(opts) {
|
|
72
|
+
if (!opts)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (!shared.has(opts)) {
|
|
75
|
+
shared.set(opts, opts.requestsPerMinute === undefined && opts.inputTokensPerMinute === undefined ? undefined : createRateLimiter(opts));
|
|
76
|
+
}
|
|
77
|
+
return shared.get(opts);
|
|
78
|
+
}
|
|
79
|
+
/** Estimated input tokens of anything that will be sent to the model (strings as-is, other values as JSON). */
|
|
80
|
+
export const estimateInputTokens = (value) => approxTokens(typeof value === 'string' ? value : JSON.stringify(value) ?? '');
|
|
81
|
+
function parsePositiveInt(v, flag) {
|
|
82
|
+
const n = Number(v);
|
|
83
|
+
if (!Number.isInteger(n) || n < 1)
|
|
84
|
+
throw new Error(`${flag} must be an integer >= 1, got "${v}"`);
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
/** CLI helper: `{ rpm, tpm }` strings from the option flags -> `RateLimitOptions` (undefined when neither is set). */
|
|
88
|
+
export function parseRateLimit(o, rpmFlag, tpmFlag) {
|
|
89
|
+
if (o.rpm === undefined && o.tpm === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
return {
|
|
92
|
+
...(o.rpm !== undefined ? { requestsPerMinute: parsePositiveInt(o.rpm, rpmFlag) } : {}),
|
|
93
|
+
...(o.tpm !== undefined ? { inputTokensPerMinute: parsePositiveInt(o.tpm, tpmFlag) } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type LanguageModel } from 'ai';
|
|
2
|
+
import type { Store } from '../store/db.js';
|
|
3
|
+
import type { TreeNode } from '../types.js';
|
|
4
|
+
import { type RateLimiter, type RateLimitOptions } from './rate-limit.js';
|
|
5
|
+
export interface SummaryOptions {
|
|
6
|
+
model: LanguageModel;
|
|
7
|
+
/** Nodes with `ownTokens` below this use their own text as the summary (default SUMMARY_TOKEN_THRESHOLD). */
|
|
8
|
+
threshold?: number;
|
|
9
|
+
/** Max parallel LLM calls (default 8). */
|
|
10
|
+
concurrency?: number;
|
|
11
|
+
/** Retries per call, handled by the AI SDK (default 3). */
|
|
12
|
+
maxRetries?: number;
|
|
13
|
+
/** Also generate a one-sentence document description (default true). */
|
|
14
|
+
describe?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Client-side rate limit for the summary calls (sliding 60 s window; input tokens are estimated from the prompt).
|
|
17
|
+
* The same options object shares one budget across every document it is passed with.
|
|
18
|
+
*/
|
|
19
|
+
rateLimit?: RateLimitOptions;
|
|
20
|
+
/** Advanced: use this limiter instead of building one from `rateLimit` (e.g. to share a budget or inject a clock). */
|
|
21
|
+
rateLimiter?: RateLimiter;
|
|
22
|
+
}
|
|
23
|
+
export interface SummaryFailure {
|
|
24
|
+
/** Node id, or `null` for the document description. */
|
|
25
|
+
nodeId: string | null;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
export interface SummarizeResult {
|
|
29
|
+
/** LLM calls made (successful or failed), including the description call. */
|
|
30
|
+
llmCalls: number;
|
|
31
|
+
/** Nodes whose summary came from the `summaries` cache. */
|
|
32
|
+
cached: number;
|
|
33
|
+
usage: {
|
|
34
|
+
inputTokens: number;
|
|
35
|
+
outputTokens: number;
|
|
36
|
+
};
|
|
37
|
+
/** Calls that still failed after retries. Successful summaries are persisted regardless. */
|
|
38
|
+
failures: SummaryFailure[];
|
|
39
|
+
}
|
|
40
|
+
/** Outline given to the description prompt is cut to this many chars so it cannot overflow the context. */
|
|
41
|
+
export declare const OUTLINE_MAX_CHARS = 12000;
|
|
42
|
+
/** Port of PageIndex `generate_node_summary`, plus language and length constraints. */
|
|
43
|
+
export declare function nodeSummaryPrompt(text: string): string;
|
|
44
|
+
/** Port of PageIndex `generate_doc_description`, plus a language constraint. */
|
|
45
|
+
export declare function docDescriptionPrompt(outline: string): string;
|
|
46
|
+
/** Titles + truncated summaries, indented by depth, cut to OUTLINE_MAX_CHARS. */
|
|
47
|
+
export declare function buildOutline(tree: TreeNode[], maxChars?: number): string;
|
|
48
|
+
/**
|
|
49
|
+
* Fills node summaries (leaf -> `summary`, non-leaf -> `prefixSummary` of the node's own text only) and, unless
|
|
50
|
+
* `describe` is false and the doc has none yet, the document description. Nodes under the threshold use their own
|
|
51
|
+
* text; others are looked up in the `summaries` cache by sha256 of their own text, then sent to the LLM. A failed
|
|
52
|
+
* call never throws and never touches the store: it is returned in `failures`, everything that succeeded is saved.
|
|
53
|
+
*/
|
|
54
|
+
export declare function summarizeDocument(store: Store, docPath: string, opts: SummaryOptions): Promise<SummarizeResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Dry-run over every indexed document: how many nodes are over the threshold, how many LLM calls a full
|
|
57
|
+
* `summarizeDocument` pass would make (unique uncached node texts + one description per undescribed document unless
|
|
58
|
+
* `describe` is false) and the node-text input tokens (description outlines are not counted).
|
|
59
|
+
*/
|
|
60
|
+
export declare function estimateSummaryCost(store: Store, opts?: {
|
|
61
|
+
threshold?: number;
|
|
62
|
+
describe?: boolean;
|
|
63
|
+
}): {
|
|
64
|
+
nodes: number;
|
|
65
|
+
llmCalls: number;
|
|
66
|
+
inputTokens: number;
|
|
67
|
+
};
|