dsh-locator 0.7.1 → 0.7.5

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