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