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
package/dist/store/db.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { segmentForIndex } from '../text/segment.js';
|
|
6
|
+
export class StoreLockedError extends Error {
|
|
7
|
+
lockFile;
|
|
8
|
+
pid;
|
|
9
|
+
constructor(lockFile, pid) {
|
|
10
|
+
super(`index is locked by another writer (pid ${pid ?? 'unknown'}): ${lockFile}. ` +
|
|
11
|
+
'Wait for it to finish, or open the store read-only.');
|
|
12
|
+
this.lockFile = lockFile;
|
|
13
|
+
this.pid = pid;
|
|
14
|
+
this.name = 'StoreLockedError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const SCHEMA_VERSION = 2;
|
|
18
|
+
const LINE_CHUNK = 200;
|
|
19
|
+
const MIGRATIONS = {
|
|
20
|
+
1: `
|
|
21
|
+
CREATE TABLE docs (
|
|
22
|
+
path TEXT PRIMARY KEY,
|
|
23
|
+
title TEXT NOT NULL,
|
|
24
|
+
description TEXT,
|
|
25
|
+
metadata TEXT NOT NULL,
|
|
26
|
+
content_hash TEXT NOT NULL,
|
|
27
|
+
line_count INTEGER NOT NULL,
|
|
28
|
+
tokens INTEGER NOT NULL,
|
|
29
|
+
indexed_at TEXT NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE nodes (
|
|
32
|
+
doc_path TEXT NOT NULL,
|
|
33
|
+
node_id TEXT NOT NULL,
|
|
34
|
+
parent_id TEXT,
|
|
35
|
+
ord INTEGER NOT NULL,
|
|
36
|
+
json TEXT NOT NULL,
|
|
37
|
+
fts_rowid INTEGER NOT NULL,
|
|
38
|
+
PRIMARY KEY (doc_path, node_id)
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE lines (
|
|
41
|
+
doc_path TEXT NOT NULL,
|
|
42
|
+
start_line INTEGER NOT NULL,
|
|
43
|
+
text TEXT NOT NULL,
|
|
44
|
+
PRIMARY KEY (doc_path, start_line)
|
|
45
|
+
);
|
|
46
|
+
CREATE TABLE summaries (
|
|
47
|
+
content_hash TEXT PRIMARY KEY,
|
|
48
|
+
summary TEXT NOT NULL
|
|
49
|
+
);
|
|
50
|
+
CREATE VIRTUAL TABLE fts_words USING fts5(
|
|
51
|
+
title, key_items, summary, body, doc_path UNINDEXED, node_id UNINDEXED,
|
|
52
|
+
tokenize = "unicode61 tokenchars '+#.-_'"
|
|
53
|
+
);
|
|
54
|
+
CREATE VIRTUAL TABLE fts_tri USING fts5(
|
|
55
|
+
title, body, doc_path UNINDEXED, node_id UNINDEXED,
|
|
56
|
+
tokenize = 'trigram'
|
|
57
|
+
);
|
|
58
|
+
`,
|
|
59
|
+
2: `
|
|
60
|
+
CREATE TABLE meta (
|
|
61
|
+
key TEXT PRIMARY KEY,
|
|
62
|
+
value TEXT NOT NULL
|
|
63
|
+
);
|
|
64
|
+
`,
|
|
65
|
+
};
|
|
66
|
+
/** Locks held by this process, released on exit. */
|
|
67
|
+
const heldLocks = new Set();
|
|
68
|
+
let exitHookInstalled = false;
|
|
69
|
+
function releaseLock(lockFile) {
|
|
70
|
+
if (!heldLocks.delete(lockFile))
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
unlinkSync(lockFile);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// already gone
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function pidAlive(pid) {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return e.code === 'EPERM';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** An empty / unparsable lock younger than this is assumed to be held (its writer may be mid-takeover). */
|
|
89
|
+
const UNREADABLE_LOCK_GRACE_MS = 10_000;
|
|
90
|
+
const MAX_LOCK_ATTEMPTS = 5;
|
|
91
|
+
const uniqueSuffix = () => `${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
92
|
+
function parsePid(content) {
|
|
93
|
+
const t = content.trim();
|
|
94
|
+
if (!/^\d+$/.test(t))
|
|
95
|
+
return undefined;
|
|
96
|
+
const n = Number(t);
|
|
97
|
+
return Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Atomically creates `lockFile` with our pid: the lock never exists without its content. */
|
|
100
|
+
function tryLink(lockFile) {
|
|
101
|
+
const tmp = `${lockFile}.${uniqueSuffix()}.tmp`;
|
|
102
|
+
writeFileSync(tmp, `${process.pid}\n`);
|
|
103
|
+
try {
|
|
104
|
+
linkSync(tmp, lockFile);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (e.code === 'EEXIST')
|
|
109
|
+
return false;
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
rmSync(tmp, { force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function holdLock(lockFile) {
|
|
117
|
+
heldLocks.add(lockFile);
|
|
118
|
+
if (!exitHookInstalled) {
|
|
119
|
+
exitHookInstalled = true;
|
|
120
|
+
process.on('exit', () => {
|
|
121
|
+
for (const f of [...heldLocks])
|
|
122
|
+
releaseLock(f);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function acquireLock(lockFile) {
|
|
127
|
+
for (let attempt = 0; attempt < MAX_LOCK_ATTEMPTS; attempt++) {
|
|
128
|
+
if (tryLink(lockFile)) {
|
|
129
|
+
holdLock(lockFile);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let content;
|
|
133
|
+
let mtimeMs;
|
|
134
|
+
try {
|
|
135
|
+
content = readFileSync(lockFile, 'utf8');
|
|
136
|
+
mtimeMs = statSync(lockFile).mtimeMs;
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
if (e.code === 'ENOENT')
|
|
140
|
+
continue; // released meanwhile: retry
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
const pid = parsePid(content);
|
|
144
|
+
if (pid === undefined) {
|
|
145
|
+
if (Date.now() - mtimeMs < UNREADABLE_LOCK_GRACE_MS)
|
|
146
|
+
throw new StoreLockedError(lockFile, undefined);
|
|
147
|
+
}
|
|
148
|
+
else if (pidAlive(pid)) {
|
|
149
|
+
throw new StoreLockedError(lockFile, pid);
|
|
150
|
+
}
|
|
151
|
+
// Stale: move it aside atomically, then confirm we moved the lock we judged (not a fresh one).
|
|
152
|
+
const aside = `${lockFile}.stale.${uniqueSuffix()}`;
|
|
153
|
+
try {
|
|
154
|
+
renameSync(lockFile, aside);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (e.code === 'ENOENT')
|
|
158
|
+
continue; // someone else took it over
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
let movedContent;
|
|
162
|
+
try {
|
|
163
|
+
movedContent = readFileSync(aside, 'utf8');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
movedContent = undefined;
|
|
167
|
+
}
|
|
168
|
+
if (movedContent !== content) {
|
|
169
|
+
// We displaced a lock created after our read: put it back (unless a newer one exists) and back off.
|
|
170
|
+
try {
|
|
171
|
+
linkSync(aside, lockFile);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// a newer lock is already in place
|
|
175
|
+
}
|
|
176
|
+
rmSync(aside, { force: true });
|
|
177
|
+
throw new StoreLockedError(lockFile, movedContent === undefined ? undefined : parsePid(movedContent));
|
|
178
|
+
}
|
|
179
|
+
rmSync(aside, { force: true });
|
|
180
|
+
}
|
|
181
|
+
throw new StoreLockedError(lockFile, undefined);
|
|
182
|
+
}
|
|
183
|
+
function migrate(db) {
|
|
184
|
+
const current = db.pragma('user_version', { simple: true });
|
|
185
|
+
for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
|
|
186
|
+
db.transaction(() => {
|
|
187
|
+
db.exec(MIGRATIONS[v]);
|
|
188
|
+
db.pragma(`user_version = ${v}`);
|
|
189
|
+
})();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Opens (creating if needed) a store file. Write mode takes an exclusive `${file}.lock` holding the pid, created
|
|
194
|
+
* atomically (temp file + link). A lock whose pid is dead, or an empty / unparsable lock older than 10 s, is taken over. Read-only opens take no lock and never block on a writer (WAL).
|
|
195
|
+
*/
|
|
196
|
+
export function openStore(file, opts = {}) {
|
|
197
|
+
if (opts.readonly) {
|
|
198
|
+
const db = new Database(file, { readonly: true, fileMustExist: true });
|
|
199
|
+
return createStore(db, () => { });
|
|
200
|
+
}
|
|
201
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
202
|
+
const lockFile = `${file}.lock`;
|
|
203
|
+
acquireLock(lockFile);
|
|
204
|
+
let db;
|
|
205
|
+
try {
|
|
206
|
+
db = new Database(file);
|
|
207
|
+
db.pragma('journal_mode = WAL');
|
|
208
|
+
db.pragma('synchronous = NORMAL');
|
|
209
|
+
migrate(db);
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
try {
|
|
213
|
+
db?.close();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
releaseLock(lockFile);
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
return createStore(db, () => releaseLock(lockFile));
|
|
221
|
+
}
|
|
222
|
+
/** Stable short id derived from the library-relative path. */
|
|
223
|
+
function docIdFor(path) {
|
|
224
|
+
return createHash('sha1').update(path).digest('hex').slice(0, 12);
|
|
225
|
+
}
|
|
226
|
+
function toRecord(r) {
|
|
227
|
+
const rec = {
|
|
228
|
+
docId: docIdFor(r.path),
|
|
229
|
+
path: r.path,
|
|
230
|
+
title: r.title,
|
|
231
|
+
metadata: JSON.parse(r.metadata),
|
|
232
|
+
contentHash: r.content_hash,
|
|
233
|
+
lineCount: r.line_count,
|
|
234
|
+
tokens: r.tokens,
|
|
235
|
+
indexedAt: r.indexed_at,
|
|
236
|
+
};
|
|
237
|
+
if (r.description !== null)
|
|
238
|
+
rec.description = r.description;
|
|
239
|
+
return rec;
|
|
240
|
+
}
|
|
241
|
+
function createStore(db, unlock) {
|
|
242
|
+
let closed = false;
|
|
243
|
+
// Prepared on first use, so a read-only store never prepares write statements.
|
|
244
|
+
const cache = new Map();
|
|
245
|
+
const stmt = (sql) => {
|
|
246
|
+
let s = cache.get(sql);
|
|
247
|
+
if (!s) {
|
|
248
|
+
s = db.prepare(sql);
|
|
249
|
+
cache.set(sql, s);
|
|
250
|
+
}
|
|
251
|
+
return s;
|
|
252
|
+
};
|
|
253
|
+
const deleteRows = (path) => {
|
|
254
|
+
stmt('DELETE FROM fts_words WHERE rowid IN (SELECT fts_rowid FROM nodes WHERE doc_path = ?)').run(path);
|
|
255
|
+
stmt('DELETE FROM fts_tri WHERE rowid IN (SELECT fts_rowid FROM nodes WHERE doc_path = ?)').run(path);
|
|
256
|
+
stmt('DELETE FROM nodes WHERE doc_path = ?').run(path);
|
|
257
|
+
stmt('DELETE FROM lines WHERE doc_path = ?').run(path);
|
|
258
|
+
stmt('DELETE FROM docs WHERE path = ?').run(path);
|
|
259
|
+
};
|
|
260
|
+
const replaceTx = db.transaction((doc, tree, lines) => {
|
|
261
|
+
const path = doc.path;
|
|
262
|
+
deleteRows(path);
|
|
263
|
+
stmt(`INSERT INTO docs (path, title, description, metadata, content_hash, line_count, tokens, indexed_at)
|
|
264
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(path, doc.title, doc.description ?? null, JSON.stringify(doc.metadata), doc.contentHash, doc.lineCount, doc.tokens, doc.indexedAt);
|
|
265
|
+
for (let i = 0; i < lines.length; i += LINE_CHUNK) {
|
|
266
|
+
stmt('INSERT INTO lines (doc_path, start_line, text) VALUES (?, ?, ?)').run(path, i + 1, lines.slice(i, i + LINE_CHUNK).join('\n'));
|
|
267
|
+
}
|
|
268
|
+
const insertNodes = (nodes, parentId) => {
|
|
269
|
+
nodes.forEach((node, ord) => {
|
|
270
|
+
const { children, ...own } = node;
|
|
271
|
+
const json = JSON.stringify(own);
|
|
272
|
+
const body = lines.slice(node.startLine - 1, node.ownEndLine).join('\n');
|
|
273
|
+
const keyItems = (node.keyItems ?? []).join('\n');
|
|
274
|
+
const info = stmt('INSERT INTO fts_words (title, key_items, summary, body, doc_path, node_id) VALUES (?, ?, ?, ?, ?, ?)').run(segmentForIndex(node.title), segmentForIndex(keyItems), '', segmentForIndex(body), path, node.nodeId);
|
|
275
|
+
stmt('INSERT INTO fts_tri (rowid, title, body, doc_path, node_id) VALUES (?, ?, ?, ?, ?)').run(info.lastInsertRowid, node.title, body, path, node.nodeId);
|
|
276
|
+
stmt('INSERT INTO nodes (doc_path, node_id, parent_id, ord, json, fts_rowid) VALUES (?, ?, ?, ?, ?, ?)').run(path, node.nodeId, parentId, ord, json, info.lastInsertRowid);
|
|
277
|
+
insertNodes(children, node.nodeId);
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
insertNodes(tree, null);
|
|
281
|
+
});
|
|
282
|
+
const updateSummariesTx = db.transaction((docPath, updates) => {
|
|
283
|
+
for (const u of updates) {
|
|
284
|
+
const row = stmt('SELECT json, fts_rowid FROM nodes WHERE doc_path = ? AND node_id = ?').get(docPath, u.nodeId);
|
|
285
|
+
if (!row)
|
|
286
|
+
throw new Error(`node ${u.nodeId} not found in ${docPath}`);
|
|
287
|
+
const node = JSON.parse(row.json);
|
|
288
|
+
if (u.summary !== undefined)
|
|
289
|
+
node.summary = u.summary;
|
|
290
|
+
if (u.prefixSummary !== undefined)
|
|
291
|
+
node.prefixSummary = u.prefixSummary;
|
|
292
|
+
stmt('UPDATE nodes SET json = ? WHERE doc_path = ? AND node_id = ?').run(JSON.stringify(node), docPath, u.nodeId);
|
|
293
|
+
stmt('UPDATE fts_words SET summary = ? WHERE rowid = ?').run(segmentForIndex(node.summary ?? node.prefixSummary ?? ''), row.fts_rowid);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
const deleteTx = db.transaction((path) => deleteRows(path));
|
|
297
|
+
const getDoc = (path) => {
|
|
298
|
+
const row = stmt('SELECT * FROM docs WHERE path = ?').get(path);
|
|
299
|
+
return row && toRecord(row);
|
|
300
|
+
};
|
|
301
|
+
return {
|
|
302
|
+
db,
|
|
303
|
+
getDoc,
|
|
304
|
+
listDocs(folder, offset, limit) {
|
|
305
|
+
const prefix = folder.replace(/^\/+|\/+$/g, '');
|
|
306
|
+
const p = prefix === '' ? '' : `${prefix}/`;
|
|
307
|
+
const paths = stmt('SELECT path FROM docs WHERE substr(path, 1, length(?)) = ? ORDER BY path').all(p, p).map((r) => r.path);
|
|
308
|
+
const folders = new Set();
|
|
309
|
+
const direct = [];
|
|
310
|
+
for (const path of paths) {
|
|
311
|
+
const rest = path.slice(p.length);
|
|
312
|
+
const slash = rest.indexOf('/');
|
|
313
|
+
if (slash === -1)
|
|
314
|
+
direct.push(path);
|
|
315
|
+
else
|
|
316
|
+
folders.add(p + rest.slice(0, slash));
|
|
317
|
+
}
|
|
318
|
+
const page = direct.slice(Math.max(0, offset), Math.max(0, offset) + Math.max(0, limit));
|
|
319
|
+
const docs = page.map((path) => getDoc(path));
|
|
320
|
+
return { folders: [...folders].sort(), docs, total: direct.length };
|
|
321
|
+
},
|
|
322
|
+
allDocPaths() {
|
|
323
|
+
return stmt('SELECT path FROM docs ORDER BY path').all().map((r) => r.path);
|
|
324
|
+
},
|
|
325
|
+
getTree(path) {
|
|
326
|
+
if (!stmt('SELECT 1 FROM docs WHERE path = ?').get(path))
|
|
327
|
+
return undefined;
|
|
328
|
+
const rows = stmt('SELECT node_id, parent_id, ord, json FROM nodes WHERE doc_path = ? ORDER BY ord').all(path);
|
|
329
|
+
const byId = new Map();
|
|
330
|
+
for (const r of rows)
|
|
331
|
+
byId.set(r.node_id, { ...JSON.parse(r.json), children: [] });
|
|
332
|
+
const roots = [];
|
|
333
|
+
for (const r of rows) {
|
|
334
|
+
const node = byId.get(r.node_id);
|
|
335
|
+
const parent = r.parent_id === null ? undefined : byId.get(r.parent_id);
|
|
336
|
+
if (parent)
|
|
337
|
+
parent.children.push(node);
|
|
338
|
+
else
|
|
339
|
+
roots.push(node);
|
|
340
|
+
}
|
|
341
|
+
return roots;
|
|
342
|
+
},
|
|
343
|
+
getLines(path, start, end) {
|
|
344
|
+
const from = Math.max(1, Math.floor(start));
|
|
345
|
+
const to = Math.floor(end);
|
|
346
|
+
if (to < from)
|
|
347
|
+
return [];
|
|
348
|
+
const firstChunk = Math.floor((from - 1) / LINE_CHUNK) * LINE_CHUNK + 1;
|
|
349
|
+
const rows = stmt('SELECT start_line, text FROM lines WHERE doc_path = ? AND start_line >= ? AND start_line <= ? ORDER BY start_line').all(path, firstChunk, to);
|
|
350
|
+
const out = [];
|
|
351
|
+
for (const r of rows) {
|
|
352
|
+
r.text.split('\n').forEach((line, i) => {
|
|
353
|
+
const n = r.start_line + i;
|
|
354
|
+
if (n >= from && n <= to)
|
|
355
|
+
out.push(line);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
},
|
|
360
|
+
replaceDoc(doc, tree, lines) {
|
|
361
|
+
replaceTx(doc, tree, lines);
|
|
362
|
+
},
|
|
363
|
+
deleteDoc(path) {
|
|
364
|
+
deleteTx(path);
|
|
365
|
+
},
|
|
366
|
+
updateNodeSummaries(docPath, updates) {
|
|
367
|
+
updateSummariesTx(docPath, updates);
|
|
368
|
+
},
|
|
369
|
+
setDocDescription(docPath, description) {
|
|
370
|
+
stmt('UPDATE docs SET description = ? WHERE path = ?').run(description, docPath);
|
|
371
|
+
},
|
|
372
|
+
getCachedSummary(contentHash) {
|
|
373
|
+
return stmt('SELECT summary FROM summaries WHERE content_hash = ?').get(contentHash)?.summary;
|
|
374
|
+
},
|
|
375
|
+
putCachedSummary(contentHash, summary) {
|
|
376
|
+
stmt('INSERT OR REPLACE INTO summaries (content_hash, summary) VALUES (?, ?)').run(contentHash, summary);
|
|
377
|
+
},
|
|
378
|
+
getMeta(key) {
|
|
379
|
+
// a read-only store opened on an index written before schema v2 has no meta table
|
|
380
|
+
if (!stmt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get())
|
|
381
|
+
return undefined;
|
|
382
|
+
return stmt('SELECT value FROM meta WHERE key = ?').get(key)?.value;
|
|
383
|
+
},
|
|
384
|
+
setMeta(key, value) {
|
|
385
|
+
stmt('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(key, value);
|
|
386
|
+
},
|
|
387
|
+
close() {
|
|
388
|
+
if (closed)
|
|
389
|
+
return;
|
|
390
|
+
closed = true;
|
|
391
|
+
try {
|
|
392
|
+
db.close();
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
unlock();
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Store } from './db.js';
|
|
2
|
+
export interface SearchHit {
|
|
3
|
+
docPath: string;
|
|
4
|
+
nodeId: string;
|
|
5
|
+
/** Ancestor titles from the top-level section down to the hit node. */
|
|
6
|
+
titlePath: string[];
|
|
7
|
+
startLine: number;
|
|
8
|
+
endLine: number;
|
|
9
|
+
/** <= 300 chars of the node's own original text around the first match. */
|
|
10
|
+
snippet: string;
|
|
11
|
+
/** Higher is better (negated FTS5 bm25). Only comparable within the same `via`. */
|
|
12
|
+
score: number;
|
|
13
|
+
via: 'bm25' | 'trigram';
|
|
14
|
+
}
|
|
15
|
+
export interface SearchOptions {
|
|
16
|
+
/** Restrict to documents under this folder ('' or omitted = whole library). */
|
|
17
|
+
folder?: string;
|
|
18
|
+
/** Restrict to exactly this document; takes precedence over `folder`. */
|
|
19
|
+
docPath?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* BM25 node search with trigram fallback (spec §7). Ranks `fts_words` by bm25(title 10, key_items 5, summary 2,
|
|
24
|
+
* body 1); if that yields fewer than `limit` nodes and the whitespace-stripped query has >= 3 chars, the
|
|
25
|
+
* `fts_tri` substring index tops the list up (deduplicated by doc+node). Never throws on malformed input.
|
|
26
|
+
*/
|
|
27
|
+
export declare function searchNodes(store: Store, query: string, opts?: SearchOptions): SearchHit[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { segmentQuery } from '../text/segment.js';
|
|
2
|
+
const SNIPPET_MAX = 300;
|
|
3
|
+
const DEFAULT_LIMIT = 10;
|
|
4
|
+
/** Own text beyond this many chars is never scanned for the snippet window. */
|
|
5
|
+
const SNIPPET_SCAN_MAX = 20_000;
|
|
6
|
+
function scopeClause(opts) {
|
|
7
|
+
if (opts.docPath !== undefined && opts.docPath !== '')
|
|
8
|
+
return { sql: 'AND f.doc_path = ?', params: [opts.docPath] };
|
|
9
|
+
const folder = (opts.folder ?? '').replace(/^\/+|\/+$/g, '');
|
|
10
|
+
if (folder === '')
|
|
11
|
+
return { sql: '', params: [] };
|
|
12
|
+
const prefix = `${folder}/`;
|
|
13
|
+
return { sql: 'AND substr(f.doc_path, 1, length(?)) = ?', params: [prefix, prefix] };
|
|
14
|
+
}
|
|
15
|
+
/** Quoted tokens of a segmentQuery() expression, un-escaped. */
|
|
16
|
+
function queryTokens(expr) {
|
|
17
|
+
return [...expr.matchAll(/"((?:[^"]|"")*)"/g)].map((m) => m[1].replaceAll('""', '"'));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Snippet from the ORIGINAL own text of the node: a window of <= 300 chars starting a little before the first
|
|
21
|
+
* occurrence of any needle (case-insensitive); falls back to the start of the text, then to the title.
|
|
22
|
+
*/
|
|
23
|
+
function makeSnippet(text, needles, title) {
|
|
24
|
+
const body = text.slice(0, SNIPPET_SCAN_MAX);
|
|
25
|
+
if (body.trim() === '')
|
|
26
|
+
return title.slice(0, SNIPPET_MAX);
|
|
27
|
+
const lower = body.toLowerCase();
|
|
28
|
+
let at = -1;
|
|
29
|
+
for (const n of needles) {
|
|
30
|
+
const i = n === '' ? -1 : lower.indexOf(n.toLowerCase());
|
|
31
|
+
if (i !== -1 && (at === -1 || i < at))
|
|
32
|
+
at = i;
|
|
33
|
+
}
|
|
34
|
+
const from = at === -1 ? 0 : Math.max(0, at - 60);
|
|
35
|
+
return body.slice(from, from + SNIPPET_MAX).trim();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* BM25 node search with trigram fallback (spec §7). Ranks `fts_words` by bm25(title 10, key_items 5, summary 2,
|
|
39
|
+
* body 1); if that yields fewer than `limit` nodes and the whitespace-stripped query has >= 3 chars, the
|
|
40
|
+
* `fts_tri` substring index tops the list up (deduplicated by doc+node). Never throws on malformed input.
|
|
41
|
+
*/
|
|
42
|
+
export function searchNodes(store, query, opts = {}) {
|
|
43
|
+
const limit = Math.max(0, Math.floor(opts.limit ?? DEFAULT_LIMIT));
|
|
44
|
+
if (limit === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const scope = scopeClause(opts);
|
|
47
|
+
const { db } = store;
|
|
48
|
+
const hits = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
const titleCache = new Map();
|
|
51
|
+
const nodeStmt = db.prepare("SELECT json_extract(json, '$.title') AS t, parent_id AS p FROM nodes WHERE doc_path = ? AND node_id = ?");
|
|
52
|
+
const titlePath = (docPath, nodeId) => {
|
|
53
|
+
const out = [];
|
|
54
|
+
let cur = nodeId;
|
|
55
|
+
while (cur !== null) {
|
|
56
|
+
const key = `${docPath}\0${cur}`;
|
|
57
|
+
let e = titleCache.get(key);
|
|
58
|
+
if (!e) {
|
|
59
|
+
const r = nodeStmt.get(docPath, cur);
|
|
60
|
+
if (!r)
|
|
61
|
+
break;
|
|
62
|
+
e = { title: r.t, parent: r.p };
|
|
63
|
+
titleCache.set(key, e);
|
|
64
|
+
}
|
|
65
|
+
out.push(e.title);
|
|
66
|
+
cur = e.parent;
|
|
67
|
+
}
|
|
68
|
+
return out.reverse();
|
|
69
|
+
};
|
|
70
|
+
const push = (r, needles, via) => {
|
|
71
|
+
const key = `${r.doc_path}\0${r.node_id}`;
|
|
72
|
+
if (seen.has(key) || hits.length >= limit)
|
|
73
|
+
return;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
const node = JSON.parse(r.json);
|
|
76
|
+
const own = store.getLines(r.doc_path, node.startLine, node.ownEndLine).join('\n');
|
|
77
|
+
hits.push({
|
|
78
|
+
docPath: r.doc_path,
|
|
79
|
+
nodeId: r.node_id,
|
|
80
|
+
titlePath: titlePath(r.doc_path, r.node_id),
|
|
81
|
+
startLine: node.startLine,
|
|
82
|
+
endLine: node.endLine,
|
|
83
|
+
snippet: makeSnippet(own, needles, node.title),
|
|
84
|
+
score: -r.rank,
|
|
85
|
+
via,
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const run = (table, match, rankExpr) => {
|
|
89
|
+
try {
|
|
90
|
+
return db
|
|
91
|
+
.prepare(`SELECT f.doc_path AS doc_path, f.node_id AS node_id, n.json AS json, ${rankExpr} AS rank
|
|
92
|
+
FROM ${table} f JOIN nodes n ON n.doc_path = f.doc_path AND n.node_id = f.node_id
|
|
93
|
+
WHERE ${table} MATCH ? ${scope.sql}
|
|
94
|
+
ORDER BY rank LIMIT ?`)
|
|
95
|
+
.all(match, ...scope.params, limit);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return []; // a MATCH expression FTS5 refuses is treated as "no results"
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const words = segmentQuery(query);
|
|
102
|
+
if (words !== '') {
|
|
103
|
+
const needles = queryTokens(words);
|
|
104
|
+
for (const r of run('fts_words', words, 'bm25(fts_words, 10.0, 5.0, 2.0, 1.0, 0.0, 0.0)'))
|
|
105
|
+
push(r, needles, 'bm25');
|
|
106
|
+
}
|
|
107
|
+
if (hits.length < limit) {
|
|
108
|
+
const compact = query.replace(/\s+/g, '');
|
|
109
|
+
if ([...compact].length >= 3) {
|
|
110
|
+
const phrase = `"${compact.replaceAll('"', '""')}"`;
|
|
111
|
+
for (const r of run('fts_tri', phrase, 'bm25(fts_tri, 10.0, 1.0, 0.0, 0.0)'))
|
|
112
|
+
push(r, [compact], 'trigram');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return hits;
|
|
116
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Space-separated lowercase tokens, suitable for an FTS5 `unicode61` column. */
|
|
2
|
+
export declare function segmentForIndex(text: string): string;
|
|
3
|
+
/** FTS5 MATCH expression: each token double-quoted, joined with OR (ranked by bm25). Empty if no tokens. */
|
|
4
|
+
export declare function segmentQuery(q: string): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
2
|
+
/**
|
|
3
|
+
* Terms like v2.1, C++, ISO-27001 that the word segmenter would split or truncate. The lookbehind anchors each
|
|
4
|
+
* attempt at the start of an alphanumeric run: without it a long run with no separator backtracks from every
|
|
5
|
+
* position (quadratic). Matches are unchanged, since a match never starts inside a run.
|
|
6
|
+
*/
|
|
7
|
+
const COMPOUND = /(?<![A-Za-z0-9])[A-Za-z0-9]+(?:[.\-_][A-Za-z0-9]+|[+#]+)+/g;
|
|
8
|
+
/** Appends the lowercased word-like segments of `text` to `out` (no spread: spans may hold 10^5+ words). */
|
|
9
|
+
function words(text, out) {
|
|
10
|
+
for (const s of segmenter.segment(text)) {
|
|
11
|
+
if (s.isWordLike)
|
|
12
|
+
out.push(s.segment.toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** NFKC first, so full-width forms (ABC, 27001) index and query like their ASCII forms. */
|
|
16
|
+
function tokenize(raw) {
|
|
17
|
+
const text = raw.normalize('NFKC');
|
|
18
|
+
const out = [];
|
|
19
|
+
let last = 0;
|
|
20
|
+
for (const m of text.matchAll(COMPOUND)) {
|
|
21
|
+
words(text.slice(last, m.index), out);
|
|
22
|
+
out.push(m[0].toLowerCase());
|
|
23
|
+
last = m.index + m[0].length;
|
|
24
|
+
}
|
|
25
|
+
words(text.slice(last), out);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Space-separated lowercase tokens, suitable for an FTS5 `unicode61` column. */
|
|
29
|
+
export function segmentForIndex(text) {
|
|
30
|
+
return tokenize(text).join(' ');
|
|
31
|
+
}
|
|
32
|
+
/** FTS5 MATCH expression: each token double-quoted, joined with OR (ranked by bm25). Empty if no tokens. */
|
|
33
|
+
export function segmentQuery(q) {
|
|
34
|
+
return tokenize(q)
|
|
35
|
+
.map((t) => `"${t.replaceAll('"', '""')}"`)
|
|
36
|
+
.join(' OR ');
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const CJK = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u;
|
|
2
|
+
/** Approximate tokens: each CJK character is 1 token, everything else is chars / 4 rounded up. */
|
|
3
|
+
export const approxTokens = (text) => {
|
|
4
|
+
let cjk = 0;
|
|
5
|
+
let other = 0;
|
|
6
|
+
for (const ch of text) {
|
|
7
|
+
if (CJK.test(ch))
|
|
8
|
+
cjk++;
|
|
9
|
+
else
|
|
10
|
+
other++;
|
|
11
|
+
}
|
|
12
|
+
return cjk + Math.ceil(other / 4);
|
|
13
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export type ToolName = 'browse_documents' | 'get_document' | 'get_document_structure' | 'get_node_content' | 'search_documents';
|
|
3
|
+
/**
|
|
4
|
+
* Tool names, descriptions and input schemas. The descriptions are the agent's usage guide and part of the
|
|
5
|
+
* contract (spec §8): a snapshot test guards them.
|
|
6
|
+
*/
|
|
7
|
+
export declare const TOOL_CONTRACT: {
|
|
8
|
+
readonly browse_documents: {
|
|
9
|
+
readonly description: string;
|
|
10
|
+
readonly input: z.ZodObject<{
|
|
11
|
+
folder: z.ZodDefault<z.ZodString>;
|
|
12
|
+
offset: z.ZodDefault<z.ZodInt>;
|
|
13
|
+
limit: z.ZodDefault<z.ZodInt>;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
};
|
|
16
|
+
readonly get_document: {
|
|
17
|
+
readonly description: string;
|
|
18
|
+
readonly input: z.ZodObject<{
|
|
19
|
+
doc_path: z.ZodString;
|
|
20
|
+
}, z.core.$strict>;
|
|
21
|
+
};
|
|
22
|
+
readonly get_document_structure: {
|
|
23
|
+
readonly description: string;
|
|
24
|
+
readonly input: z.ZodObject<{
|
|
25
|
+
doc_path: z.ZodString;
|
|
26
|
+
node_id: z.ZodOptional<z.ZodString>;
|
|
27
|
+
depth: z.ZodDefault<z.ZodInt>;
|
|
28
|
+
part: z.ZodDefault<z.ZodInt>;
|
|
29
|
+
}, z.core.$strict>;
|
|
30
|
+
};
|
|
31
|
+
readonly get_node_content: {
|
|
32
|
+
readonly description: string;
|
|
33
|
+
readonly input: z.ZodObject<{
|
|
34
|
+
doc_path: z.ZodString;
|
|
35
|
+
node_ids: z.ZodArray<z.ZodString>;
|
|
36
|
+
part: z.ZodDefault<z.ZodInt>;
|
|
37
|
+
}, z.core.$strict>;
|
|
38
|
+
};
|
|
39
|
+
readonly search_documents: {
|
|
40
|
+
readonly description: string;
|
|
41
|
+
readonly input: z.ZodObject<{
|
|
42
|
+
query: z.ZodString;
|
|
43
|
+
folder: z.ZodOptional<z.ZodString>;
|
|
44
|
+
doc_path: z.ZodOptional<z.ZodString>;
|
|
45
|
+
limit: z.ZodDefault<z.ZodInt>;
|
|
46
|
+
}, z.core.$strict>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export type ToolInput<N extends ToolName> = z.output<(typeof TOOL_CONTRACT)[N]['input']>;
|
|
50
|
+
/** System-prompt guidance for an agent using the tools in `TOOL_CONTRACT`. */
|
|
51
|
+
export declare const AGENT_INSTRUCTIONS: string;
|