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