@fishdivinity/dsh-local-search 0.1.0 → 0.2.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/format.js CHANGED
@@ -1,92 +1,101 @@
1
- /**
2
- * Model-facing formatting. Results stay bounded: callers cap items and text
3
- * before these functions run.
4
- */
5
-
6
- /** Convert a Windows FILETIME (100 ns ticks since 1601) to an ISO string. */
7
- export function filetimeToIso(ft) {
8
- if (typeof ft !== 'number' || ft <= 0) return undefined
9
- const date = new Date(ft / 10000 - 11644473600000)
10
- return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
11
- }
12
-
13
- function sizeSuffix(size) {
14
- return typeof size === 'number' && Number.isFinite(size) ? `\t${size} B` : ''
15
- }
16
-
17
- function modifiedSuffix(modified) {
18
- return typeof modified === 'string' && modified.length > 0 ? `\t${modified}` : ''
19
- }
20
-
21
- /** Format the files result (Everything). */
22
- export function formatFiles(value) {
23
- const items = Array.isArray(value.items) ? value.items : []
24
- if (items.length === 0) return 'No files found.'
25
- const header = value.truncated
26
- ? `Found ${items.length} path(s) (capped)`
27
- : `Found ${items.length} path(s)`
28
- const body = items
29
- .map((item) => `${item.path}${sizeSuffix(item.size)}${modifiedSuffix(item.modified)}`)
30
- .join('\n')
31
- return `${header}\n\n${body}`
32
- }
33
-
34
- /** Format the content result (Anytxt). */
35
- export function formatContent(value) {
36
- const items = Array.isArray(value.items) ? value.items : []
37
- if (items.length === 0) return `No content matches (indexed total ${value.total ?? 0}).`
38
- const header = `${items.length} of ${value.total ?? items.length} indexed file(s) match`
39
- const body = items.map((item) => `${item.ref}\t${item.path}`).join('\n')
40
- const footer = value.truncated ? '\n\n(Showing the first results; narrow the query or raise limit to see more.)' : ''
41
- return `${header}\n\n${body}${footer}`
42
- }
43
-
44
- /** Format fragments from one indexed document. */
45
- export function formatFragments(value) {
46
- const fragments = Array.isArray(value.fragments) ? value.fragments.filter(Boolean) : []
47
- return fragments.length > 0 ? fragments.join('\n---\n') : 'No fragments returned.'
48
- }
49
-
50
- /** Format a document excerpt with its total length. */
51
- export function formatRead(value) {
52
- const total = typeof value.totalChars === 'number' ? value.totalChars : 0
53
- const offset = typeof value.offset === 'number' ? value.offset : 0
54
- const returned = typeof value.returned === 'number' ? value.returned : 0
55
- return `chars ${total}; showing ${offset}..${offset + returned}\n\n${value.text ?? ''}`
56
- }
57
-
58
- /** Format both engine statuses and the configured roots. */
59
- export function formatStatus(value) {
60
- const lines = []
61
- const everything = value.everything ?? {}
62
- if (everything.ok) {
63
- lines.push(
64
- `Everything: OK${everything.version ? ` (${everything.version})` : ''}${everything.path ? ` via ${everything.path}` : ''}`,
65
- )
66
- } else if (everything.code === 'SKIPPED') {
67
- lines.push(`Everything: SKIPPED${everything.message ? ` — ${everything.message}` : ''}`)
68
- } else {
69
- lines.push(
70
- `Everything: DOWN — ${everything.code ?? 'UNKNOWN'}${everything.message ? `: ${everything.message}` : ''}`,
71
- )
72
- }
73
- const anytxt = value.anytxt ?? {}
74
- if (anytxt.ok) {
75
- lines.push(`Anytxt: OK${anytxt.url ? ` at ${anytxt.url}` : ''}`)
76
- } else if (anytxt.code === 'SKIPPED') {
77
- lines.push(`Anytxt: SKIPPED${anytxt.message ? ` — ${anytxt.message}` : ''}`)
78
- } else {
79
- lines.push(
80
- `Anytxt: DOWN — ${anytxt.code ?? 'UNKNOWN'}${anytxt.message ? `: ${anytxt.message}` : ''}`,
81
- )
82
- }
83
- const roots = value.roots ?? {}
84
- lines.push(`workspace=${roots.workspace ?? ''}`)
85
- lines.push(`work=${roots.work ?? ''}`)
86
- lines.push(`personal=${roots.personal ?? ''}`)
87
- lines.push(`all=${Array.isArray(roots.all) ? roots.all.join(',') : ''}`)
88
- if (value.engines) {
89
- lines.push(`engines=files:${(value.engines.files ?? []).join(',')} content:${(value.engines.content ?? []).join(',')}`)
90
- }
91
- return lines.join('\n')
92
- }
1
+ /**
2
+ * Model-facing formatting. Results stay bounded: callers cap items and text
3
+ * before these functions run.
4
+ */
5
+
6
+ /** Convert a Windows FILETIME (100 ns ticks since 1601) to an ISO string. */
7
+ export function filetimeToIso(ft) {
8
+ if (typeof ft !== 'number' || ft <= 0) return undefined
9
+ const date = new Date(ft / 10000 - 11644473600000)
10
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
11
+ }
12
+
13
+ function sizeSuffix(size) {
14
+ return typeof size === 'number' && Number.isFinite(size) ? `\t${size} B` : ''
15
+ }
16
+
17
+ function modifiedSuffix(modified) {
18
+ return typeof modified === 'string' && modified.length > 0 ? `\t${modified}` : ''
19
+ }
20
+
21
+ /** Format the files result (Everything). */
22
+ export function formatFiles(value) {
23
+ const items = Array.isArray(value.items) ? value.items : []
24
+ if (items.length === 0) {
25
+ // The cap is applied before the index filter, so an empty window can still
26
+ // mean "more existed": say so instead of a flat "no files found".
27
+ return value.truncated
28
+ ? 'No files found in the returned window (the result set was capped; narrow the query).'
29
+ : 'No files found.'
30
+ }
31
+ const header = value.truncated
32
+ ? `Found ${items.length} path(s) (capped)`
33
+ : `Found ${items.length} path(s)`
34
+ const body = items
35
+ .map((item) => `${item.path}${sizeSuffix(item.size)}${modifiedSuffix(item.modified)}`)
36
+ .join('\n')
37
+ return `${header}\n\n${body}`
38
+ }
39
+
40
+ /** Format the content result (Anytxt). */
41
+ export function formatContent(value) {
42
+ const items = Array.isArray(value.items) ? value.items : []
43
+ if (items.length === 0) return `No content matches (indexed total ${value.total ?? 0}).`
44
+ const header = `${items.length} of ${value.total ?? items.length} indexed file(s) match`
45
+ const body = items.map((item) => `${item.ref}\t${item.path}`).join('\n')
46
+ const footer = value.truncated ? '\n\n(Showing the first results; narrow the query or raise limit to see more.)' : ''
47
+ return `${header}\n\n${body}${footer}`
48
+ }
49
+
50
+ /** Format fragments from one indexed document. */
51
+ export function formatFragments(value) {
52
+ const fragments = Array.isArray(value.fragments) ? value.fragments.filter(Boolean) : []
53
+ return fragments.length > 0 ? fragments.join('\n---\n') : 'No fragments returned.'
54
+ }
55
+
56
+ /** Format a document excerpt with its total length. */
57
+ export function formatRead(value) {
58
+ const total = typeof value.totalChars === 'number' ? value.totalChars : 0
59
+ const offset = typeof value.offset === 'number' ? value.offset : 0
60
+ const returned = typeof value.returned === 'number' ? value.returned : 0
61
+ if (returned === 0) return `chars ${total}; nothing to show at offset ${offset}`
62
+ // The end is inclusive so a reader can subtract the two numbers directly.
63
+ return `chars ${total}; showing ${offset}..${offset + returned - 1}\n\n${value.text ?? ''}`
64
+ }
65
+
66
+ /**
67
+ * One engine's status line.
68
+ *
69
+ * An aborted probe means this call was cancelled, not that the index is down —
70
+ * reporting it as DOWN sends the reader after the wrong problem.
71
+ * @param {string} label - engine label
72
+ * @param {object} status - the probe result
73
+ * @param {(status: object) => string} okDetail - the suffix a healthy engine adds
74
+ * @returns {string} the line
75
+ */
76
+ function engineLine(label, status, okDetail) {
77
+ if (status.ok) return `${label}: OK${okDetail(status)}`
78
+ if (status.code === 'SKIPPED') return `${label}: SKIPPED${status.message ? ` — ${status.message}` : ''}`
79
+ if (typeof status.code === 'string' && status.code.endsWith('_ABORTED')) {
80
+ return `${label}: ABORTED — ${status.code}`
81
+ }
82
+ return `${label}: DOWN — ${status.code ?? 'UNKNOWN'}${status.message ? `: ${status.message}` : ''}`
83
+ }
84
+
85
+ /** Format both engine statuses and the configured roots. */
86
+ export function formatStatus(value) {
87
+ const lines = [
88
+ engineLine('Everything', value.everything ?? {}, (status) =>
89
+ `${status.version ? ` (${status.version})` : ''}${status.path ? ` via ${status.path}` : ''}`),
90
+ engineLine('Anytxt', value.anytxt ?? {}, (status) => (status.url ? ` at ${status.url}` : '')),
91
+ ]
92
+ const roots = value.roots ?? {}
93
+ lines.push(`workspace=${roots.workspace ?? ''}`)
94
+ lines.push(`work=${roots.work ?? ''}`)
95
+ lines.push(`personal=${roots.personal ?? ''}`)
96
+ lines.push(`all=${Array.isArray(roots.all) ? roots.all.join(',') : ''}`)
97
+ if (value.engines) {
98
+ lines.push(`engines=files:${(value.engines.files ?? []).join(',')} content:${(value.engines.content ?? []).join(',')}`)
99
+ }
100
+ return lines.join('\n')
101
+ }
package/lib/index.js CHANGED
@@ -1,71 +1,77 @@
1
- import z from '@deepseek-ai/schemastery'
2
- import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
3
- import { createTools } from './tools.js'
4
-
5
- /** Cordis plugin name used by loader diagnostics. */
6
- export const name = 'local-search'
7
-
8
- /** Services required by the tools. */
9
- export const inject = ['tools', 'systemPrompt', 'workingDirectory']
10
-
11
- export const Config = z.object({
12
- esPath: z.string().default('').description('Absolute path to es.exe; empty auto-detects PATH and common install folders.'),
13
- anytxtUrl: z.string().default('http://127.0.0.1:9920').description('Local Anytxt JSON-RPC endpoint; :9921 is tried as a fallback.'),
14
- workspaceRoot: z.string().default('').description('Root for scope=workspace; empty uses the session working directory.'),
15
- workRoot: z.string().default('').description('Root for scope=work; required before using that scope.'),
16
- personalRoot: z.string().default('').description('Root for scope=personal; required before using that scope.'),
17
- allRoots: z.string().default('').description('Semicolon-separated roots for Anytxt scope=all; empty uses C: and D: when present.'),
18
- maxResults: z.number().default(20).description('Default result cap; the hard cap is 100.'),
19
- timeoutMs: z.number().default(30000).description('Per-call timeout budget in milliseconds.'),
20
- readMaxChars: z.number().default(8000).description('Default local_search_read window; the hard cap is 20,000 characters.'),
21
- esEncoding: z.string().default('gbk').description('Code page used to decode es.exe output; zh-CN uses gbk.'),
22
- })
23
-
24
- function assertPositiveInteger(field, value) {
25
- if (!Number.isInteger(value) || value < 1) {
26
- throw new Error(`dsh-local-search: ${field} must be a positive integer`)
27
- }
28
- }
29
-
30
- /**
31
- * Validate a candidate config. Shared by activation and the future config
32
- * page's save/test path; the loader may pass a fresh object on HMR.
33
- */
34
- export function validateConfig(config) {
35
- assertPositiveInteger('maxResults', config.maxResults)
36
- assertPositiveInteger('timeoutMs', config.timeoutMs)
37
- assertPositiveInteger('readMaxChars', config.readMaxChars)
38
- if (config.timeoutMs > MAX_TIMER_DELAY_MS) {
39
- throw new Error(`dsh-local-search: timeoutMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
40
- }
41
- if (!/^https?:\/\//i.test(config.anytxtUrl)) {
42
- throw new Error('dsh-local-search: anytxtUrl must start with http:// or https://')
43
- }
44
- return config
45
- }
46
-
47
- /**
48
- * Register the five local-search tools and their routing guidance.
49
- */
50
- export async function apply(ctx, config) {
51
- validateConfig(config)
52
-
53
- ctx.effect(() => {
54
- const disposers = createTools(ctx, config).map((tool) => ctx.tools.register(tool))
55
- return () => {
56
- for (const dispose of disposers) dispose()
57
- }
58
- })
59
-
60
- ctx.effect(() =>
61
- ctx.systemPrompt.section({
62
- name: 'tool:local_search',
63
- order: 106,
64
- text:
65
- 'Use local_search_files for file/folder names or paths, especially outside the workspace or across drives. ' +
66
- 'Use local_search_content for text inside PDF/Office/EPUB/TXT/MD documents or a whole-disk content search, then local_search_fragments or local_search_read for detail. ' +
67
- 'Keep grep/glob for source code and plain text already in the workspace. ' +
68
- 'If a search reports EVERYTHING_DOWN or ANYTXT_DOWN, fall back to grep/glob and say that you fell back.',
69
- }),
70
- )
71
- }
1
+ import z from '@deepseek-ai/schemastery'
2
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
3
+ import { createTools } from './tools.js'
4
+ import { validateConfig } from './config.js'
5
+ import { registerStatusRoute } from './route.js'
6
+ import { MAX_LIMIT, MAX_READ } from './limits.js'
7
+
8
+ /** Cordis plugin name used by loader diagnostics. */
9
+ export const name = 'local-search'
10
+
11
+ /** Services required by the tools. */
12
+ export const inject = ['tools', 'systemPrompt', 'workingDirectory']
13
+
14
+ // Every field the Plugins page edits is volatile: that is what makes the
15
+ // settings namespace served and a page write accepted. timeoutMs stays a
16
+ // fixed number because tool registration captures it (see lib/config.js).
17
+ // esEncoding stays a plain string on purpose: 0.1 accepted any label and fell
18
+ // back to UTF-8 in `decodeEs`, so narrowing it to a union would refuse to
19
+ // activate a profile that worked yesterday. The page offers the two labels
20
+ // that matter; YAML keeps the old tolerance.
21
+ export const Config = z.object({
22
+ esPath: z.string().default('').volatile().description('Absolute path to es.exe; empty auto-detects PATH and common install folders.'),
23
+ anytxtUrl: z.string().default('http://127.0.0.1:9920').volatile().description('Local Anytxt JSON-RPC endpoint on this machine (127.0.0.1, localhost or [::1]); :9921 is tried as a fallback.'),
24
+ workspaceRoot: z.string().default('').volatile().description('Root for scope=workspace; empty uses the session working directory.'),
25
+ workRoot: z.string().default('').volatile().description('Root for scope=work; required before using that scope.'),
26
+ personalRoot: z.string().default('').volatile().description('Root for scope=personal; required before using that scope.'),
27
+ allRoots: z.string().default('').volatile().description('Semicolon-separated roots for Anytxt scope=all; empty uses C: and D: when present.'),
28
+ maxResults: z.number().min(1).max(MAX_LIMIT).default(20).volatile().description(`Default result cap; the hard cap is ${MAX_LIMIT}.`),
29
+ timeoutMs: z.number().default(30000).description('Per-call timeout budget in milliseconds; profile YAML only, because each tool fixes its deadline at registration.'),
30
+ readMaxChars: z.number().min(1).max(MAX_READ).default(8000).volatile().description(`Default local_search_read window; the hard cap is ${MAX_READ} characters.`),
31
+ esEncoding: z.string().default('gbk').volatile().description('Code page used to decode es.exe output: gbk for a zh-CN console, utf-8 otherwise. Any TextDecoder label is accepted; the page offers these two.'),
32
+ })
33
+
34
+ export { validateConfig }
35
+
36
+ /**
37
+ * Register the five local-search tools, their routing guidance, and the
38
+ * configuration page's probe route.
39
+ * @param {import('@deepseek-ai/cordis').Context} ctx
40
+ * @param {object} config - schema output; volatile fields are live references
41
+ */
42
+ export async function apply(ctx, config) {
43
+ validateConfig(config, MAX_TIMER_DELAY_MS)
44
+
45
+ ctx.effect(() => {
46
+ const disposers = createTools(ctx, config).map((tool) => ctx.tools.register(tool))
47
+ return () => {
48
+ for (const dispose of disposers) dispose()
49
+ }
50
+ })
51
+
52
+ // Routing hint, not a tool catalogue: each tool description already says what
53
+ // it is for, so this only orders the choice and names the fallback. Four
54
+ // clauses, ~250 characters — half the previous text for the same decision.
55
+ ctx.effect(() =>
56
+ ctx.systemPrompt.section({
57
+ name: 'tool:local_search',
58
+ order: 106,
59
+ text:
60
+ 'Names anywhere outside the workspace: local_search_files. ' +
61
+ 'Document text (PDF/Office/EPUB, whole disk): local_search_content, then fragments/read. ' +
62
+ 'Workspace code and text: grep/glob. ' +
63
+ 'On *_DOWN, fall back to grep/glob and say so.',
64
+ }),
65
+ )
66
+
67
+ // The configuration page's Test control: the browser cannot call the local
68
+ // Anytxt JSON-RPC itself, so the Host answers for it. A deployment without a
69
+ // web server simply has no Test control and no route.
70
+ ctx.inject(['webServer'], (webCtx) => {
71
+ const server = webCtx.get('webServer')
72
+ webCtx.effect(
73
+ () => registerStatusRoute(server, config),
74
+ 'local-search: configuration page probe route',
75
+ )
76
+ })
77
+ }
package/lib/limits.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The hard caps one call may ask for.
3
+ *
4
+ * The schema caps the saved default, the tools clamp a per-call argument, and
5
+ * the configuration page mirrors the same numbers so a save cannot be rejected.
6
+ * They live here to stay one value; the page cannot import this file (a classic
7
+ * script has no ESM imports), so its copy is checked by a test instead.
8
+ */
9
+
10
+ /** Hard cap on a result count. */
11
+ export const MAX_LIMIT = 100
12
+
13
+ /** Hard cap on characters one read returns. */
14
+ export const MAX_READ = 20000
package/lib/probe.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * One probe of both local indexes, shared by the model-facing
3
+ * `local_search_status` tool and the configuration page's Test control.
4
+ *
5
+ * They answer the same question — "can this machine reach its indexes right
6
+ * now?" — so they must not drift into two slightly different probes. The
7
+ * probe goes through the engine registry, so a new backend is still one entry
8
+ * in `lib/engines.js`.
9
+ */
10
+
11
+ import { readConfig } from './config.js'
12
+ import { resolveContentEngine, resolveFilesEngine } from './engines.js'
13
+ import { everythingInstalled } from './everything.js'
14
+
15
+ /**
16
+ * Which link of the Everything chain is missing.
17
+ *
18
+ * This detail is for the person reading the configuration page, who can install
19
+ * or start something; the model keeps the two stable codes. When the install
20
+ * check itself could not run, the answer is `unknown` and the page falls back to
21
+ * its generic hint rather than telling someone to install what they already have.
22
+ * @param {string} code - the error code the probe produced
23
+ * @returns {'not-installed'|'not-running'|'es-missing'|'unknown'} the actionable cause
24
+ */
25
+ export function diagnoseEverything(code, presence = everythingInstalled()) {
26
+ if (!presence.known) return 'unknown'
27
+ if (!presence.installed) return 'not-installed'
28
+ return code === 'EVERYTHING_ES_NOT_FOUND' ? 'es-missing' : 'not-running'
29
+ }
30
+
31
+ /**
32
+ * Probe the configured indexes once, never throwing.
33
+ * @param {object} config - schema output, volatile references included
34
+ * @param {object} [options]
35
+ * @param {string} [options.only] - `auto` probes both; `anytxt` / `everything` skips the other
36
+ * @param {string} [options.workspace] - root Anytxt is asked about
37
+ * @param {string} [options.anytxtUrl] - endpoint to probe instead of the saved one (a page draft)
38
+ * @param {boolean} [options.detail] - add the page-facing cause to a failed engine
39
+ * @param {AbortSignal} [options.signal] - caller cancellation
40
+ * @returns {Promise<{everything: object, anytxt: object}>} both statuses
41
+ */
42
+ export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl, signal, detail = false } = {}) {
43
+ const cfg = readConfig(config)
44
+ const everything = { ok: false }
45
+ const anytxt = { ok: false }
46
+ const budget = Number.isFinite(cfg.timeoutMs) ? cfg.timeoutMs : 5000
47
+ // A probe is a diagnostic, not a search: it gets a floor as well as a ceiling,
48
+ // because a tiny timeoutMs would report a healthy engine as DOWN.
49
+ const probeBudget = Math.max(1500, Math.min(budget, 5000))
50
+
51
+ if (only !== 'anytxt') {
52
+ try {
53
+ Object.assign(everything, await resolveFilesEngine('everything').status({
54
+ config: cfg,
55
+ esPath: cfg.esPath,
56
+ timeoutMs: probeBudget,
57
+ signal,
58
+ encoding: cfg.esEncoding,
59
+ }))
60
+ } catch (error) {
61
+ everything.code = error.code || 'EVERYTHING_DOWN'
62
+ everything.message = error.message
63
+ if (detail && everything.code !== 'EVERYTHING_ABORTED') everything.detail = diagnoseEverything(everything.code)
64
+ }
65
+ } else {
66
+ everything.code = 'SKIPPED'
67
+ everything.message = 'not checked because engine=anytxt'
68
+ }
69
+
70
+ if (only !== 'everything') {
71
+ try {
72
+ Object.assign(anytxt, await resolveContentEngine('anytxt').status({
73
+ url: anytxtUrl || cfg.anytxtUrl,
74
+ root: workspace || cfg.workspaceRoot || process.cwd(),
75
+ timeoutMs: probeBudget,
76
+ signal,
77
+ }))
78
+ } catch (error) {
79
+ anytxt.code = error.code || 'ANYTXT_DOWN'
80
+ anytxt.message = error.message
81
+ }
82
+ } else {
83
+ anytxt.code = 'SKIPPED'
84
+ anytxt.message = 'not checked because engine=everything'
85
+ }
86
+
87
+ return { everything, anytxt }
88
+ }
package/lib/ref.js CHANGED
@@ -1,38 +1,38 @@
1
- import { EngineError } from './errors.js'
2
-
3
- /**
4
- * Refs are self-describing: `<engine>:<id>`. This keeps a Recoll docid, an
5
- * Everything fid, and a future Windows Search item from colliding when the
6
- * model passes a stale ref back to local_search_read/local_search_fragments.
7
- */
8
- export function formatRef(engine, id) {
9
- if (!engine || typeof engine !== 'string') {
10
- throw new EngineError('engine name is required to format a ref', 'INVALID_REF')
11
- }
12
- if (id === undefined || id === null || String(id).length === 0) {
13
- throw new EngineError('ref id is required', 'INVALID_REF')
14
- }
15
- return `${engine}:${String(id)}`
16
- }
17
-
18
- /** Parse `<engine>:<id>`; reject bare ids with an actionable hint. */
19
- export function parseRef(ref) {
20
- const text = String(ref ?? '')
21
- const separator = text.indexOf(':')
22
- if (separator <= 0 || separator === text.length - 1) {
23
- throw new EngineError(`ref must look like "<engine>:<id>", got "${text}"`, 'INVALID_REF', {
24
- hint: 'Rerun local_search_content and pass the item ref unchanged.',
25
- })
26
- }
27
- return { engine: text.slice(0, separator), id: text.slice(separator + 1) }
28
- }
29
-
30
- /** Ensure a ref belongs to the resolved engine. */
31
- export function assertRefEngine(parsed, engineName) {
32
- if (parsed.engine !== engineName) {
33
- throw new EngineError(`ref belongs to ${parsed.engine}, not ${engineName}`, 'INVALID_REF', {
34
- hint: 'Pass the item ref and its engine together, unchanged.',
35
- })
36
- }
37
- return parsed
38
- }
1
+ import { EngineError } from './errors.js'
2
+
3
+ /**
4
+ * Refs are self-describing: `<engine>:<id>`. This keeps a Recoll docid, an
5
+ * Everything fid, and a future Windows Search item from colliding when the
6
+ * model passes a stale ref back to local_search_read/local_search_fragments.
7
+ */
8
+ export function formatRef(engine, id) {
9
+ if (!engine || typeof engine !== 'string') {
10
+ throw new EngineError('engine name is required to format a ref', 'INVALID_REF')
11
+ }
12
+ if (id === undefined || id === null || String(id).length === 0) {
13
+ throw new EngineError('ref id is required', 'INVALID_REF')
14
+ }
15
+ return `${engine}:${String(id)}`
16
+ }
17
+
18
+ /** Parse `<engine>:<id>`; reject bare ids with an actionable hint. */
19
+ export function parseRef(ref) {
20
+ const text = String(ref ?? '')
21
+ const separator = text.indexOf(':')
22
+ if (separator <= 0 || separator === text.length - 1) {
23
+ throw new EngineError(`ref must look like "<engine>:<id>", got "${text}"`, 'INVALID_REF', {
24
+ hint: 'Rerun local_search_content and pass the item ref unchanged.',
25
+ })
26
+ }
27
+ return { engine: text.slice(0, separator), id: text.slice(separator + 1) }
28
+ }
29
+
30
+ /** Ensure a ref belongs to the resolved engine. */
31
+ export function assertRefEngine(parsed, engineName) {
32
+ if (parsed.engine !== engineName) {
33
+ throw new EngineError(`ref belongs to ${parsed.engine}, not ${engineName}`, 'INVALID_REF', {
34
+ hint: 'Pass the item ref and its engine together, unchanged.',
35
+ })
36
+ }
37
+ return parsed
38
+ }