dsh-locator 0.8.2 → 0.9.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/src/content.ts CHANGED
@@ -27,7 +27,7 @@ import { DatabaseSync } from 'node:sqlite'
27
27
  import { randomUUID } from 'node:crypto'
28
28
  import { fileURLToPath } from 'node:url'
29
29
  import { Worker } from 'node:worker_threads'
30
- import { searchContent } from './content-search.ts'
30
+ import { searchContent, type ContentSearchOptions, type ContentSearchResult } from './content-search.ts'
31
31
  import type { LocatorRuntime } from './runtime.ts'
32
32
 
33
33
  const SCHEMA_VERSION = 3
@@ -67,6 +67,8 @@ export interface ContentHit {
67
67
  messageId?: string
68
68
  /** Exact `data-chat-node-key` of the rendered row, when the host has one. */
69
69
  nodeKey?: string
70
+ /** 1-based line of the first match inside the stored body, for file hits. */
71
+ line?: number
70
72
  score: number
71
73
  }
72
74
 
@@ -128,7 +130,9 @@ export class ContentIndex {
128
130
  private workerSeq = 0
129
131
  private pendingStart = false
130
132
  private dbIdentity: { dev: number; ino: number } | null = null
131
- private readonly pending = new Map<number, { resolve: (hits: ContentHit[]) => void; reject: (error: Error) => void }>()
133
+ /** Session id → indexed session log path, for session-scoped content search. */
134
+ private readonly sessionPaths = new Map<string, string>()
135
+ private readonly pending = new Map<number, { resolve: (result: ContentSearchResult) => void; reject: (error: Error) => void }>()
132
136
 
133
137
  constructor(private readonly rt: LocatorRuntime, private readonly options: ContentOptions) {}
134
138
 
@@ -176,6 +180,7 @@ export class ContentIndex {
176
180
  this.releaseLock()
177
181
  this.pendingStart = false
178
182
  this.dbIdentity = null
183
+ this.sessionPaths.clear()
179
184
  }
180
185
 
181
186
  /** Build everything from scratch. */
@@ -347,6 +352,7 @@ export class ContentIndex {
347
352
  this.db.exec("INSERT OR REPLACE INTO content_meta (key, value) VALUES ('complete', 0)")
348
353
  }
349
354
  if (fresh) this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
355
+ this.refreshSessionPaths()
350
356
  this.reopenWorker()
351
357
  }
352
358
 
