dsh-locator 0.7.1 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-es.md +35 -5
  3. package/README-hi.md +33 -5
  4. package/README-pt.md +31 -5
  5. package/README.en.md +32 -4
  6. package/README.md +25 -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 +38 -0
  20. package/lib/content-search.js.map +1 -0
  21. package/lib/content.d.ts +70 -4
  22. package/lib/content.d.ts.map +1 -1
  23. package/lib/content.js +420 -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 +102 -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 +39 -0
  45. package/src/content.ts +387 -71
  46. package/src/find.ts +8 -3
  47. package/src/http.ts +4 -2
  48. package/src/index.ts +102 -25
  49. 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, readdirSync, readFileSync, statSync } from 'node:fs';
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 { 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
+ /** 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
- timer;
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,6 +174,8 @@ 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) {
@@ -95,23 +190,58 @@ export class ContentIndex {
95
190
  this.finish();
96
191
  }
97
192
  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();
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(join(this.options.stateDir, 'content.db'));
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('SELECT rowid FROM docs_meta WHERE path = ?').all(path);
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()) || contentSkipPath(entry.path))
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()) || contentSkipPath(entry.path))
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
- return;
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 raw;
491
+ async readFileDoc(path, name, mtime) {
492
+ let buffer;
227
493
  try {
228
- raw = readFileSync(path, 'utf8');
494
+ buffer = await readFile(path);
229
495
  }
230
496
  catch {
231
497
  return null;
232
498
  }
233
- if (raw.includes('\u0000'))
499
+ if (buffer.includes(0))
234
500
  return null;
235
- return { key: `file:${path}`, body: raw.slice(0, this.options.maxFileBytes), kind: 'file', path, title: name, sessionId: null, messageId: null, mtime };
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 = sessionFiles(this.sessionRoots()).slice(0, this.options.maxSessions > 0 ? this.options.maxSessions : undefined);
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 = sessionFiles(this.sessionRoots()).slice(0, this.options.maxSessions > 0 ? this.options.maxSessions : undefined);
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 = zstdDecompressSync(readFileSync(file.path)).toString('utf8');
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, 8_000);
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(process.env.DSH_HOME || join(homedir(), '.dsh'), 'sessions')];
603
+ return [join(dshHome(), 'sessions')];
308
604
  }
309
- search(query, limit) {
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
- const text = query.trim();
313
- if (text === '')
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
- 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
- }));
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 || !existsSync(root))
692
+ const walk = async (root, depth) => {
693
+ if (depth > 4)
371
694
  return;
372
695
  let entries;
373
696
  try {
374
- entries = readdirSync(root, { withFileTypes: true });
697
+ entries = await readdir(root, { withFileTypes: true });
375
698
  }
376
699
  catch {
377
700
  return;
@@ -379,24 +702,44 @@ 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: statSync(path).mtimeMs });
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
  }
398
- function contentSkipPath(path) {
399
- return path.includes('/.dsh/') || path.includes('\\.dsh\\');
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;
400
743
  }
401
744
  function messageOf(error) {
402
745
  return error instanceof Error ? error.message : String(error);