dsh-locator 0.5.3 → 0.6.0
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 +7 -0
- package/README.en.md +4 -0
- package/README.md +109 -154
- package/client.js +82 -15
- package/lib/config.d.ts +10 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +5 -0
- package/lib/config.js.map +1 -1
- package/lib/content.d.ts +56 -0
- package/lib/content.d.ts.map +1 -0
- package/lib/content.js +274 -0
- package/lib/content.js.map +1 -0
- package/lib/find.d.ts +8 -0
- package/lib/find.d.ts.map +1 -0
- package/lib/find.js +75 -0
- package/lib/find.js.map +1 -0
- package/lib/http.d.ts +2 -1
- package/lib/http.d.ts.map +1 -1
- package/lib/http.js +12 -1
- package/lib/http.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +33 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +15 -0
- package/src/content.ts +253 -0
- package/src/find.ts +76 -0
- package/src/http.ts +13 -1
- package/src/index.ts +30 -1
package/src/config.ts
CHANGED
|
@@ -25,6 +25,16 @@ export interface Config {
|
|
|
25
25
|
scanWorkers: number
|
|
26
26
|
/** Soft Go heap limit in MiB for the engine; 0 leaves it to GOMEMLIMIT/GOGC. */
|
|
27
27
|
memoryLimitMB: number
|
|
28
|
+
/** Index text-file bodies and DSH session messages into the content database. */
|
|
29
|
+
contentIndex: boolean
|
|
30
|
+
/** Maximum bytes read from one text file. */
|
|
31
|
+
contentMaxFileBytes: number
|
|
32
|
+
/** Maximum text files indexed per build; 0 means no cap. */
|
|
33
|
+
contentMaxFiles: number
|
|
34
|
+
/** Most recent session logs indexed; 0 means all. */
|
|
35
|
+
contentMaxSessions: number
|
|
36
|
+
/** Extra session directories; empty means $DSH_HOME/sessions. */
|
|
37
|
+
contentSessionDirs: string[]
|
|
28
38
|
/**
|
|
29
39
|
* Try the NTFS USN/MFT enumeration before the portable walk (Windows only,
|
|
30
40
|
* requires running DSH as administrator). Falls back automatically.
|
|
@@ -61,6 +71,11 @@ export const Config: Schema<Config> = Schema.object({
|
|
|
61
71
|
refreshIntervalMs: Schema.number().default(1_800_000),
|
|
62
72
|
scanWorkers: Schema.number().default(0),
|
|
63
73
|
memoryLimitMB: Schema.number().default(0),
|
|
74
|
+
contentIndex: Schema.boolean().default(true),
|
|
75
|
+
contentMaxFileBytes: Schema.number().default(1_000_000),
|
|
76
|
+
contentMaxFiles: Schema.number().default(50_000),
|
|
77
|
+
contentMaxSessions: Schema.number().default(50),
|
|
78
|
+
contentSessionDirs: Schema.array(Schema.string()).default([]),
|
|
64
79
|
windowsUsn: Schema.boolean().default(false),
|
|
65
80
|
stateDir: Schema.string().default(''),
|
|
66
81
|
binaryPath: Schema.string().default(''),
|
package/src/content.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content search: text files and DSH session messages in SQLite FTS5.
|
|
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.
|
|
8
|
+
* @module dsh-locator/content
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
14
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
15
|
+
import type { LocatorRuntime } from './runtime.ts'
|
|
16
|
+
|
|
17
|
+
const TEXT_EXTENSIONS = new Set([
|
|
18
|
+
'.txt', '.md', '.markdown', '.rst', '.log', '.csv', '.tsv', '.json', '.jsonl',
|
|
19
|
+
'.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.xml', '.html', '.htm',
|
|
20
|
+
'.css', '.scss', '.less', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
21
|
+
'.go', '.rs', '.py', '.rb', '.php', '.java', '.kt', '.swift', '.c', '.h',
|
|
22
|
+
'.cc', '.cpp', '.hpp', '.cs', '.sh', '.bash', '.zsh', '.fish', '.ps1',
|
|
23
|
+
'.sql', '.graphql', '.proto', '.tex', '.org', '.adoc',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
/** One searchable document. */
|
|
27
|
+
export interface ContentHit {
|
|
28
|
+
kind: 'file' | 'session'
|
|
29
|
+
path: string
|
|
30
|
+
title: string
|
|
31
|
+
snippet: string
|
|
32
|
+
sessionId?: string
|
|
33
|
+
messageId?: string
|
|
34
|
+
score: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build progress shown by the panel/status. */
|
|
38
|
+
export interface ContentState {
|
|
39
|
+
building: boolean
|
|
40
|
+
files: number
|
|
41
|
+
sessions: number
|
|
42
|
+
messages: number
|
|
43
|
+
error: string
|
|
44
|
+
updatedAt: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The content store; one instance per plugin. */
|
|
48
|
+
export class ContentIndex {
|
|
49
|
+
private db: import('node:sqlite').DatabaseSync | null = null
|
|
50
|
+
private readonly state: ContentState = { building: false, files: 0, sessions: 0, messages: 0, error: '', updatedAt: 0 }
|
|
51
|
+
private generation = 0
|
|
52
|
+
|
|
53
|
+
constructor(private readonly rt: LocatorRuntime, private readonly options: {
|
|
54
|
+
stateDir: string
|
|
55
|
+
sessionDirs: string[]
|
|
56
|
+
maxFileBytes: number
|
|
57
|
+
maxFiles: number
|
|
58
|
+
maxSessions: number
|
|
59
|
+
}) {}
|
|
60
|
+
|
|
61
|
+
status(): ContentState { return { ...this.state } }
|
|
62
|
+
|
|
63
|
+
/** Close the database when the plugin unmounts. */
|
|
64
|
+
close(): void {
|
|
65
|
+
this.generation++
|
|
66
|
+
this.db?.close()
|
|
67
|
+
this.db = null
|
|
68
|
+
this.state.building = false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build in the background; stale generations stop at their next checkpoint. */
|
|
72
|
+
start(): void {
|
|
73
|
+
const generation = ++this.generation
|
|
74
|
+
this.state.building = true
|
|
75
|
+
this.state.error = ''
|
|
76
|
+
void this.build(generation).catch(error => {
|
|
77
|
+
if (generation !== this.generation) return
|
|
78
|
+
this.state.error = messageOf(error)
|
|
79
|
+
this.state.building = false
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async build(generation: number): Promise<void> {
|
|
84
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
85
|
+
const path = join(this.options.stateDir, 'content.db')
|
|
86
|
+
this.db?.close()
|
|
87
|
+
this.db = new DatabaseSync(path)
|
|
88
|
+
this.db.exec('PRAGMA journal_mode = WAL')
|
|
89
|
+
this.db.exec('PRAGMA synchronous = OFF')
|
|
90
|
+
this.db.exec('DROP TABLE IF EXISTS docs')
|
|
91
|
+
this.db.exec(`CREATE VIRTUAL TABLE docs USING fts5(
|
|
92
|
+
body,
|
|
93
|
+
kind UNINDEXED,
|
|
94
|
+
path UNINDEXED,
|
|
95
|
+
title UNINDEXED,
|
|
96
|
+
session_id UNINDEXED,
|
|
97
|
+
message_id UNINDEXED,
|
|
98
|
+
tokenize = 'trigram'
|
|
99
|
+
)`)
|
|
100
|
+
const insert = this.db.prepare('INSERT INTO docs (body, kind, path, title, session_id, message_id) VALUES (?, ?, ?, ?, ?, ?)')
|
|
101
|
+
const flush = (rows: Array<[string, string, string, string, string | null, string | null]>) => {
|
|
102
|
+
if (rows.length === 0) return
|
|
103
|
+
this.db!.exec('BEGIN')
|
|
104
|
+
try {
|
|
105
|
+
for (const row of rows) insert.run(...row)
|
|
106
|
+
this.db!.exec('COMMIT')
|
|
107
|
+
} catch (error) {
|
|
108
|
+
this.db!.exec('ROLLBACK')
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await this.indexSessions(flush, generation)
|
|
114
|
+
if (generation !== this.generation) return
|
|
115
|
+
await this.indexFiles(flush, generation)
|
|
116
|
+
if (generation !== this.generation) return
|
|
117
|
+
this.state.building = false
|
|
118
|
+
this.state.updatedAt = Date.now()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async indexSessions(flush: (rows: Array<[string, string, string, string, string | null, string | null]>) => void, generation: number): Promise<void> {
|
|
122
|
+
const files: Array<{ path: string; mtime: number }> = []
|
|
123
|
+
for (const root of this.sessionRoots()) {
|
|
124
|
+
walkSessions(root, files, 0)
|
|
125
|
+
}
|
|
126
|
+
files.sort((a, b) => b.mtime - a.mtime)
|
|
127
|
+
const limit = this.options.maxSessions > 0 ? this.options.maxSessions : files.length
|
|
128
|
+
let batch: Array<[string, string, string, string, string | null, string | null]> = []
|
|
129
|
+
for (const file of files.slice(0, limit)) {
|
|
130
|
+
if (generation !== this.generation) return
|
|
131
|
+
let text: string
|
|
132
|
+
try { text = zstdDecompressSync(readFileSync(file.path)).toString('utf8') } catch { continue }
|
|
133
|
+
const sessionId = basename(dirname(file.path))
|
|
134
|
+
let title = ''
|
|
135
|
+
let messages = 0
|
|
136
|
+
for (const line of text.split('\n')) {
|
|
137
|
+
if (!line) continue
|
|
138
|
+
let event: { type?: string; data?: Record<string, unknown> }
|
|
139
|
+
try { event = JSON.parse(line) } catch { continue }
|
|
140
|
+
const extracted = messageText(event)
|
|
141
|
+
if (extracted === undefined) continue
|
|
142
|
+
if (title === '') title = extracted.text.replace(/\s+/g, ' ').slice(0, 90)
|
|
143
|
+
batch.push([extracted.text.slice(0, 8_000), 'session', file.path, title || sessionId, sessionId, extracted.id])
|
|
144
|
+
messages++
|
|
145
|
+
if (batch.length >= 200) { flush(batch); batch = [] }
|
|
146
|
+
}
|
|
147
|
+
this.state.sessions++
|
|
148
|
+
this.state.messages += messages
|
|
149
|
+
}
|
|
150
|
+
flush(batch)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async indexFiles(flush: (rows: Array<[string, string, string, string, string | null, string | null]>) => void, generation: number): Promise<void> {
|
|
154
|
+
let after = 0
|
|
155
|
+
let batch: Array<[string, string, string, string, string | null, string | null]> = []
|
|
156
|
+
let seen = 0
|
|
157
|
+
for (;;) {
|
|
158
|
+
if (generation !== this.generation) return
|
|
159
|
+
const page = await this.rt.dump(after, 2_000)
|
|
160
|
+
for (const entry of page.entries) {
|
|
161
|
+
if (entry.dir || entry.size <= 0 || entry.size > this.options.maxFileBytes) continue
|
|
162
|
+
if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
|
|
163
|
+
if (this.options.maxFiles > 0 && seen >= this.options.maxFiles) break
|
|
164
|
+
let body: string
|
|
165
|
+
try { body = readFileSync(entry.path, 'utf8') } catch { continue }
|
|
166
|
+
if (body.includes('\u0000')) continue
|
|
167
|
+
seen++
|
|
168
|
+
this.state.files++
|
|
169
|
+
batch.push([body.slice(0, this.options.maxFileBytes), 'file', entry.path, entry.name, null, null])
|
|
170
|
+
if (batch.length >= 200) { flush(batch); batch = [] }
|
|
171
|
+
}
|
|
172
|
+
after = page.next
|
|
173
|
+
if (page.done || page.entries.length === 0) break
|
|
174
|
+
}
|
|
175
|
+
flush(batch)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private sessionRoots(): string[] {
|
|
179
|
+
if (this.options.sessionDirs.length > 0) return this.options.sessionDirs
|
|
180
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
181
|
+
return [join(home, 'sessions')]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Query the FTS table; short queries fall back to LIKE because trigram needs 3 chars. */
|
|
185
|
+
search(query: string, limit: number): ContentHit[] {
|
|
186
|
+
if (this.db === null) return []
|
|
187
|
+
const text = query.trim()
|
|
188
|
+
if (text === '') return []
|
|
189
|
+
const capped = Math.max(1, Math.min(100, limit))
|
|
190
|
+
try {
|
|
191
|
+
let rows: Array<Record<string, unknown>>
|
|
192
|
+
if ([...text].length < 3) {
|
|
193
|
+
rows = this.db.prepare(`SELECT kind, path, title, session_id, message_id, body, 0 AS score
|
|
194
|
+
FROM docs WHERE body LIKE '%' || ? || '%' LIMIT ?`).all(text, capped) as Array<Record<string, unknown>>
|
|
195
|
+
} else {
|
|
196
|
+
const match = text.split(/\s+/).filter(Boolean).map(term => '"' + term.replace(/"/g, '') + '"').join(' AND ')
|
|
197
|
+
rows = this.db.prepare(`SELECT kind, path, title, session_id, message_id,
|
|
198
|
+
snippet(docs, 0, char(1), char(2), '…', 16) AS snippet, bm25(docs) AS score
|
|
199
|
+
FROM docs WHERE docs MATCH ? ORDER BY score LIMIT ?`).all(match, capped) as Array<Record<string, unknown>>
|
|
200
|
+
}
|
|
201
|
+
return rows.map(row => ({
|
|
202
|
+
kind: row.kind === 'session' ? 'session' : 'file',
|
|
203
|
+
path: String(row.path ?? ''),
|
|
204
|
+
title: String(row.title ?? ''),
|
|
205
|
+
snippet: String(row.snippet ?? row.body ?? '').replace(/[\u0001\u0002]/g, ''),
|
|
206
|
+
...(row.session_id ? { sessionId: String(row.session_id) } : {}),
|
|
207
|
+
...(row.message_id ? { messageId: String(row.message_id) } : {}),
|
|
208
|
+
score: Number(row.score ?? 0),
|
|
209
|
+
}))
|
|
210
|
+
} catch (error) {
|
|
211
|
+
this.state.error = messageOf(error)
|
|
212
|
+
return []
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function messageText(event: { type?: string; data?: Record<string, unknown> }): { text: string; id: string } | undefined {
|
|
218
|
+
if (event.type !== 'user/message' && event.type !== 'assistant/message') return undefined
|
|
219
|
+
const data = event.data ?? {}
|
|
220
|
+
if (event.type === 'user/message') {
|
|
221
|
+
const source = (data.source ?? {}) as Record<string, unknown>
|
|
222
|
+
if (source.kind === 'time-context') return undefined
|
|
223
|
+
}
|
|
224
|
+
const message = (data.message ?? {}) as Record<string, unknown>
|
|
225
|
+
const content = event.type === 'user/message' ? data.content : message.content
|
|
226
|
+
if (!Array.isArray(content)) return undefined
|
|
227
|
+
const text = content
|
|
228
|
+
.filter((block): block is { type: string; text: string } => typeof block === 'object' && block !== null && (block as { type?: string }).type === 'text' && typeof (block as { text?: unknown }).text === 'string')
|
|
229
|
+
.map(block => block.text)
|
|
230
|
+
.join('\n')
|
|
231
|
+
.trim()
|
|
232
|
+
if (text === '') return undefined
|
|
233
|
+
const id = event.type === 'user/message'
|
|
234
|
+
? String(data.id ?? '')
|
|
235
|
+
: String(message.id ?? '')
|
|
236
|
+
return { text, id: id || `seq-${String(data.seq ?? '')}` }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function walkSessions(root: string, out: Array<{ path: string; mtime: number }>, depth: number): void {
|
|
240
|
+
if (depth > 4 || !existsSync(root)) return
|
|
241
|
+
let entries
|
|
242
|
+
try { entries = readdirSync(root, { withFileTypes: true }) } catch { return }
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const path = join(root, entry.name)
|
|
245
|
+
if (entry.isDirectory()) { walkSessions(path, out, depth + 1); continue }
|
|
246
|
+
if (!entry.isFile() || !entry.name.endsWith('.v4.jsonl.zstd')) continue
|
|
247
|
+
try { out.push({ path, mtime: statSync(path).mtimeMs }) } catch { /* ignore */ }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function messageOf(error: unknown): string {
|
|
252
|
+
return error instanceof Error ? error.message : String(error)
|
|
253
|
+
}
|
package/src/find.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* locator_find — search file bodies and DSH session messages.
|
|
3
|
+
* @module dsh-locator/find
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
7
|
+
import type { ContentIndex, ContentState } from './content.ts'
|
|
8
|
+
|
|
9
|
+
const HIT = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
properties: {
|
|
13
|
+
kind: { type: 'string', enum: ['file', 'session'], required: true },
|
|
14
|
+
path: { type: 'string', required: true },
|
|
15
|
+
title: { type: 'string' },
|
|
16
|
+
snippet: { type: 'string' },
|
|
17
|
+
sessionId: { type: 'string' },
|
|
18
|
+
messageId: { type: 'string' },
|
|
19
|
+
score: { type: 'number' },
|
|
20
|
+
},
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
/** Build the locator_find tool. */
|
|
24
|
+
export function makeFindTool(content: ContentIndex) {
|
|
25
|
+
return defineTool({
|
|
26
|
+
name: 'locator_find',
|
|
27
|
+
description: [
|
|
28
|
+
'Search the **contents** of text files and DSH session messages (dsh-locator).',
|
|
29
|
+
'',
|
|
30
|
+
'Use it when the user remembers a phrase, a sentence, a decision or a code snippet rather than a file name. `locator_search` searches names and paths; this searches bodies and returns snippets plus `sessionId`/`messageId` for session hits, so the result can be opened at its source.',
|
|
31
|
+
'',
|
|
32
|
+
'Content is built asynchronously after start: results may be partial while `state.building` is true. Text files are capped by `contentMaxFileBytes`; session logs are decompressed from `$DSH_HOME/sessions`.',
|
|
33
|
+
].join('\n'),
|
|
34
|
+
parameters: {
|
|
35
|
+
query: { type: 'string', required: true, description: 'Words or a phrase to find in file/session content.' },
|
|
36
|
+
limit: { type: 'integer', description: 'Maximum hits (default 30, at most 100).' },
|
|
37
|
+
},
|
|
38
|
+
output: {
|
|
39
|
+
schema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
properties: {
|
|
43
|
+
ok: { type: 'boolean', required: true },
|
|
44
|
+
query: { type: 'string' },
|
|
45
|
+
hits: { type: 'array', items: HIT },
|
|
46
|
+
state: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
properties: {
|
|
50
|
+
building: { type: 'boolean' },
|
|
51
|
+
files: { type: 'integer' },
|
|
52
|
+
sessions: { type: 'integer' },
|
|
53
|
+
messages: { type: 'integer' },
|
|
54
|
+
updatedAt: { type: 'integer' },
|
|
55
|
+
error: { type: 'string' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
error: { type: 'string' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
render: (_args, value) => {
|
|
62
|
+
const v = value as { ok: boolean; hits?: Array<{ kind: string; title?: string; snippet?: string; path?: string }>; error?: string; state?: ContentState }
|
|
63
|
+
if (!v.ok) return [{ type: 'text', text: `locator_find failed: ${v.error ?? 'unknown'}` }]
|
|
64
|
+
const lines = (v.hits ?? []).map((hit, index) => `${String(index + 1)}. [${hit.kind}] ${hit.title ?? ''} — ${hit.snippet ?? ''}\n ${hit.path ?? ''}`)
|
|
65
|
+
return [{ type: 'text', text: `${String(v.hits?.length ?? 0)} content hit(s)${v.state?.building ? ' (index still building)' : ''}\n${lines.join('\n')}` }]
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
async execute(args) {
|
|
69
|
+
const query = args.query.trim()
|
|
70
|
+
if (query === '') return { ok: false as const, error: 'query is empty' }
|
|
71
|
+
const limit = Math.max(1, Math.min(100, args.limit ?? 30))
|
|
72
|
+
const hits = content.search(query, limit)
|
|
73
|
+
return { ok: true as const, query, hits, state: content.status() }
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
17
|
import type { LocatorRuntime, SearchArgs } from './runtime.ts'
|
|
18
|
+
import type { ContentIndex } from './content.ts'
|
|
18
19
|
import { openWithDefaultApp, revealInFileManager, withinRoots } from './reveal.ts'
|
|
19
20
|
import { statSync } from 'node:fs'
|
|
20
21
|
|
|
@@ -46,7 +47,7 @@ interface SearchBody {
|
|
|
46
47
|
const BODY_LIMIT = 64 * 1024
|
|
47
48
|
|
|
48
49
|
/** Mount the panel's routes when this profile runs a Web server. */
|
|
49
|
-
export function registerHttp(ctx: Context, rt: LocatorRuntime): void {
|
|
50
|
+
export function registerHttp(ctx: Context, rt: LocatorRuntime, content?: ContentIndex): void {
|
|
50
51
|
const inject = (ctx as unknown as {
|
|
51
52
|
inject?: (deps: string[], callback: (scoped: Context) => void) => void
|
|
52
53
|
}).inject
|
|
@@ -69,6 +70,17 @@ export function registerHttp(ctx: Context, rt: LocatorRuntime): void {
|
|
|
69
70
|
path: '/dsh-locator/reveal',
|
|
70
71
|
handler: handler(scoped, async body => await reveal(rt, body)),
|
|
71
72
|
}))
|
|
73
|
+
if (content !== undefined) {
|
|
74
|
+
scoped.effect(() => server.register({
|
|
75
|
+
kind: 'exact',
|
|
76
|
+
path: '/dsh-locator/content',
|
|
77
|
+
handler: handler(scoped, async body => {
|
|
78
|
+
const query = typeof body.query === 'string' ? body.query : ''
|
|
79
|
+
const limit = typeof body.limit === 'number' ? body.limit : 30
|
|
80
|
+
return { ok: true, query, hits: content.search(query, limit), state: content.status() }
|
|
81
|
+
}),
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
72
84
|
scoped.effect(() => server.register({
|
|
73
85
|
kind: 'exact',
|
|
74
86
|
path: '/dsh-locator/open',
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import type { Context } from '@deepseek-ai/cordis'
|
|
8
8
|
import { Config as ConfigSchema } from './config.ts'
|
|
9
9
|
import type { Config } from './config.ts'
|
|
10
|
+
import { ContentIndex } from './content.ts'
|
|
10
11
|
import { makeExportTool } from './export.ts'
|
|
12
|
+
import { makeFindTool } from './find.ts'
|
|
11
13
|
import { registerHttp } from './http.ts'
|
|
12
14
|
import { resolveBinary, resolveRoots, resolveStateDir } from './paths.ts'
|
|
13
15
|
import { LocatorRuntime } from './runtime.ts'
|
|
@@ -34,15 +36,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
34
36
|
const binary = resolveBinary(config, cwd)
|
|
35
37
|
const logger = ctx.logger('dsh-locator')
|
|
36
38
|
const rt = new LocatorRuntime(config, roots, stateDir, binary, cwd)
|
|
39
|
+
const content = config.contentIndex
|
|
40
|
+
? new ContentIndex(rt, {
|
|
41
|
+
stateDir,
|
|
42
|
+
sessionDirs: config.contentSessionDirs,
|
|
43
|
+
maxFileBytes: config.contentMaxFileBytes,
|
|
44
|
+
maxFiles: config.contentMaxFiles,
|
|
45
|
+
maxSessions: config.contentMaxSessions,
|
|
46
|
+
})
|
|
47
|
+
: undefined
|
|
37
48
|
|
|
38
49
|
ctx.effect(() => {
|
|
39
50
|
// Let the host finish starting before a cold scan starts reading the disk.
|
|
40
51
|
const timer = setTimeout(() => {
|
|
41
52
|
rt.start()
|
|
42
53
|
logger.info(`index roots: ${roots.join(', ')} (state ${stateDir})`)
|
|
54
|
+
if (content !== undefined) void startContent(rt, content, logger)
|
|
43
55
|
}, 1500)
|
|
44
56
|
return () => {
|
|
45
57
|
clearTimeout(timer)
|
|
58
|
+
content?.close()
|
|
46
59
|
void rt.stop()
|
|
47
60
|
}
|
|
48
61
|
})
|
|
@@ -51,8 +64,24 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
51
64
|
ctx.tools.register(makeStatusTool(rt))
|
|
52
65
|
ctx.tools.register(makeRebuildTool(rt))
|
|
53
66
|
ctx.tools.register(makeExportTool(rt))
|
|
67
|
+
if (content !== undefined) ctx.tools.register(makeFindTool(content))
|
|
54
68
|
|
|
55
69
|
// The Web GUI panel is optional: profiles without a Web server keep the
|
|
56
70
|
// three tools and never mount an HTTP route.
|
|
57
|
-
registerHttp(ctx, rt)
|
|
71
|
+
registerHttp(ctx, rt, content)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function startContent(rt: LocatorRuntime, content: ContentIndex, logger: { info(message: string): void }): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
await rt.waitReady()
|
|
77
|
+
for (let i = 0; i < 900; i++) {
|
|
78
|
+
const status = await rt.engineStatus()
|
|
79
|
+
if (status === undefined || !status.scanning) break
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 2_000))
|
|
81
|
+
}
|
|
82
|
+
content.start()
|
|
83
|
+
logger.info('content index started')
|
|
84
|
+
} catch (error) {
|
|
85
|
+
logger.info(`content index skipped: ${error instanceof Error ? error.message : String(error)}`)
|
|
86
|
+
}
|
|
58
87
|
}
|