@@ -358,7 +364,7 @@ export class ContentIndex {
358
364
  private stopWorker(): void {
359
365
  const worker = this.worker
360
366
  this.worker = null
361
- for (const entry of this.pending.values()) entry.resolve([])
367
+ for (const entry of this.pending.values()) entry.resolve(emptySearchResult())
362
368
  this.pending.clear()
363
369
  if (worker !== null) void worker.terminate()
364
370
  }
@@ -373,11 +379,15 @@ export class ContentIndex {
373
379
  const worker = new Worker(workerPath, {
374
380
  workerData: { dbPath: join(this.options.stateDir, 'content.db') },
375
381
  })
376
- worker.on('message', (message: { id: number; hits?: ContentHit[] }) => {
382
+ worker.on('message', (message: { id: number; result?: ContentSearchResult; error?: string }) => {
377
383
  const entry = this.pending.get(message.id)
378
384
  if (entry === undefined) return
379
385
  this.pending.delete(message.id)
380
- entry.resolve(message.hits ?? [])
386
+ if (typeof message.error === 'string' && message.error !== '') {
387
+ entry.reject(new Error(message.error))
388
+ return
389
+ }
390
+ entry.resolve(message.result ?? emptySearchResult())
381
391
  })
382
392
  const fail = (): void => {
383
393
  if (this.worker !== worker) return
@@ -424,6 +434,7 @@ export class ContentIndex {
424
434
  this.dbIdentity = null
425
435
  this.stopWorker()
426
436
  this.totalBytes = 0
437
+ this.sessionPaths.clear()
427
438
  this.state.updatedAt = 0
428
439
  this.state.error = ''
429
440
  this.state.truncated = false
@@ -502,9 +513,32 @@ export class ContentIndex {
502
513
  this.state.updatedAt = Date.now()
503
514
  this.writeTotalBytes()
504
515
  this.writeFlag('complete', 1)
516
+ this.refreshSessionPaths()
505
517
  this.reopenWorker()
506
518
  }
507
519
 
520
+ /**
521
+ * Rebuild the session id → session log path map from the persisted sources.
522
+ * Runs after `open()` and after every build/refresh, so session-scoped
523
+ * searches work against an existing database before the first refresh ends.
524
+ */
525
+ private refreshSessionPaths(): void {
526
+ this.sessionPaths.clear()
527
+ if (this.db === null) return
528
+ try {
529
+ const rows = this.db.prepare('SELECT path FROM sources').all() as Array<{ path?: string }>
530
+ for (const row of rows) {
531
+ const path = row.path
532
+ if (typeof path !== 'string' || path === '') continue
533
+ const sessionId = basename(dirname(path))
534
+ if (sessionId !== '') this.sessionPaths.set(sessionId, path)
535
+ }
536
+ } catch {
537
+ // A database from an older schema cannot answer session-scoped queries
538
+ // until its next rebuild; leave the map empty instead of failing boot.
539
+ }
540
+ }
541
+
508
542
  private addDoc(row: DocRow): boolean {
509
543
  const cap = this.options.maxTotalBytes ?? 0
510
544
  if (cap > 0 && this.totalBytes + row.body.length > cap) {
@@ -669,6 +703,7 @@ export class ContentIndex {
669
703
  try { text = (await zstdDecompressAsync(await readFile(file.path))).toString('utf8') } catch { return true }
670
704
  if (generation !== this.generation) return false
671
705
  const sessionId = basename(dirname(file.path))
706
+ this.sessionPaths.set(sessionId, file.path)
672
707
  let title = ''
673
708
  let parsed = 0
674
709
  for (const line of text.split('\n')) {
@@ -712,47 +747,75 @@ export class ContentIndex {
712
747
  * Search file bodies and session messages. With `searchWorker` enabled the
713
748
  * query itself runs off the host thread; a failed or wedged worker falls back
714
749
  * to the in-process query so a result is still produced.
750
+ *
751
+ * The two-argument form returns the hit array (tools and the legacy
752
+ * `/content` route). The three-argument form returns hits plus count,
753
+ * warnings and an error code, and understands the advanced match options.
715
754
  * @param query - words or a phrase to find.
716
755
  * @param limit - maximum hits.
756
+ * @param options - match mode, case/whole-word flags, session scope, count mode.
717
757
  * @returns matching documents ordered by relevance.
718
758
  */
719
- async search(query: string, limit: number): Promise<ContentHit[]> {
720
- if (this.db === null) return []
759
+ async search(query: string, limit: number): Promise<ContentHit[]>
760
+ async search(query: string, limit: number, options: ContentSearchOptions): Promise<ContentSearchResult>
761
+ async search(query: string, limit: number, options?: ContentSearchOptions): Promise<ContentHit[] | ContentSearchResult> {
762
+ const result = await this.searchDetailed(query, limit, options ?? {})
763
+ return options === undefined ? result.hits : result
764
+ }
765
+
766
+ private async searchDetailed(query: string, limit: number, options: ContentSearchOptions): Promise<ContentSearchResult> {
767
+ const resolved = this.resolveSession(options)
768
+ if (resolved === null) return emptySearchResult(['unknown-session'])
769
+ if (this.db === null) return emptySearchResult()
721
770
  if (!this.databaseIsCurrent()) {
722
771
  this.recoverExternal()
723
- return []
772
+ return emptySearchResult()
724
773
  }
725
- if (this.options.searchWorker !== true) return this.searchSync(query, limit)
774
+ if (this.options.searchWorker !== true) return this.searchSync(query, limit, resolved)
726
775
  this.ensureWorker()
727
- if (this.worker === null) return this.searchSync(query, limit)
776
+ if (this.worker === null) return this.searchSync(query, limit, resolved)
728
777
  const id = ++this.workerSeq
729
778
  const worker = this.worker
730
- const response = new Promise<ContentHit[]>((resolve, reject) => { this.pending.set(id, { resolve, reject }) })
731
- worker.postMessage({ id, query, limit })
779
+ const response = new Promise<ContentSearchResult>((resolve, reject) => { this.pending.set(id, { resolve, reject }) })
780
+ worker.postMessage({ id, query, limit, options: resolved })
732
781
  let timer: NodeJS.Timeout | undefined
733
- const timeout = new Promise<ContentHit[]>((_resolve, reject) => {
782
+ const timeout = new Promise<ContentSearchResult>((_resolve, reject) => {
734
783
  timer = setTimeout(() => { this.pending.delete(id); reject(new Error('content search worker timed out')) }, 5_000)
735
784
  })
736
785
  try {
737
786
  return await Promise.race([response, timeout])
738
787
  } catch {
739
- return this.searchSync(query, limit)
788
+ return this.searchSync(query, limit, resolved)
740
789
  } finally {
741
790
  if (timer !== undefined) clearTimeout(timer)
742
791
  }
743
792
  }
744
793
 
745
- private searchSync(query: string, limit: number): ContentHit[] {
746
- if (this.db === null) return []
794
+ /** Map a request's sessionId onto the indexed session log path. */
795
+ private resolveSession(options: ContentSearchOptions): ContentSearchOptions | null {
796
+ if (options.sessionPath !== undefined && options.sessionPath !== null) return options
797
+ if (options.sessionId === undefined || options.sessionId === '') return { ...options, sessionPath: null }
798
+ const path = this.sessionPaths.get(options.sessionId)
799
+ if (path === undefined) return null
800
+ return { ...options, sessionPath: path }
801
+ }
802
+
803
+ private searchSync(query: string, limit: number, options: ContentSearchOptions): ContentSearchResult {
804
+ if (this.db === null) return emptySearchResult()
747
805
  try {
748
- return searchContent(this.db, query, limit)
806
+ return searchContent(this.db, query, limit, options)
749
807
  } catch (error) {
750
808
  this.state.error = messageOf(error)
751
- return []
809
+ return emptySearchResult()
752
810
  }
753
811
  }
754
812
  }
755
813
 
814
+ /** Result used when a content query could not run at all. */
815
+ function emptySearchResult(warnings: string[] = []): ContentSearchResult {
816
+ return { hits: [], count: { value: 0, kind: 'unknown' }, warnings }
817
+ }
818
+
756
819
  function dshHome(): string {
757
820
  return process.env.DSH_HOME || join(homedir(), '.dsh')
758
821
  }
package/src/find.ts CHANGED
@@ -17,6 +17,7 @@ const HIT = {
17
17
  sessionId: { type: 'string' },
18
18
  messageId: { type: 'string' },
19
19
  nodeKey: { type: 'string' },
20
+ line: { type: 'integer', description: '1-based line of the first match in the stored body (file hits).' },
20
21
  score: { type: 'number' },
21
22
  },
22
23
  } as const
@@ -61,9 +62,9 @@ export function makeFindTool(content: ContentIndex, ensure?: () => void) {
61
62
  },
62
63
  },
63
64
  render: (_args, value) => {
64
- const v = value as { ok: boolean; hits?: Array<{ kind: string; title?: string; snippet?: string; path?: string }>; error?: string; state?: ContentState }
65
+ const v = value as { ok: boolean; hits?: Array<{ kind: string; title?: string; snippet?: string; path?: string; line?: number }>; error?: string; state?: ContentState }
65
66
  if (!v.ok) return [{ type: 'text', text: `locator_find failed: ${v.error ?? 'unknown'}` }]
66
- const lines = (v.hits ?? []).map((hit, index) => `${String(index + 1)}. [${hit.kind}] ${hit.title ?? ''} — ${hit.snippet ?? ''}\n ${hit.path ?? ''}`)
67
+ const lines = (v.hits ?? []).map((hit, index) => `${String(index + 1)}. [${hit.kind}] ${hit.title ?? ''} — ${hit.snippet ?? ''}\n ${hit.path ?? ''}${typeof hit.line === 'number' ? ` (line ${String(hit.line)})` : ''}`)
67
68
  const notes = [
68
69
  v.state?.building ? ' (index still building)' : '',
69
70
  v.state?.truncated === true ? ' (content budget reached: raise contentMaxTotalBytes or trim contentExcludes; later files are not searchable)' : '',
package/src/http.ts CHANGED
@@ -16,6 +16,7 @@ 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
18
  import type { ContentIndex } from './content.ts'
19
+ import type { ContentSearchOptions } from './content-search.ts'
19
20
  import { readCombinedRequest, runCombinedSearch, type CombinedDeps } from './combined.ts'
20
21
  import { openWithDefaultApp, revealInFileManager, withinRoots } from './reveal.ts'
21
22
  import { statSync } from 'node:fs'
@@ -176,14 +177,22 @@ async function combined(
176
177
  search: async args => await rt.search(args),
177
178
  maxResults: rt.config.maxResults,
178
179
  ...(content === undefined ? {} : {
179
- contentSearch: async (query: string, limit: number) => await content.search(query, limit),
180
+ contentSearch: async (query: string, limit: number, options: ContentSearchOptions) =>
181
+ await content.search(query, limit, options),
180
182
  contentStatus: () => content.status(),
181
183
  ...(ensure === undefined ? {} : { ensure }),
182
184
  }),
183
185
  }
184
186
  const request = readCombinedRequest(raw, rt.config.maxResults)
185
187
  const result = await runCombinedSearch(deps, request)
186
- return { query: request.query, ...result }
188
+ return {
189
+ query: request.query,
190
+ matchMode: request.matchMode,
191
+ caseSensitive: request.caseSensitive,
192
+ wholeWord: request.wholeWord,
193
+ sessionId: request.sessionId,
194
+ ...result,
195
+ }
187
196
  }
188
197
 
189
198
  async function reveal(rt: LocatorRuntime, raw: Record<string, unknown>): Promise<unknown> {