dsh-locator 0.7.0 → 0.7.4
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 +30 -0
- package/README-es.md +35 -5
- package/README-hi.md +33 -5
- package/README-pt.md +31 -5
- package/README.en.md +32 -4
- package/README.md +25 -5
- package/client.js +117 -17
- package/cordis.patch.yml +13 -0
- package/lib/config.d.ts +20 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +38 -4
- package/lib/config.js.map +1 -1
- package/lib/content-search-worker.d.ts +8 -0
- package/lib/content-search-worker.d.ts.map +1 -0
- package/lib/content-search-worker.js +54 -0
- package/lib/content-search-worker.js.map +1 -0
- package/lib/content-search.d.ts +15 -0
- package/lib/content-search.d.ts.map +1 -0
- package/lib/content-search.js +38 -0
- package/lib/content-search.js.map +1 -0
- package/lib/content.d.ts +70 -4
- package/lib/content.d.ts.map +1 -1
- package/lib/content.js +423 -77
- package/lib/content.js.map +1 -1
- package/lib/find.d.ts +1 -1
- package/lib/find.d.ts.map +1 -1
- package/lib/find.js +8 -3
- package/lib/find.js.map +1 -1
- package/lib/http.d.ts +1 -1
- package/lib/http.d.ts.map +1 -1
- package/lib/http.js +4 -2
- package/lib/http.js.map +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +102 -27
- package/lib/index.js.map +1 -1
- package/lib/reindex.d.ts +1 -1
- package/lib/reindex.d.ts.map +1 -1
- package/lib/reindex.js +2 -1
- package/lib/reindex.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +58 -4
- package/src/content-search-worker.ts +52 -0
- package/src/content-search.ts +39 -0
- package/src/content.ts +391 -71
- package/src/find.ts +8 -3
- package/src/http.ts +4 -2
- package/src/index.ts +102 -25
- package/src/reindex.ts +2 -1
package/lib/content.js
CHANGED
|
@@ -6,13 +6,38 @@
|
|
|
6
6
|
* `session.v4.jsonl.zstd`, and SQLite's trigram tokenizer gives substring and
|
|
7
7
|
* CJK search. A meta table stores each source's mtime so refresh() only
|
|
8
8
|
* re-reads what changed; rebuild() drops and rebuilds everything.
|
|
9
|
+
*
|
|
10
|
+
* Everything here runs inside the DSH host process, so the build must never
|
|
11
|
+
* monopolise its event loop: file reads and zstd decoding are asynchronous and
|
|
12
|
+
* the loops yield to the scheduler every ~8 ms of work. Session logs are read
|
|
13
|
+
* only once they have settled (an actively written session is skipped until it
|
|
14
|
+
* has been quiet for `activeSessionDelayMs`), and archived sessions are left
|
|
15
|
+
* alone unless `includeArchived` is set.
|
|
9
16
|
* @module dsh-locator/content
|
|
10
17
|
*/
|
|
11
|
-
import { existsSync, mkdirSync,
|
|
18
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
12
20
|
import { homedir } from 'node:os';
|
|
13
21
|
import { basename, dirname, extname, join } from 'node:path';
|
|
14
|
-
import {
|
|
22
|
+
import { promisify } from 'node:util';
|
|
23
|
+
import { zstdDecompress } from 'node:zlib';
|
|
24
|
+
import { scheduler } from 'node:timers/promises';
|
|
15
25
|
import { DatabaseSync } from 'node:sqlite';
|
|
26
|
+
import { randomUUID } from 'node:crypto';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { Worker } from 'node:worker_threads';
|
|
29
|
+
import { searchContent } from "./content-search.js";
|
|
30
|
+
const SCHEMA_VERSION = 2;
|
|
31
|
+
/** Work budget between two event-loop yields; keeps host latency low. */
|
|
32
|
+
const SLICE_MS = 8;
|
|
33
|
+
/** Session bodies are short: a message, not a whole file. */
|
|
34
|
+
const SESSION_BODY_BYTES = 8_000;
|
|
35
|
+
const zstdDecompressAsync = promisify(zstdDecompress);
|
|
36
|
+
// `scheduler.yield` is newer than the oldest supported Node line; fall back to
|
|
37
|
+
// setImmediate so a build on Node 22 still yields instead of throwing.
|
|
38
|
+
const yieldToLoop = typeof scheduler.yield === 'function'
|
|
39
|
+
? () => scheduler.yield()
|
|
40
|
+
: () => new Promise(resolve => { setImmediate(resolve); });
|
|
16
41
|
const TEXT_EXTENSIONS = new Set([
|
|
17
42
|
'.txt', '.md', '.markdown', '.rst', '.log', '.csv', '.tsv', '.json', '.jsonl',
|
|
18
43
|
'.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.xml', '.html', '.htm',
|
|
@@ -28,18 +53,17 @@ export class ContentIndex {
|
|
|
28
53
|
state = { building: false, files: 0, sessions: 0, messages: 0, truncated: false, error: '', updatedAt: 0 };
|
|
29
54
|
generation = 0;
|
|
30
55
|
totalBytes = 0;
|
|
31
|
-
|
|
56
|
+
sliceDeadline = 0;
|
|
57
|
+
worker = null;
|
|
58
|
+
lockHeld = false;
|
|
59
|
+
lockToken = randomUUID();
|
|
60
|
+
workerSeq = 0;
|
|
61
|
+
pending = new Map();
|
|
32
62
|
constructor(rt, options) {
|
|
33
63
|
this.rt = rt;
|
|
34
64
|
this.options = options;
|
|
35
65
|
}
|
|
36
66
|
status() { return { ...this.state }; }
|
|
37
|
-
/** Start periodic incremental refreshes; 0 disables. */
|
|
38
|
-
startAutoRefresh(ms) {
|
|
39
|
-
if (ms <= 0 || this.timer !== undefined)
|
|
40
|
-
return;
|
|
41
|
-
this.timer = setInterval(() => this.refresh(), ms);
|
|
42
|
-
}
|
|
43
67
|
/** Queue a full rebuild; false when one is already running. */
|
|
44
68
|
rebuild() {
|
|
45
69
|
if (this.state.building)
|
|
@@ -55,22 +79,91 @@ export class ContentIndex {
|
|
|
55
79
|
return true;
|
|
56
80
|
}
|
|
57
81
|
close() {
|
|
58
|
-
if (this.timer !== undefined) {
|
|
59
|
-
clearInterval(this.timer);
|
|
60
|
-
this.timer = undefined;
|
|
61
|
-
}
|
|
62
82
|
this.generation++;
|
|
63
83
|
this.db?.close();
|
|
64
84
|
this.db = null;
|
|
65
85
|
this.state.building = false;
|
|
86
|
+
this.stopWorker();
|
|
87
|
+
this.releaseLock();
|
|
66
88
|
}
|
|
67
89
|
/** Build everything from scratch. */
|
|
68
90
|
start() { this.begin('full'); }
|
|
91
|
+
/**
|
|
92
|
+
* Start at plugin boot: reuse an existing database and refresh it
|
|
93
|
+
* incrementally, so a restart does not re-read and re-index gigabytes of
|
|
94
|
+
* text. A missing or incompatible database falls back to a full build.
|
|
95
|
+
*/
|
|
96
|
+
boot() {
|
|
97
|
+
this.begin(existsSync(join(this.options.stateDir, 'content.db')) ? 'incremental' : 'full');
|
|
98
|
+
}
|
|
69
99
|
/** Re-read changed files/sessions; open the DB when this is the first run. */
|
|
70
100
|
refresh() { this.begin(this.db === null ? 'full' : 'incremental'); }
|
|
101
|
+
/**
|
|
102
|
+
* Serialize content builds across plugin instances and DSH processes that
|
|
103
|
+
* share a state directory: a stale lock (dead pid or older than 30 minutes)
|
|
104
|
+
* is taken over, a live one keeps this instance from writing concurrently.
|
|
105
|
+
* @returns whether this instance owns the build lock.
|
|
106
|
+
*/
|
|
107
|
+
acquireLock() {
|
|
108
|
+
if (this.lockHeld)
|
|
109
|
+
return true;
|
|
110
|
+
mkdirSync(this.options.stateDir, { recursive: true });
|
|
111
|
+
const path = join(this.options.stateDir, 'content.lock');
|
|
112
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
113
|
+
try {
|
|
114
|
+
const fd = openSync(path, 'wx');
|
|
115
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now(), token: this.lockToken }));
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
this.lockHeld = true;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
let holder = {};
|
|
122
|
+
try {
|
|
123
|
+
holder = JSON.parse(readFileSync(path, 'utf8'));
|
|
124
|
+
}
|
|
125
|
+
catch { /* unreadable: treat as stale */ }
|
|
126
|
+
const age = Date.now() - Number(holder.startedAt ?? 0);
|
|
127
|
+
let alive = false;
|
|
128
|
+
if (typeof holder.pid === 'number' && age < 30 * 60_000) {
|
|
129
|
+
try {
|
|
130
|
+
process.kill(holder.pid, 0);
|
|
131
|
+
alive = true;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
alive = error.code === 'EPERM';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// A live sibling instance (for example during profile HMR) blocks even
|
|
138
|
+
// when it shares our pid; the per-instance token tells them apart.
|
|
139
|
+
if (alive && holder.token !== this.lockToken)
|
|
140
|
+
return false;
|
|
141
|
+
try {
|
|
142
|
+
rmSync(path, { force: true });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
releaseLock() {
|
|
152
|
+
if (!this.lockHeld)
|
|
153
|
+
return;
|
|
154
|
+
this.lockHeld = false;
|
|
155
|
+
try {
|
|
156
|
+
rmSync(join(this.options.stateDir, 'content.lock'), { force: true });
|
|
157
|
+
}
|
|
158
|
+
catch { /* ignore */ }
|
|
159
|
+
}
|
|
71
160
|
begin(mode) {
|
|
72
161
|
if (this.state.building)
|
|
73
162
|
return;
|
|
163
|
+
if (!this.acquireLock()) {
|
|
164
|
+
this.state.error = 'another dsh-locator content build is running';
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
74
167
|
const generation = ++this.generation;
|
|
75
168
|
this.state.building = true;
|
|
76
169
|
this.state.error = '';
|
|
@@ -81,37 +174,74 @@ export class ContentIndex {
|
|
|
81
174
|
return;
|
|
82
175
|
this.state.error = messageOf(error);
|
|
83
176
|
this.state.building = false;
|
|
177
|
+
}).finally(() => {
|
|
178
|
+
this.releaseLock();
|
|
84
179
|
});
|
|
85
180
|
}
|
|
86
181
|
async full(generation) {
|
|
87
182
|
this.open(true);
|
|
88
183
|
this.totalBytes = 0;
|
|
89
|
-
await this.
|
|
184
|
+
await this.indexSessions(generation);
|
|
90
185
|
if (generation !== this.generation)
|
|
91
186
|
return;
|
|
92
|
-
await this.
|
|
187
|
+
await this.indexFiles(generation);
|
|
93
188
|
if (generation !== this.generation)
|
|
94
189
|
return;
|
|
95
190
|
this.finish();
|
|
96
191
|
}
|
|
97
192
|
async incremental(generation) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
193
|
+
try {
|
|
194
|
+
this.open(false);
|
|
195
|
+
const stored = this.db.prepare('PRAGMA user_version').get()?.user_version ?? 0;
|
|
196
|
+
if (Number(stored) !== SCHEMA_VERSION)
|
|
197
|
+
return await this.full(generation);
|
|
198
|
+
this.totalBytes = this.readTotalBytes();
|
|
199
|
+
await this.refreshFiles(generation);
|
|
200
|
+
if (generation !== this.generation)
|
|
201
|
+
return;
|
|
202
|
+
await this.refreshSessions(generation);
|
|
203
|
+
if (generation !== this.generation)
|
|
204
|
+
return;
|
|
205
|
+
this.finish();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
if (generation !== this.generation)
|
|
209
|
+
return;
|
|
210
|
+
// A corrupt or outdated database must not disable content search for the
|
|
211
|
+
// rest of the session: rebuild it from scratch instead.
|
|
212
|
+
this.db?.close();
|
|
213
|
+
this.db = null;
|
|
214
|
+
await this.full(generation);
|
|
215
|
+
}
|
|
107
216
|
}
|
|
108
217
|
open(fresh) {
|
|
109
218
|
mkdirSync(this.options.stateDir, { recursive: true });
|
|
219
|
+
const dbPath = join(this.options.stateDir, 'content.db');
|
|
220
|
+
let oldFileRemained = false;
|
|
221
|
+
if (fresh) {
|
|
222
|
+
// A full rebuild throws every row away. Dropping the FTS tables in place
|
|
223
|
+
// on a multi-GB database blocks the host for about a second, so recreate
|
|
224
|
+
// the file instead: the worker is stopped and the sidecars go with it.
|
|
225
|
+
this.stopWorker();
|
|
226
|
+
try {
|
|
227
|
+
this.db?.close();
|
|
228
|
+
}
|
|
229
|
+
catch { /* ignore */ }
|
|
230
|
+
this.db = null;
|
|
231
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
232
|
+
try {
|
|
233
|
+
rmSync(dbPath + suffix, { force: true });
|
|
234
|
+
}
|
|
235
|
+
catch { /* Windows keeps a busy file; DROP below still works */ }
|
|
236
|
+
}
|
|
237
|
+
oldFileRemained = existsSync(dbPath);
|
|
238
|
+
}
|
|
110
239
|
this.db?.close();
|
|
111
|
-
this.db = new DatabaseSync(
|
|
240
|
+
this.db = new DatabaseSync(dbPath);
|
|
112
241
|
this.db.exec('PRAGMA journal_mode = WAL');
|
|
113
242
|
this.db.exec('PRAGMA synchronous = OFF');
|
|
114
|
-
if (fresh) {
|
|
243
|
+
if (fresh && oldFileRemained) {
|
|
244
|
+
// Fallback for the rare recreate failure above: still start from empty.
|
|
115
245
|
this.db.exec('DROP TABLE IF EXISTS docs');
|
|
116
246
|
this.db.exec('DROP TABLE IF EXISTS docs_meta');
|
|
117
247
|
this.db.exec('DROP TABLE IF EXISTS sources');
|
|
@@ -122,6 +252,93 @@ export class ContentIndex {
|
|
|
122
252
|
)`);
|
|
123
253
|
this.db.exec('CREATE TABLE IF NOT EXISTS docs_meta (rowid INTEGER PRIMARY KEY, doc_key TEXT UNIQUE, path TEXT, mtime INTEGER, kind TEXT)');
|
|
124
254
|
this.db.exec('CREATE TABLE IF NOT EXISTS sources (path TEXT PRIMARY KEY, mtime INTEGER)');
|
|
255
|
+
if (fresh) {
|
|
256
|
+
this.db.exec('DROP TABLE IF EXISTS content_meta');
|
|
257
|
+
}
|
|
258
|
+
this.db.exec('CREATE TABLE IF NOT EXISTS content_meta (key TEXT PRIMARY KEY, value INTEGER)');
|
|
259
|
+
if (fresh)
|
|
260
|
+
this.db.exec("INSERT OR REPLACE INTO content_meta (key, value) VALUES ('total_bytes', 0)");
|
|
261
|
+
if (fresh)
|
|
262
|
+
this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
263
|
+
this.reopenWorker();
|
|
264
|
+
}
|
|
265
|
+
reopenWorker() {
|
|
266
|
+
if (this.worker === null)
|
|
267
|
+
return;
|
|
268
|
+
this.worker.postMessage({ id: ++this.workerSeq, open: join(this.options.stateDir, 'content.db') });
|
|
269
|
+
}
|
|
270
|
+
stopWorker() {
|
|
271
|
+
const worker = this.worker;
|
|
272
|
+
this.worker = null;
|
|
273
|
+
for (const entry of this.pending.values())
|
|
274
|
+
entry.resolve([]);
|
|
275
|
+
this.pending.clear();
|
|
276
|
+
if (worker !== null)
|
|
277
|
+
void worker.terminate();
|
|
278
|
+
}
|
|
279
|
+
ensureWorker() {
|
|
280
|
+
if (this.options.searchWorker !== true || this.worker !== null)
|
|
281
|
+
return;
|
|
282
|
+
// In a TypeScript test run the compiled worker does not exist next to the
|
|
283
|
+
// source; skip spawning and answer queries in-process instead.
|
|
284
|
+
const workerPath = fileURLToPath(new URL('./content-search-worker.js', import.meta.url));
|
|
285
|
+
if (!existsSync(workerPath))
|
|
286
|
+
return;
|
|
287
|
+
try {
|
|
288
|
+
const worker = new Worker(workerPath, {
|
|
289
|
+
workerData: { dbPath: join(this.options.stateDir, 'content.db') },
|
|
290
|
+
});
|
|
291
|
+
worker.on('message', (message) => {
|
|
292
|
+
const entry = this.pending.get(message.id);
|
|
293
|
+
if (entry === undefined)
|
|
294
|
+
return;
|
|
295
|
+
this.pending.delete(message.id);
|
|
296
|
+
entry.resolve(message.hits ?? []);
|
|
297
|
+
});
|
|
298
|
+
const fail = () => {
|
|
299
|
+
if (this.worker !== worker)
|
|
300
|
+
return;
|
|
301
|
+
this.worker = null;
|
|
302
|
+
for (const entry of this.pending.values())
|
|
303
|
+
entry.reject(new Error('content search worker failed'));
|
|
304
|
+
this.pending.clear();
|
|
305
|
+
};
|
|
306
|
+
worker.on('error', fail);
|
|
307
|
+
worker.on('exit', fail);
|
|
308
|
+
this.worker = worker;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
this.worker = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Body bytes already stored. Reading a persisted counter avoids a full scan
|
|
316
|
+
* of every FTS body (measured ~0.6 s for 1 GB) at the start of each refresh.
|
|
317
|
+
* A database written before the counter existed falls back to one scan.
|
|
318
|
+
*/
|
|
319
|
+
readTotalBytes() {
|
|
320
|
+
const row = this.db.prepare("SELECT value FROM content_meta WHERE key = 'total_bytes'").get();
|
|
321
|
+
if (row?.value !== undefined)
|
|
322
|
+
return Number(row.value);
|
|
323
|
+
const scanned = Number(this.db.prepare('SELECT COALESCE(SUM(length(body)), 0) AS n FROM docs').get().n);
|
|
324
|
+
this.db.prepare("INSERT OR REPLACE INTO content_meta (key, value) VALUES ('total_bytes', ?)").run(scanned);
|
|
325
|
+
return scanned;
|
|
326
|
+
}
|
|
327
|
+
writeTotalBytes() {
|
|
328
|
+
this.db.prepare("INSERT OR REPLACE INTO content_meta (key, value) VALUES ('total_bytes', ?)").run(Math.max(0, Math.round(this.totalBytes)));
|
|
329
|
+
}
|
|
330
|
+
/** Yield to the host event loop once the current ~8 ms work slice is spent. */
|
|
331
|
+
async breathe() {
|
|
332
|
+
const now = performance.now();
|
|
333
|
+
if (now < this.sliceDeadline)
|
|
334
|
+
return;
|
|
335
|
+
this.sliceDeadline = now + SLICE_MS;
|
|
336
|
+
await yieldToLoop();
|
|
337
|
+
}
|
|
338
|
+
/** True when a session log has been quiet long enough to be read safely. */
|
|
339
|
+
settled(mtime) {
|
|
340
|
+
const delay = this.options.activeSessionDelayMs ?? 0;
|
|
341
|
+
return delay <= 0 || Date.now() - mtime >= delay;
|
|
125
342
|
}
|
|
126
343
|
finish() {
|
|
127
344
|
const counts = this.db.prepare("SELECT kind, COUNT(*) AS n FROM docs_meta GROUP BY kind").all();
|
|
@@ -130,6 +347,8 @@ export class ContentIndex {
|
|
|
130
347
|
this.state.sessions = Number(this.db.prepare('SELECT COUNT(*) AS n FROM sources').get().n);
|
|
131
348
|
this.state.building = false;
|
|
132
349
|
this.state.updatedAt = Date.now();
|
|
350
|
+
this.writeTotalBytes();
|
|
351
|
+
this.reopenWorker();
|
|
133
352
|
}
|
|
134
353
|
addDoc(row) {
|
|
135
354
|
const cap = this.options.maxTotalBytes ?? 0;
|
|
@@ -148,14 +367,19 @@ export class ContentIndex {
|
|
|
148
367
|
const row = this.db.prepare('SELECT rowid FROM docs_meta WHERE doc_key = ?').get(key);
|
|
149
368
|
if (row === undefined)
|
|
150
369
|
return;
|
|
370
|
+
const body = this.db.prepare('SELECT length(body) AS n FROM docs WHERE rowid = ?').get(row.rowid);
|
|
371
|
+
if (body !== undefined)
|
|
372
|
+
this.totalBytes = Math.max(0, this.totalBytes - Number(body.n));
|
|
151
373
|
this.db.prepare('DELETE FROM docs WHERE rowid = ?').run(row.rowid);
|
|
152
374
|
this.db.prepare('DELETE FROM docs_meta WHERE rowid = ?').run(row.rowid);
|
|
153
375
|
}
|
|
154
376
|
deletePath(path) {
|
|
155
|
-
const rows = this.db.prepare(
|
|
377
|
+
const rows = this.db.prepare(`SELECT m.rowid AS rowid, length(d.body) AS n
|
|
378
|
+
FROM docs_meta m JOIN docs d ON d.rowid = m.rowid WHERE m.path = ?`).all(path);
|
|
156
379
|
const delDoc = this.db.prepare('DELETE FROM docs WHERE rowid = ?');
|
|
157
380
|
const delMeta = this.db.prepare('DELETE FROM docs_meta WHERE rowid = ?');
|
|
158
381
|
for (const row of rows) {
|
|
382
|
+
this.totalBytes = Math.max(0, this.totalBytes - Number(row.n));
|
|
159
383
|
delDoc.run(row.rowid);
|
|
160
384
|
delMeta.run(row.rowid);
|
|
161
385
|
}
|
|
@@ -163,23 +387,29 @@ export class ContentIndex {
|
|
|
163
387
|
async indexFiles(generation) {
|
|
164
388
|
let after = 0;
|
|
165
389
|
let seen = 0;
|
|
390
|
+
let scanned = 0;
|
|
166
391
|
for (;;) {
|
|
167
392
|
if (generation !== this.generation)
|
|
168
393
|
return;
|
|
169
394
|
const page = await this.rt.dump(after, 2_000);
|
|
170
395
|
for (const entry of page.entries) {
|
|
396
|
+
if ((++scanned & 0xff) === 0)
|
|
397
|
+
await this.breathe();
|
|
398
|
+
if (generation !== this.generation)
|
|
399
|
+
return;
|
|
171
400
|
if (entry.dir || entry.size <= 0 || entry.size > this.options.maxFileBytes)
|
|
172
401
|
continue;
|
|
173
|
-
if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase()))
|
|
402
|
+
if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase()) || contentSkipPath(entry.path, this.options.excludes ?? []))
|
|
174
403
|
continue;
|
|
175
404
|
if (this.options.maxFiles > 0 && seen >= this.options.maxFiles)
|
|
176
405
|
break;
|
|
177
|
-
const row = this.readFileDoc(entry.path, entry.name, entry.mtime);
|
|
406
|
+
const row = await this.readFileDoc(entry.path, entry.name, entry.mtime);
|
|
178
407
|
if (row === null)
|
|
179
408
|
continue;
|
|
180
409
|
seen++;
|
|
181
410
|
if (!this.addDoc(row))
|
|
182
411
|
return;
|
|
412
|
+
await this.breathe();
|
|
183
413
|
}
|
|
184
414
|
after = page.next;
|
|
185
415
|
if (page.done || page.entries.length === 0)
|
|
@@ -191,16 +421,22 @@ export class ContentIndex {
|
|
|
191
421
|
for (const row of this.db.prepare("SELECT doc_key, mtime FROM docs_meta WHERE kind = 'file'").all())
|
|
192
422
|
meta.set(row.doc_key, row.mtime);
|
|
193
423
|
const seen = new Set();
|
|
424
|
+
const deferred = [];
|
|
194
425
|
let after = 0;
|
|
195
426
|
let indexed = 0;
|
|
427
|
+
let scanned = 0;
|
|
196
428
|
for (;;) {
|
|
197
429
|
if (generation !== this.generation)
|
|
198
430
|
return;
|
|
199
431
|
const page = await this.rt.dump(after, 2_000);
|
|
200
432
|
for (const entry of page.entries) {
|
|
433
|
+
if ((++scanned & 0xff) === 0)
|
|
434
|
+
await this.breathe();
|
|
435
|
+
if (generation !== this.generation)
|
|
436
|
+
return;
|
|
201
437
|
if (entry.dir || entry.size <= 0 || entry.size > this.options.maxFileBytes)
|
|
202
438
|
continue;
|
|
203
|
-
if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase()))
|
|
439
|
+
if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase()) || contentSkipPath(entry.path, this.options.excludes ?? []))
|
|
204
440
|
continue;
|
|
205
441
|
if (this.options.maxFiles > 0 && indexed >= this.options.maxFiles)
|
|
206
442
|
break;
|
|
@@ -209,58 +445,95 @@ export class ContentIndex {
|
|
|
209
445
|
indexed++;
|
|
210
446
|
if (meta.get(key) === entry.mtime)
|
|
211
447
|
continue;
|
|
448
|
+
if (this.wouldExceed(entry.size)) {
|
|
449
|
+
deferred.push({ path: entry.path, name: entry.name, mtime: entry.mtime, size: entry.size });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
212
452
|
this.deleteDoc(key);
|
|
213
|
-
const row = this.readFileDoc(entry.path, entry.name, entry.mtime);
|
|
214
|
-
if (row !== null && !this.addDoc(row))
|
|
215
|
-
|
|
453
|
+
const row = await this.readFileDoc(entry.path, entry.name, entry.mtime);
|
|
454
|
+
if (row !== null && !this.addDoc(row)) {
|
|
455
|
+
deferred.push({ path: entry.path, name: entry.name, mtime: entry.mtime, size: entry.size });
|
|
456
|
+
}
|
|
457
|
+
await this.breathe();
|
|
216
458
|
}
|
|
217
459
|
after = page.next;
|
|
218
460
|
if (page.done || page.entries.length === 0)
|
|
219
461
|
break;
|
|
220
462
|
}
|
|
463
|
+
// Removing rows for files that disappeared frees budget before the deferred
|
|
464
|
+
// files are retried; otherwise a stale large entry would block new ones.
|
|
221
465
|
for (const key of meta.keys())
|
|
222
466
|
if (!seen.has(key))
|
|
223
467
|
this.deleteDoc(key);
|
|
468
|
+
for (const entry of deferred) {
|
|
469
|
+
if (generation !== this.generation)
|
|
470
|
+
return;
|
|
471
|
+
if (this.wouldExceed(entry.size)) {
|
|
472
|
+
this.state.truncated = true;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
this.deleteDoc(`file:${entry.path}`);
|
|
476
|
+
const row = await this.readFileDoc(entry.path, entry.name, entry.mtime);
|
|
477
|
+
if (row === null || !this.addDoc(row)) {
|
|
478
|
+
this.state.truncated = true;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
await this.breathe();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Would storing a file of this size overflow the configured body budget? */
|
|
485
|
+
wouldExceed(size) {
|
|
486
|
+
const cap = this.options.maxTotalBytes ?? 0;
|
|
487
|
+
if (cap <= 0)
|
|
488
|
+
return false;
|
|
489
|
+
return this.totalBytes + Math.min(size, this.options.maxFileBytes) > cap;
|
|
224
490
|
}
|
|
225
|
-
readFileDoc(path, name, mtime) {
|
|
226
|
-
let
|
|
491
|
+
async readFileDoc(path, name, mtime) {
|
|
492
|
+
let buffer;
|
|
227
493
|
try {
|
|
228
|
-
|
|
494
|
+
buffer = await readFile(path);
|
|
229
495
|
}
|
|
230
496
|
catch {
|
|
231
497
|
return null;
|
|
232
498
|
}
|
|
233
|
-
if (
|
|
499
|
+
if (buffer.includes(0))
|
|
234
500
|
return null;
|
|
235
|
-
|
|
501
|
+
const body = buffer.toString('utf8').slice(0, this.options.maxFileBytes);
|
|
502
|
+
return { key: `file:${path}`, body, kind: 'file', path, title: name, sessionId: null, messageId: null, mtime };
|
|
236
503
|
}
|
|
237
504
|
async indexSessions(generation) {
|
|
238
|
-
const files =
|
|
505
|
+
const files = (await this.sessionCandidates()).filter(file => this.settled(file.mtime));
|
|
239
506
|
for (const file of files) {
|
|
240
507
|
if (generation !== this.generation)
|
|
241
508
|
return;
|
|
242
509
|
this.deletePath(file.path);
|
|
243
|
-
if (!this.indexSessionFile(file))
|
|
510
|
+
if (!(await this.indexSessionFile(file, generation)))
|
|
244
511
|
return;
|
|
245
512
|
this.db.prepare('INSERT OR REPLACE INTO sources (path, mtime) VALUES (?, ?)').run(file.path, file.mtime);
|
|
513
|
+
await this.breathe();
|
|
246
514
|
}
|
|
247
515
|
}
|
|
248
516
|
async refreshSessions(generation) {
|
|
249
517
|
const known = new Map();
|
|
250
518
|
for (const row of this.db.prepare('SELECT path, mtime FROM sources').all())
|
|
251
519
|
known.set(row.path, row.mtime);
|
|
252
|
-
const files =
|
|
520
|
+
const files = await this.sessionCandidates();
|
|
253
521
|
const present = new Set();
|
|
254
522
|
for (const file of files) {
|
|
255
523
|
if (generation !== this.generation)
|
|
256
524
|
return;
|
|
257
525
|
present.add(file.path);
|
|
526
|
+
// The session being written right now is left for a later refresh: keep
|
|
527
|
+
// its existing rows instead of re-reading the whole growing log.
|
|
528
|
+
if (!this.settled(file.mtime))
|
|
529
|
+
continue;
|
|
258
530
|
if (known.get(file.path) === file.mtime)
|
|
259
531
|
continue;
|
|
260
532
|
this.deletePath(file.path);
|
|
261
|
-
if (!this.indexSessionFile(file))
|
|
533
|
+
if (!(await this.indexSessionFile(file, generation)))
|
|
262
534
|
return;
|
|
263
535
|
this.db.prepare('INSERT OR REPLACE INTO sources (path, mtime) VALUES (?, ?)').run(file.path, file.mtime);
|
|
536
|
+
await this.breathe();
|
|
264
537
|
}
|
|
265
538
|
for (const path of known.keys()) {
|
|
266
539
|
if (present.has(path))
|
|
@@ -269,19 +542,27 @@ export class ContentIndex {
|
|
|
269
542
|
this.db.prepare('DELETE FROM sources WHERE path = ?').run(path);
|
|
270
543
|
}
|
|
271
544
|
}
|
|
272
|
-
indexSessionFile(file) {
|
|
545
|
+
async indexSessionFile(file, generation) {
|
|
273
546
|
let text;
|
|
274
547
|
try {
|
|
275
|
-
text =
|
|
548
|
+
text = (await zstdDecompressAsync(await readFile(file.path))).toString('utf8');
|
|
276
549
|
}
|
|
277
550
|
catch {
|
|
278
551
|
return true;
|
|
279
552
|
}
|
|
553
|
+
if (generation !== this.generation)
|
|
554
|
+
return false;
|
|
280
555
|
const sessionId = basename(dirname(file.path));
|
|
281
556
|
let title = '';
|
|
557
|
+
let parsed = 0;
|
|
282
558
|
for (const line of text.split('\n')) {
|
|
283
559
|
if (!line)
|
|
284
560
|
continue;
|
|
561
|
+
if ((++parsed & 0xff) === 0) {
|
|
562
|
+
await this.breathe();
|
|
563
|
+
if (generation !== this.generation)
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
285
566
|
let event;
|
|
286
567
|
try {
|
|
287
568
|
event = JSON.parse(line);
|
|
@@ -294,46 +575,73 @@ export class ContentIndex {
|
|
|
294
575
|
continue;
|
|
295
576
|
if (title === '')
|
|
296
577
|
title = extracted.text.replace(/\s+/g, ' ').slice(0, 90);
|
|
297
|
-
const body = extracted.text.slice(0,
|
|
578
|
+
const body = extracted.text.slice(0, SESSION_BODY_BYTES);
|
|
298
579
|
const row = { key: `session:${sessionId}:${extracted.id}`, body, kind: 'session', path: file.path, title: title || sessionId, sessionId, messageId: extracted.id, mtime: file.mtime };
|
|
299
580
|
if (!this.addDoc(row))
|
|
300
581
|
return false;
|
|
301
582
|
}
|
|
302
583
|
return true;
|
|
303
584
|
}
|
|
585
|
+
/** Session logs to consider, newest first, minus archived ones by default. */
|
|
586
|
+
async sessionCandidates() {
|
|
587
|
+
const files = await sessionFiles(this.sessionRoots());
|
|
588
|
+
const archived = this.options.includeArchived === true ? new Set() : await archivedSessionIds();
|
|
589
|
+
const max = this.options.maxSessions > 0 ? this.options.maxSessions : Number.POSITIVE_INFINITY;
|
|
590
|
+
const out = [];
|
|
591
|
+
for (const file of files) {
|
|
592
|
+
if (out.length >= max)
|
|
593
|
+
break;
|
|
594
|
+
if (archived.has(basename(dirname(file.path))))
|
|
595
|
+
continue;
|
|
596
|
+
out.push(file);
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
304
600
|
sessionRoots() {
|
|
305
601
|
if (this.options.sessionDirs !== undefined)
|
|
306
602
|
return this.options.sessionDirs;
|
|
307
|
-
return [join(
|
|
603
|
+
return [join(dshHome(), 'sessions')];
|
|
308
604
|
}
|
|
309
|
-
|
|
605
|
+
/**
|
|
606
|
+
* Search file bodies and session messages. With `searchWorker` enabled the
|
|
607
|
+
* query itself runs off the host thread; a failed or wedged worker falls back
|
|
608
|
+
* to the in-process query so a result is still produced.
|
|
609
|
+
* @param query - words or a phrase to find.
|
|
610
|
+
* @param limit - maximum hits.
|
|
611
|
+
* @returns matching documents ordered by relevance.
|
|
612
|
+
*/
|
|
613
|
+
async search(query, limit) {
|
|
310
614
|
if (this.db === null)
|
|
311
615
|
return [];
|
|
312
|
-
|
|
313
|
-
|
|
616
|
+
if (this.options.searchWorker !== true)
|
|
617
|
+
return this.searchSync(query, limit);
|
|
618
|
+
this.ensureWorker();
|
|
619
|
+
if (this.worker === null)
|
|
620
|
+
return this.searchSync(query, limit);
|
|
621
|
+
const id = ++this.workerSeq;
|
|
622
|
+
const worker = this.worker;
|
|
623
|
+
const response = new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); });
|
|
624
|
+
worker.postMessage({ id, query, limit });
|
|
625
|
+
let timer;
|
|
626
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
627
|
+
timer = setTimeout(() => { this.pending.delete(id); reject(new Error('content search worker timed out')); }, 5_000);
|
|
628
|
+
});
|
|
629
|
+
try {
|
|
630
|
+
return await Promise.race([response, timeout]);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return this.searchSync(query, limit);
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
if (timer !== undefined)
|
|
637
|
+
clearTimeout(timer);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
searchSync(query, limit) {
|
|
641
|
+
if (this.db === null)
|
|
314
642
|
return [];
|
|
315
|
-
const capped = Math.max(1, Math.min(100, limit));
|
|
316
643
|
try {
|
|
317
|
-
|
|
318
|
-
if ([...text].length < 3) {
|
|
319
|
-
rows = this.db.prepare(`SELECT kind, path, title, session_id, message_id, body, 0 AS score
|
|
320
|
-
FROM docs WHERE body LIKE '%' || ? || '%' LIMIT ?`).all(text, capped);
|
|
321
|
-
}
|
|
322
|
-
else {
|
|
323
|
-
const match = text.split(/\s+/).filter(Boolean).map(term => '"' + term.replace(/"/g, '') + '"').join(' AND ');
|
|
324
|
-
rows = this.db.prepare(`SELECT kind, path, title, session_id, message_id,
|
|
325
|
-
snippet(docs, 0, char(1), char(2), '…', 16) AS snippet, bm25(docs) AS score
|
|
326
|
-
FROM docs WHERE docs MATCH ? ORDER BY score LIMIT ?`).all(match, capped);
|
|
327
|
-
}
|
|
328
|
-
return rows.map(row => ({
|
|
329
|
-
kind: row.kind === 'session' ? 'session' : 'file',
|
|
330
|
-
path: String(row.path ?? ''),
|
|
331
|
-
title: String(row.title ?? ''),
|
|
332
|
-
snippet: String(row.snippet ?? row.body ?? '').replace(/[\u0001\u0002]/g, ''),
|
|
333
|
-
...(row.session_id ? { sessionId: String(row.session_id) } : {}),
|
|
334
|
-
...(row.message_id ? { messageId: String(row.message_id) } : {}),
|
|
335
|
-
score: Number(row.score ?? 0),
|
|
336
|
-
}));
|
|
644
|
+
return searchContent(this.db, query, limit);
|
|
337
645
|
}
|
|
338
646
|
catch (error) {
|
|
339
647
|
this.state.error = messageOf(error);
|
|
@@ -341,6 +649,21 @@ export class ContentIndex {
|
|
|
341
649
|
}
|
|
342
650
|
}
|
|
343
651
|
}
|
|
652
|
+
function dshHome() {
|
|
653
|
+
return process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
654
|
+
}
|
|
655
|
+
/** Archived session ids recorded by the Workspace registry; empty when absent. */
|
|
656
|
+
async function archivedSessionIds() {
|
|
657
|
+
try {
|
|
658
|
+
const raw = await readFile(join(dshHome(), 'storages', 'workspace.json'), 'utf8');
|
|
659
|
+
const parsed = JSON.parse(raw);
|
|
660
|
+
const ids = parsed.global?.archivedSessionIds;
|
|
661
|
+
return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : []);
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
return new Set();
|
|
665
|
+
}
|
|
666
|
+
}
|
|
344
667
|
function messageText(event) {
|
|
345
668
|
if (event.type !== 'user/message' && event.type !== 'assistant/message')
|
|
346
669
|
return undefined;
|
|
@@ -364,14 +687,14 @@ function messageText(event) {
|
|
|
364
687
|
const id = event.type === 'user/message' ? String(data.id ?? '') : String(message.id ?? '');
|
|
365
688
|
return { text, id: id || `seq-${String(data.seq ?? '')}` };
|
|
366
689
|
}
|
|
367
|
-
function sessionFiles(roots) {
|
|
690
|
+
async function sessionFiles(roots) {
|
|
368
691
|
const out = [];
|
|
369
|
-
const walk = (root, depth) => {
|
|
370
|
-
if (depth > 4
|
|
692
|
+
const walk = async (root, depth) => {
|
|
693
|
+
if (depth > 4)
|
|
371
694
|
return;
|
|
372
695
|
let entries;
|
|
373
696
|
try {
|
|
374
|
-
entries =
|
|
697
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
375
698
|
}
|
|
376
699
|
catch {
|
|
377
700
|
return;
|
|
@@ -379,22 +702,45 @@ function sessionFiles(roots) {
|
|
|
379
702
|
for (const entry of entries) {
|
|
380
703
|
const path = join(root, entry.name);
|
|
381
704
|
if (entry.isDirectory()) {
|
|
382
|
-
walk(path, depth + 1);
|
|
705
|
+
await walk(path, depth + 1);
|
|
383
706
|
continue;
|
|
384
707
|
}
|
|
385
708
|
if (!entry.isFile() || !entry.name.endsWith('.v4.jsonl.zstd'))
|
|
386
709
|
continue;
|
|
387
710
|
try {
|
|
388
|
-
out.push({ path, mtime:
|
|
711
|
+
out.push({ path, mtime: (await stat(path)).mtimeMs });
|
|
389
712
|
}
|
|
390
713
|
catch { /* ignore */ }
|
|
391
714
|
}
|
|
392
715
|
};
|
|
393
716
|
for (const root of roots)
|
|
394
|
-
walk(root, 0);
|
|
717
|
+
await walk(root, 0);
|
|
395
718
|
out.sort((a, b) => b.mtime - a.mtime);
|
|
396
719
|
return out;
|
|
397
720
|
}
|
|
721
|
+
function contentSkipPath(path, excludes) {
|
|
722
|
+
const normalized = path.replace(/\\/g, '/');
|
|
723
|
+
// The plugin's own session/config tree is never content.
|
|
724
|
+
if (normalized === '/.dsh' || normalized.includes('/.dsh/'))
|
|
725
|
+
return true;
|
|
726
|
+
if (excludes.length === 0)
|
|
727
|
+
return false;
|
|
728
|
+
const lower = normalized.toLowerCase();
|
|
729
|
+
const segments = lower.split('/');
|
|
730
|
+
for (const raw of excludes) {
|
|
731
|
+
const needle = raw.trim().toLowerCase();
|
|
732
|
+
if (needle === '')
|
|
733
|
+
continue;
|
|
734
|
+
if (needle.includes('/')) {
|
|
735
|
+
if (lower.includes(needle))
|
|
736
|
+
return true;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (segments.includes(needle))
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
398
744
|
function messageOf(error) {
|
|
399
745
|
return error instanceof Error ? error.message : String(error);
|
|
400
746
|
}
|