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.
- package/CHANGELOG.md +24 -0
- package/README-es.md +35 -5
- package/README-hi.md +33 -5
- package/README-pt.md +31 -5
- package/README.en.md +32 -4
- package/README.md +25 -5
- package/client.js +117 -17
- package/cordis.patch.yml +13 -0
- package/lib/config.d.ts +20 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +38 -4
- package/lib/config.js.map +1 -1
- package/lib/content-search-worker.d.ts +8 -0
- package/lib/content-search-worker.d.ts.map +1 -0
- package/lib/content-search-worker.js +54 -0
- package/lib/content-search-worker.js.map +1 -0
- package/lib/content-search.d.ts +15 -0
- package/lib/content-search.d.ts.map +1 -0
- package/lib/content-search.js +38 -0
- package/lib/content-search.js.map +1 -0
- package/lib/content.d.ts +70 -4
- package/lib/content.d.ts.map +1 -1
- package/lib/content.js +420 -77
- package/lib/content.js.map +1 -1
- package/lib/find.d.ts +1 -1
- package/lib/find.d.ts.map +1 -1
- package/lib/find.js +8 -3
- package/lib/find.js.map +1 -1
- package/lib/http.d.ts +1 -1
- package/lib/http.d.ts.map +1 -1
- package/lib/http.js +4 -2
- package/lib/http.js.map +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +102 -27
- package/lib/index.js.map +1 -1
- package/lib/reindex.d.ts +1 -1
- package/lib/reindex.d.ts.map +1 -1
- package/lib/reindex.js +2 -1
- package/lib/reindex.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +58 -4
- package/src/content-search-worker.ts +52 -0
- package/src/content-search.ts +39 -0
- package/src/content.ts +387 -71
- package/src/find.ts +8 -3
- package/src/http.ts +4 -2
- package/src/index.ts +102 -25
- package/src/reindex.ts +2 -1
package/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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
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(
|
|
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) {
|
|
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))
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
return
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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(
|
|
584
|
+
return [join(dshHome(), 'sessions')]
|
|
309
585
|
}
|
|
310
586
|
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
666
|
+
const walk = async (root: string, depth: number): Promise<void> => {
|
|
667
|
+
if (depth > 4) return
|
|
367
668
|
let entries
|
|
368
|
-
try { entries =
|
|
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:
|
|
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
|
-
|
|
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 {
|