dsh-locator 0.6.3 → 0.7.1

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/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import { Config as ConfigSchema } from "./config.js";
7
7
  import { ContentIndex } from "./content.js";
8
8
  import { makeExportTool } from "./export.js";
9
9
  import { makeFindTool } from "./find.js";
10
+ import { makeReindexTool } from "./reindex.js";
10
11
  import { registerHttp } from "./http.js";
11
12
  import { resolveBinary, resolveRoots, resolveStateDir } from "./paths.js";
12
13
  import { LocatorRuntime } from "./runtime.js";
@@ -33,7 +34,7 @@ export function apply(ctx, config) {
33
34
  const content = config.contentIndex
34
35
  ? new ContentIndex(rt, {
35
36
  stateDir,
36
- sessionDirs: config.contentSessionDirs,
37
+ sessionDirs: config.contentSessionDirs.length > 0 ? config.contentSessionDirs : undefined,
37
38
  maxFileBytes: config.contentMaxFileBytes,
38
39
  maxTotalBytes: config.contentMaxTotalBytes,
39
40
  maxFiles: config.contentMaxFiles,
@@ -46,7 +47,7 @@ export function apply(ctx, config) {
46
47
  rt.start();
47
48
  logger.info(`index roots: ${roots.join(', ')} (state ${stateDir})`);
48
49
  if (content !== undefined)
49
- void startContent(rt, content, logger);
50
+ void startContent(rt, content, logger, config.contentRefreshMs);
50
51
  }, 1500);
51
52
  return () => {
52
53
  clearTimeout(timer);
@@ -58,13 +59,15 @@ export function apply(ctx, config) {
58
59
  ctx.tools.register(makeStatusTool(rt));
59
60
  ctx.tools.register(makeRebuildTool(rt));
60
61
  ctx.tools.register(makeExportTool(rt));
61
- if (content !== undefined)
62
+ if (content !== undefined) {
62
63
  ctx.tools.register(makeFindTool(content));
64
+ ctx.tools.register(makeReindexTool(content));
65
+ }
63
66
  // The Web GUI panel is optional: profiles without a Web server keep the
64
67
  // three tools and never mount an HTTP route.
65
68
  registerHttp(ctx, rt, content);
66
69
  }
67
- async function startContent(rt, content, logger) {
70
+ async function startContent(rt, content, logger, refreshMs) {
68
71
  try {
69
72
  await rt.waitReady();
70
73
  for (let i = 0; i < 900; i++) {
@@ -74,6 +77,7 @@ async function startContent(rt, content, logger) {
74
77
  await new Promise(resolve => setTimeout(resolve, 2_000));
75
78
  }
76
79
  content.start();
80
+ content.startAutoRefresh(refreshMs);
77
81
  logger.info('content index started');
78
82
  }
79
83
  catch (error) {
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { ContentIndex } from './content.ts';
2
+ export declare function makeReindexTool(content: ContentIndex): import("@deepseek-ai/dsh-tools").ToolDefinition;
3
+ //# sourceMappingURL=reindex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reindex.d.ts","sourceRoot":"","sources":["../src/reindex.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAEhD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,mDAkCpD"}
package/lib/reindex.js ADDED
@@ -0,0 +1,39 @@
1
+ /** locator_reindex — full or incremental content-index maintenance. */
2
+ import { defineTool } from '@deepseek-ai/dsh-tools';
3
+ export function makeReindexTool(content) {
4
+ return defineTool({
5
+ name: 'locator_reindex',
6
+ description: [
7
+ 'Rebuild or refresh the dsh-locator content index (file bodies + DSH session messages).',
8
+ '',
9
+ '`full: true` drops the content database and re-reads everything; `full: false` (default) only re-reads files/sessions whose mtime changed. Name/path search keeps working during either mode. Use `locator_status` or the content state in `locator_find` to watch progress.',
10
+ ].join('\n'),
11
+ parameters: {
12
+ full: { type: 'boolean', description: 'Drop and rebuild the content database (default false = incremental).' },
13
+ },
14
+ output: {
15
+ schema: {
16
+ type: 'object',
17
+ additionalProperties: false,
18
+ properties: {
19
+ ok: { type: 'boolean', required: true },
20
+ started: { type: 'boolean' },
21
+ full: { type: 'boolean' },
22
+ error: { type: 'string' },
23
+ },
24
+ },
25
+ render: (_args, value) => {
26
+ const v = value;
27
+ return [{ type: 'text', text: v.ok ? `${v.full ? 'Full' : 'Incremental'} content rebuild started.` : `locator_reindex failed: ${v.error ?? 'unknown'}` }];
28
+ },
29
+ },
30
+ async execute(args) {
31
+ const full = args.full === true;
32
+ const started = full ? content.rebuild() : content.refreshNow();
33
+ if (!started)
34
+ return { ok: false, error: 'a content build is already running' };
35
+ return { ok: true, started: true, full };
36
+ },
37
+ });
38
+ }
39
+ //# sourceMappingURL=reindex.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reindex.js","sourceRoot":"","sources":["../src/reindex.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AAGnD,MAAM,UAAU,eAAe,CAAC,OAAqB;IACnD,OAAO,UAAU,CAAC;QAChB,IAAI,EAAE,iBAAiB;QACvB,WAAW,EAAE;YACX,wFAAwF;YACxF,EAAE;YACF,8QAA8Q;SAC/Q,CAAC,IAAI,CAAC,IAAI,CAAC;QACZ,UAAU,EAAE;YACV,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,sEAAsE,EAAE;SAC/G;QACD,MAAM,EAAE;YACN,MAAM,EAAE;gBACN,IAAI,EAAE,QAAQ;gBACd,oBAAoB,EAAE,KAAK;gBAC3B,UAAU,EAAE;oBACV,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE;oBACvC,OAAO,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE;oBAC5B,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE;oBACzB,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;iBAC1B;aACF;YACD,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;gBACvB,MAAM,CAAC,GAAG,KAA2E,CAAA;gBACrF,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,aAAa,2BAA2B,CAAC,CAAC,CAAC,2BAA2B,CAAC,CAAC,KAAK,IAAI,SAAS,EAAE,EAAE,CAAC,CAAA;YAC3J,CAAC;SACF;QACD,KAAK,CAAC,OAAO,CAAC,IAAI;YAChB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,KAAK,IAAI,CAAA;YAC/B,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,CAAA;YAC/D,IAAI,CAAC,OAAO;gBAAE,OAAO,EAAE,EAAE,EAAE,KAAc,EAAE,KAAK,EAAE,oCAAoC,EAAE,CAAA;YACxF,OAAO,EAAE,EAAE,EAAE,IAAa,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;QACnD,CAAC;KACF,CAAC,CAAA;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-locator",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "description": "Whole-disk, millisecond file search for DeepSeek Harness without Everything: a self-built cross-platform index, an Everything-style query language, agent tools and a Web search panel (Ctrl+Shift+F). Windows, macOS and Linux.",
5
5
  "author": "PerryLink contributors",
6
6
  "repository": {
package/src/config.ts CHANGED
@@ -31,6 +31,8 @@ export interface Config {
31
31
  contentMaxFileBytes: number
32
32
  /** Stop adding content after this many body bytes; 0 means unlimited. */
33
33
  contentMaxTotalBytes: number
34
+ /** Incremental content refresh cadence; 0 disables it. */
35
+ contentRefreshMs: number
34
36
  /** Maximum text files indexed per build; 0 means no cap. */
35
37
  contentMaxFiles: number
36
38
  /** Most recent session logs indexed; 0 means all. */
@@ -76,6 +78,7 @@ export const Config: Schema<Config> = Schema.object({
76
78
  contentIndex: Schema.boolean().default(true),
77
79
  contentMaxFileBytes: Schema.number().default(4_000_000),
78
80
  contentMaxTotalBytes: Schema.number().default(2_000_000_000),
81
+ contentRefreshMs: Schema.number().default(120_000),
79
82
  contentMaxFiles: Schema.number().default(200_000),
80
83
  contentMaxSessions: Schema.number().default(200),
81
84
  contentSessionDirs: Schema.array(Schema.string()).default([]),
package/src/content.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Content search: text files and DSH session messages in SQLite FTS5.
3
3
  *
4
- * The file-name index stays in the Go engine. This store is built beside it:
5
- * the engine's `dump` RPC supplies the already-scanned file list, Node reads
6
- * text files and decompresses `session.v4.jsonl.zstd` logs, and SQLite's
7
- * trigram tokenizer gives substring/CJK search with snippets.
4
+ * File names stay in the Go engine. This store is built from the engine's
5
+ * `dump` RPC plus the session logs: Node reads text bodies, decompresses
6
+ * `session.v4.jsonl.zstd`, and SQLite's trigram tokenizer gives substring and
7
+ * CJK search. A meta table stores each source's mtime so refresh() only
8
+ * re-reads what changed; rebuild() drops and rebuilds everything.
8
9
  * @module dsh-locator/content
9
10
  */
10
11
 
@@ -12,6 +13,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node
12
13
  import { homedir } from 'node:os'
13
14
  import { basename, dirname, extname, join } from 'node:path'
14
15
  import { zstdDecompressSync } from 'node:zlib'
16
+ import { DatabaseSync } from 'node:sqlite'
15
17
  import type { LocatorRuntime } from './runtime.ts'
16
18
 
17
19
  const TEXT_EXTENSIONS = new Set([
@@ -23,7 +25,6 @@ const TEXT_EXTENSIONS = new Set([
23
25
  '.sql', '.graphql', '.proto', '.tex', '.org', '.adoc',
24
26
  ])
25
27
 
26
- /** One searchable document. */
27
28
  export interface ContentHit {
28
29
  kind: 'file' | 'session'
29
30
  path: string
@@ -34,7 +35,6 @@ export interface ContentHit {
34
35
  score: number
35
36
  }
36
37
 
37
- /** Build progress shown by the panel/status. */
38
38
  export interface ContentState {
39
39
  building: boolean
40
40
  files: number
@@ -45,166 +45,269 @@ export interface ContentState {
45
45
  updatedAt: number
46
46
  }
47
47
 
48
- /** The content store; one instance per plugin. */
48
+ export interface ContentOptions {
49
+ stateDir: string
50
+ sessionDirs?: string[] | undefined
51
+ maxFileBytes: number
52
+ maxTotalBytes?: number
53
+ maxFiles: number
54
+ maxSessions: number
55
+ }
56
+
57
+ interface DocRow {
58
+ key: string
59
+ body: string
60
+ kind: 'file' | 'session'
61
+ path: string
62
+ title: string
63
+ sessionId: string | null
64
+ messageId: string | null
65
+ mtime: number
66
+ }
67
+
49
68
  export class ContentIndex {
50
- private db: import('node:sqlite').DatabaseSync | null = null
69
+ private db: DatabaseSync | null = null
51
70
  private readonly state: ContentState = { building: false, files: 0, sessions: 0, messages: 0, truncated: false, error: '', updatedAt: 0 }
52
71
  private generation = 0
72
+ private totalBytes = 0
73
+ private timer: NodeJS.Timeout | undefined
53
74
 
54
- constructor(private readonly rt: LocatorRuntime, private readonly options: {
55
- stateDir: string
56
- sessionDirs: string[]
57
- maxFileBytes: number
58
- maxTotalBytes?: number
59
- maxFiles: number
60
- maxSessions: number
61
- }) {}
75
+ constructor(private readonly rt: LocatorRuntime, private readonly options: ContentOptions) {}
62
76
 
63
77
  status(): ContentState { return { ...this.state } }
64
78
 
65
- /** Close the database when the plugin unmounts. */
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
+ /** Queue a full rebuild; false when one is already running. */
86
+ rebuild(): boolean {
87
+ if (this.state.building) return false
88
+ this.start()
89
+ return true
90
+ }
91
+
92
+ /** Queue an incremental refresh; false when one is already running. */
93
+ refreshNow(): boolean {
94
+ if (this.state.building) return false
95
+ this.refresh()
96
+ return true
97
+ }
98
+
66
99
  close(): void {
100
+ if (this.timer !== undefined) { clearInterval(this.timer); this.timer = undefined }
67
101
  this.generation++
68
102
  this.db?.close()
69
103
  this.db = null
70
104
  this.state.building = false
71
105
  }
72
106
 
73
- /** Build in the background; stale generations stop at their next checkpoint. */
74
- start(): void {
107
+ /** Build everything from scratch. */
108
+ start(): void { this.begin('full') }
109
+
110
+ /** Re-read changed files/sessions; open the DB when this is the first run. */
111
+ refresh(): void { this.begin(this.db === null ? 'full' : 'incremental') }
112
+
113
+ private begin(mode: 'full' | 'incremental'): void {
114
+ if (this.state.building) return
75
115
  const generation = ++this.generation
76
116
  this.state.building = true
77
- this.state.truncated = false
78
117
  this.state.error = ''
79
- void this.build(generation).catch(error => {
118
+ this.state.truncated = false
119
+ const run = mode === 'full' ? this.full(generation) : this.incremental(generation)
120
+ void run.catch(error => {
80
121
  if (generation !== this.generation) return
81
122
  this.state.error = messageOf(error)
82
123
  this.state.building = false
83
124
  })
84
125
  }
85
126
 
86
- private async build(generation: number): Promise<void> {
87
- const { DatabaseSync } = await import('node:sqlite')
88
- const path = join(this.options.stateDir, 'content.db')
127
+ private async full(generation: number): Promise<void> {
128
+ this.open(true)
129
+ this.totalBytes = 0
130
+ await this.indexSessions(generation)
131
+ if (generation !== this.generation) return
132
+ await this.indexFiles(generation)
133
+ if (generation !== this.generation) return
134
+ this.finish()
135
+ }
136
+
137
+ 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()
145
+ }
146
+
147
+ private open(fresh: boolean): void {
89
148
  mkdirSync(this.options.stateDir, { recursive: true })
90
149
  this.db?.close()
91
- this.db = new DatabaseSync(path)
150
+ this.db = new DatabaseSync(join(this.options.stateDir, 'content.db'))
92
151
  this.db.exec('PRAGMA journal_mode = WAL')
93
152
  this.db.exec('PRAGMA synchronous = OFF')
94
- this.db.exec('DROP TABLE IF EXISTS docs')
95
- this.db.exec(`CREATE VIRTUAL TABLE docs USING fts5(
96
- body,
97
- kind UNINDEXED,
98
- path UNINDEXED,
99
- title UNINDEXED,
100
- session_id UNINDEXED,
101
- message_id UNINDEXED,
102
- tokenize = 'trigram'
103
- )`)
104
- const insert = this.db.prepare('INSERT INTO docs (body, kind, path, title, session_id, message_id) VALUES (?, ?, ?, ?, ?, ?)')
105
- const flush = (rows: Array<[string, string, string, string, string | null, string | null]>) => {
106
- if (rows.length === 0) return
107
- this.db!.exec('BEGIN')
108
- try {
109
- for (const row of rows) insert.run(...row)
110
- this.db!.exec('COMMIT')
111
- } catch (error) {
112
- this.db!.exec('ROLLBACK')
113
- throw error
114
- }
153
+ if (fresh) {
154
+ this.db.exec('DROP TABLE IF EXISTS docs')
155
+ this.db.exec('DROP TABLE IF EXISTS docs_meta')
156
+ this.db.exec('DROP TABLE IF EXISTS sources')
115
157
  }
158
+ this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
159
+ body, kind UNINDEXED, path UNINDEXED, title UNINDEXED,
160
+ session_id UNINDEXED, message_id UNINDEXED, tokenize = 'trigram'
161
+ )`)
162
+ 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
+ this.db.exec('CREATE TABLE IF NOT EXISTS sources (path TEXT PRIMARY KEY, mtime INTEGER)')
164
+ }
116
165
 
117
- this.state.truncated = false
118
- await this.indexSessions(flush, generation)
119
- if (generation !== this.generation) return
120
- await this.indexFiles(flush, generation)
121
- if (generation !== this.generation) return
166
+ private finish(): void {
167
+ const counts = this.db!.prepare("SELECT kind, COUNT(*) AS n FROM docs_meta GROUP BY kind").all() as Array<{ kind: string; n: number }>
168
+ this.state.files = Number(counts.find(row => row.kind === 'file')?.n ?? 0)
169
+ this.state.messages = Number(counts.find(row => row.kind === 'session')?.n ?? 0)
170
+ this.state.sessions = Number((this.db!.prepare('SELECT COUNT(*) AS n FROM sources').get() as { n: number }).n)
122
171
  this.state.building = false
123
172
  this.state.updatedAt = Date.now()
124
173
  }
125
174
 
126
- private totalBytes = 0
127
-
128
- /** Account one body against the total cap; false means the cap was reached. */
129
- private account(body: string): boolean {
175
+ private addDoc(row: DocRow): boolean {
130
176
  const cap = this.options.maxTotalBytes ?? 0
131
- if (cap > 0 && this.totalBytes + body.length > cap) {
177
+ if (cap > 0 && this.totalBytes + row.body.length > cap) {
132
178
  this.state.truncated = true
133
179
  return false
134
180
  }
135
- this.totalBytes += body.length
181
+ this.deleteDoc(row.key)
182
+ const meta = this.db!.prepare('INSERT INTO docs_meta (doc_key, path, mtime, kind) VALUES (?, ?, ?, ?)').run(row.key, row.path, row.mtime, row.kind)
183
+ const rowid = meta.lastInsertRowid
184
+ this.db!.prepare('INSERT INTO docs (rowid, body, kind, path, title, session_id, message_id) VALUES (?, ?, ?, ?, ?, ?, ?)').run(rowid, row.body, row.kind, row.path, row.title, row.sessionId, row.messageId)
185
+ this.totalBytes += row.body.length
136
186
  return true
137
187
  }
138
188
 
139
- private async indexSessions(flush: (rows: Array<[string, string, string, string, string | null, string | null]>) => void, generation: number): Promise<void> {
140
- const files: Array<{ path: string; mtime: number }> = []
141
- for (const root of this.sessionRoots()) {
142
- walkSessions(root, files, 0)
143
- }
144
- files.sort((a, b) => b.mtime - a.mtime)
145
- const limit = this.options.maxSessions > 0 ? this.options.maxSessions : files.length
146
- let batch: Array<[string, string, string, string, string | null, string | null]> = []
147
- for (const file of files.slice(0, limit)) {
148
- if (generation !== this.generation) return
149
- let text: string
150
- try { text = zstdDecompressSync(readFileSync(file.path)).toString('utf8') } catch { continue }
151
- const sessionId = basename(dirname(file.path))
152
- let title = ''
153
- let messages = 0
154
- for (const line of text.split('\n')) {
155
- if (!line) continue
156
- let event: { type?: string; data?: Record<string, unknown> }
157
- try { event = JSON.parse(line) } catch { continue }
158
- const extracted = messageText(event)
159
- if (extracted === undefined) continue
160
- if (title === '') title = extracted.text.replace(/\s+/g, ' ').slice(0, 90)
161
- const body = extracted.text.slice(0, 8_000)
162
- if (!this.account(body)) break
163
- batch.push([body, 'session', file.path, title || sessionId, sessionId, extracted.id])
164
- messages++
165
- if (batch.length >= 200) { flush(batch); batch = [] }
166
- }
167
- if (this.state.truncated) { flush(batch); return }
168
- this.state.sessions++
169
- this.state.messages += messages
170
- }
171
- flush(batch)
189
+ private deleteDoc(key: string): void {
190
+ const row = this.db!.prepare('SELECT rowid FROM docs_meta WHERE doc_key = ?').get(key) as { rowid: number } | undefined
191
+ if (row === undefined) return
192
+ this.db!.prepare('DELETE FROM docs WHERE rowid = ?').run(row.rowid)
193
+ this.db!.prepare('DELETE FROM docs_meta WHERE rowid = ?').run(row.rowid)
172
194
  }
173
195
 
174
- private async indexFiles(flush: (rows: Array<[string, string, string, string, string | null, string | null]>) => void, generation: number): Promise<void> {
196
+ private deletePath(path: string): void {
197
+ const rows = this.db!.prepare('SELECT rowid FROM docs_meta WHERE path = ?').all(path) as Array<{ rowid: number }>
198
+ const delDoc = this.db!.prepare('DELETE FROM docs WHERE rowid = ?')
199
+ 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) }
201
+ }
202
+
203
+ private async indexFiles(generation: number): Promise<void> {
175
204
  let after = 0
176
- let batch: Array<[string, string, string, string, string | null, string | null]> = []
177
205
  let seen = 0
178
206
  for (;;) {
179
207
  if (generation !== this.generation) return
180
208
  const page = await this.rt.dump(after, 2_000)
181
209
  for (const entry of page.entries) {
182
210
  if (entry.dir || entry.size <= 0 || entry.size > this.options.maxFileBytes) continue
183
- if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
211
+ if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase()) || contentSkipPath(entry.path)) continue
184
212
  if (this.options.maxFiles > 0 && seen >= this.options.maxFiles) break
185
- let raw: string
186
- try { raw = readFileSync(entry.path, 'utf8') } catch { continue }
187
- if (raw.includes('\u0000')) continue
188
- const body = raw.slice(0, this.options.maxFileBytes)
189
- if (!this.account(body)) { flush(batch); return }
213
+ const row = this.readFileDoc(entry.path, entry.name, entry.mtime)
214
+ if (row === null) continue
190
215
  seen++
191
- this.state.files++
192
- batch.push([body, 'file', entry.path, entry.name, null, null])
193
- if (batch.length >= 200) { flush(batch); batch = [] }
216
+ if (!this.addDoc(row)) return
217
+ }
218
+ after = page.next
219
+ if (page.done || page.entries.length === 0) break
220
+ }
221
+ }
222
+
223
+ private async refreshFiles(generation: number): Promise<void> {
224
+ const meta = new Map<string, number>()
225
+ 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
+ const seen = new Set<string>()
227
+ let after = 0
228
+ let indexed = 0
229
+ for (;;) {
230
+ if (generation !== this.generation) return
231
+ const page = await this.rt.dump(after, 2_000)
232
+ for (const entry of page.entries) {
233
+ 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
235
+ if (this.options.maxFiles > 0 && indexed >= this.options.maxFiles) break
236
+ const key = `file:${entry.path}`
237
+ seen.add(key)
238
+ indexed++
239
+ if (meta.get(key) === entry.mtime) continue
240
+ this.deleteDoc(key)
241
+ const row = this.readFileDoc(entry.path, entry.name, entry.mtime)
242
+ if (row !== null && !this.addDoc(row)) return
194
243
  }
195
244
  after = page.next
196
245
  if (page.done || page.entries.length === 0) break
197
246
  }
198
- flush(batch)
247
+ for (const key of meta.keys()) if (!seen.has(key)) this.deleteDoc(key)
199
248
  }
200
249
 
201
- private sessionRoots(): string[] {
202
- if (this.options.sessionDirs.length > 0) return this.options.sessionDirs
203
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
204
- return [join(home, 'sessions')]
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 }
255
+ }
256
+
257
+ private async indexSessions(generation: number): Promise<void> {
258
+ const files = sessionFiles(this.sessionRoots()).slice(0, this.options.maxSessions > 0 ? this.options.maxSessions : undefined)
259
+ for (const file of files) {
260
+ if (generation !== this.generation) return
261
+ this.deletePath(file.path)
262
+ if (!this.indexSessionFile(file)) return
263
+ this.db!.prepare('INSERT OR REPLACE INTO sources (path, mtime) VALUES (?, ?)').run(file.path, file.mtime)
264
+ }
265
+ }
266
+
267
+ private async refreshSessions(generation: number): Promise<void> {
268
+ const known = new Map<string, number>()
269
+ 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)
271
+ const present = new Set<string>()
272
+ for (const file of files) {
273
+ if (generation !== this.generation) return
274
+ present.add(file.path)
275
+ if (known.get(file.path) === file.mtime) continue
276
+ this.deletePath(file.path)
277
+ if (!this.indexSessionFile(file)) return
278
+ this.db!.prepare('INSERT OR REPLACE INTO sources (path, mtime) VALUES (?, ?)').run(file.path, file.mtime)
279
+ }
280
+ for (const path of known.keys()) {
281
+ if (present.has(path)) continue
282
+ this.deletePath(path)
283
+ this.db!.prepare('DELETE FROM sources WHERE path = ?').run(path)
284
+ }
285
+ }
286
+
287
+ private indexSessionFile(file: { path: string; mtime: number }): boolean {
288
+ let text: string
289
+ try { text = zstdDecompressSync(readFileSync(file.path)).toString('utf8') } catch { return true }
290
+ const sessionId = basename(dirname(file.path))
291
+ let title = ''
292
+ for (const line of text.split('\n')) {
293
+ if (!line) continue
294
+ let event: { type?: string; data?: Record<string, unknown> }
295
+ try { event = JSON.parse(line) } catch { continue }
296
+ const extracted = messageText(event)
297
+ if (extracted === undefined) continue
298
+ if (title === '') title = extracted.text.replace(/\s+/g, ' ').slice(0, 90)
299
+ const body = extracted.text.slice(0, 8_000)
300
+ 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
+ if (!this.addDoc(row)) return false
302
+ }
303
+ return true
304
+ }
305
+
306
+ sessionRoots(): string[] {
307
+ if (this.options.sessionDirs !== undefined) return this.options.sessionDirs
308
+ return [join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'sessions')]
205
309
  }
206
310
 
207
- /** Query the FTS table; short queries fall back to LIKE because trigram needs 3 chars. */
208
311
  search(query: string, limit: number): ContentHit[] {
209
312
  if (this.db === null) return []
210
313
  const text = query.trim()
@@ -253,22 +356,30 @@ function messageText(event: { type?: string; data?: Record<string, unknown> }):
253
356
  .join('\n')
254
357
  .trim()
255
358
  if (text === '') return undefined
256
- const id = event.type === 'user/message'
257
- ? String(data.id ?? '')
258
- : String(message.id ?? '')
359
+ const id = event.type === 'user/message' ? String(data.id ?? '') : String(message.id ?? '')
259
360
  return { text, id: id || `seq-${String(data.seq ?? '')}` }
260
361
  }
261
362
 
262
- function walkSessions(root: string, out: Array<{ path: string; mtime: number }>, depth: number): void {
263
- if (depth > 4 || !existsSync(root)) return
264
- let entries
265
- try { entries = readdirSync(root, { withFileTypes: true }) } catch { return }
266
- for (const entry of entries) {
267
- const path = join(root, entry.name)
268
- if (entry.isDirectory()) { walkSessions(path, out, depth + 1); continue }
269
- if (!entry.isFile() || !entry.name.endsWith('.v4.jsonl.zstd')) continue
270
- try { out.push({ path, mtime: statSync(path).mtimeMs }) } catch { /* ignore */ }
363
+ function sessionFiles(roots: string[]): Array<{ path: string; mtime: number }> {
364
+ const out: Array<{ path: string; mtime: number }> = []
365
+ const walk = (root: string, depth: number): void => {
366
+ if (depth > 4 || !existsSync(root)) return
367
+ let entries
368
+ try { entries = readdirSync(root, { withFileTypes: true }) } catch { return }
369
+ for (const entry of entries) {
370
+ const path = join(root, entry.name)
371
+ if (entry.isDirectory()) { walk(path, depth + 1); continue }
372
+ if (!entry.isFile() || !entry.name.endsWith('.v4.jsonl.zstd')) continue
373
+ try { out.push({ path, mtime: statSync(path).mtimeMs }) } catch { /* ignore */ }
374
+ }
271
375
  }
376
+ for (const root of roots) walk(root, 0)
377
+ out.sort((a, b) => b.mtime - a.mtime)
378
+ return out
379
+ }
380
+
381
+ function contentSkipPath(path: string): boolean {
382
+ return path.includes('/.dsh/') || path.includes('\\.dsh\\')
272
383
  }
273
384
 
274
385
  function messageOf(error: unknown): string {
package/src/http.ts CHANGED
@@ -71,6 +71,15 @@ export function registerHttp(ctx: Context, rt: LocatorRuntime, content?: Content
71
71
  handler: handler(scoped, async body => await reveal(rt, body)),
72
72
  }))
73
73
  if (content !== undefined) {
74
+ scoped.effect(() => server.register({
75
+ kind: 'exact',
76
+ path: '/dsh-locator/content/rebuild',
77
+ handler: handler(scoped, async body => {
78
+ const full = body.full === true
79
+ const started = full ? content.rebuild() : content.refreshNow()
80
+ return started ? { ok: true, started: true, full } : { ok: false, error: 'a content build is already running' }
81
+ }),
82
+ }))
74
83
  scoped.effect(() => server.register({
75
84
  kind: 'exact',
76
85
  path: '/dsh-locator/content',