md-semantic-search 1.0.2 → 1.0.3

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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Shell completions generator (issue #107).
3
+ *
4
+ * Single source of truth: the FLAGS table below drives every shell script AND
5
+ * the drift-guard test (test/completions.test.mjs) that diffs it against the
6
+ * flags actually parsed in bin/cli.mjs. Adding a flag in parseArgs without
7
+ * updating this table fails the test — and vice versa.
8
+ *
9
+ * Static generation only: no runtime dependency, `mdss completions <shell>`
10
+ * prints a self-contained script to stdout.
11
+ */
12
+ /** Every option bin/cli.mjs parseArgs understands (issue #107 drift guard). */
13
+ export const FLAGS = [
14
+ // boolean switches
15
+ { name: '--json', kind: 'flag' },
16
+ { name: '--semantic', kind: 'flag' },
17
+ { name: '--offline', kind: 'flag' },
18
+ { name: '--watch', kind: 'flag' },
19
+ { name: '--watch-debug', kind: 'flag' },
20
+ { name: '--no-ui', kind: 'flag' },
21
+ { name: '--mcp', kind: 'flag' },
22
+ { name: '--rerank', kind: 'flag' },
23
+ { name: '--explain', kind: 'flag' },
24
+ { name: '--interactive', alias: '-i', kind: 'flag' },
25
+ { name: '--list-tools', kind: 'flag' },
26
+ { name: '--no-vectors', alias: '--no-vector', kind: 'flag' },
27
+ { name: '--no-query-cache', kind: 'flag' },
28
+ { name: '--recency', kind: 'number' },
29
+ { name: '--embedder', kind: 'value' },
30
+ { name: '--embedder-model', kind: 'value' },
31
+ { name: '--embedder-base-url', kind: 'value' },
32
+ { name: '--embedder-key-file', kind: 'file' },
33
+ { name: '--expand', kind: 'value' },
34
+ { name: '--expand-passages', kind: 'number' },
35
+ { name: '--health-public', kind: 'flag' },
36
+ { name: '--allow-unsecured', kind: 'flag' },
37
+ { name: '--ann', kind: 'flag' },
38
+ { name: '--canonical', kind: 'flag' },
39
+ { name: '--rag', kind: 'flag' },
40
+ { name: '--auto-tag', kind: 'flag' },
41
+ { name: '--auto-summarize', kind: 'flag' },
42
+ { name: '--help', alias: '-h', kind: 'flag' },
43
+ { name: '--version', kind: 'flag' },
44
+ { name: '--open', kind: 'flag' }, // optional N consumed by parseArgs itself
45
+ { name: '--fix', kind: 'flag' },
46
+ { name: '--dry-run', kind: 'flag' },
47
+ // values
48
+ { name: '--workers', kind: 'number' },
49
+ { name: '--config', kind: 'file' },
50
+ { name: '--format', kind: 'value' },
51
+ { name: '--output', alias: '-o', kind: 'file' },
52
+ { name: '--db', kind: 'dir' },
53
+ { name: '--vault', kind: 'dir' },
54
+ { name: '--index-dir', kind: 'dir' },
55
+ { name: '--cache-dir', kind: 'dir' },
56
+ { name: '--model', kind: 'value' },
57
+ { name: '--k', kind: 'number' },
58
+ { name: '--max-per-file', kind: 'number' },
59
+ { name: '--max-per-doc', kind: 'number' },
60
+ { name: '--target-tokens', kind: 'number' },
61
+ { name: '--port', kind: 'number' },
62
+ { name: '--host', kind: 'value' },
63
+ { name: '--api-key', kind: 'value' },
64
+ { name: '--api-key-file', kind: 'file' },
65
+ { name: '--allowed-host', kind: 'value' },
66
+ { name: '--cors-origin', kind: 'value' },
67
+ { name: '--watch-delay', kind: 'number' },
68
+ { name: '--watch-interval', kind: 'number' },
69
+ { name: '--rate-limit', kind: 'number' },
70
+ { name: '--max-concurrency', kind: 'number' },
71
+ { name: '--since', kind: 'value' },
72
+ { name: '--path', kind: 'value' },
73
+ { name: '--ignore', kind: 'value' },
74
+ { name: '--tag', kind: 'value' },
75
+ { name: '--nprobe', kind: 'number' },
76
+ { name: '--project', kind: 'value' },
77
+ { name: '--type', kind: 'value' },
78
+ { name: '--status', kind: 'value' },
79
+ { name: '--graph-boost', kind: 'number' },
80
+ { name: '--filter', kind: 'value' },
81
+ { name: '--quantize', kind: 'value' },
82
+ { name: '--llm-endpoint', kind: 'value' },
83
+ { name: '--llm-model', kind: 'value' },
84
+ { name: '--system-prompt', kind: 'value' },
85
+ ];
86
+ export const COMMANDS = [
87
+ 'index', 'stats', 'check', 'doctor', 'export', 'search',
88
+ 'ask', 'tui', 'serve', 'mcp', 'models', 'completions',
89
+ ];
90
+ export const COMPLETION_SHELLS = ['bash', 'zsh', 'fish', 'powershell'];
91
+ const DIR_FLAGS = [...new Set(FLAGS.filter((f) => f.kind === 'dir').flatMap((f) => [f.name, ...(f.alias && !f.alias.startsWith('--') ? [f.alias] : [])]))];
92
+ const FILE_FLAGS = [...new Set(FLAGS.filter((f) => f.kind === 'file').flatMap((f) => [f.name, ...(f.alias && !f.alias.startsWith('--') ? [f.alias] : [])]))];
93
+ const ALL_FLAG_WORDS = FLAGS.flatMap((f) => [f.name, ...(f.alias && f.alias.startsWith('--') ? [f.alias] : [])]);
94
+ function bash() {
95
+ const dirCase = DIR_FLAGS.join('|');
96
+ const fileCase = FILE_FLAGS.join('|');
97
+ return `# bash completion for mdss (md-semantic-search)
98
+ # Generated by \`mdss completions bash\` (issue #107).
99
+ # Install: eval "$(mdss completions bash)"
100
+ _mdss_completion() {
101
+ local cur prev commands flags
102
+ cur="\${COMP_WORDS[COMP_CWORD]}"
103
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
104
+ commands="${COMMANDS.join(' ')}"
105
+ flags="${ALL_FLAG_WORDS.join(' ')}"
106
+
107
+ case "$prev" in
108
+ ${dirCase})
109
+ mapfile -t COMPREPLY < <(compgen -d -- "$cur"); return 0 ;;
110
+ ${fileCase})
111
+ mapfile -t COMPREPLY < <(compgen -f -- "$cur"); return 0 ;;
112
+ esac
113
+
114
+ if [[ "$cur" == -* ]]; then
115
+ mapfile -t COMPREPLY < <(compgen -W "$flags" -- "$cur")
116
+ return 0
117
+ fi
118
+
119
+ if (( COMP_CWORD <= 2 )); then
120
+ mapfile -t COMPREPLY < <(compgen -W "$commands" -- "$cur")
121
+ fi
122
+ }
123
+ complete -o default -F _mdss_completion mdss md-semantic-search
124
+ `;
125
+ }
126
+ function zsh() {
127
+ const cmdDescs = COMMANDS.map((c) => `'${c}'`).join(' ');
128
+ return `#compdef mdss
129
+ # zsh completion for mdss — generated by \`mdss completions zsh\` (issue #107).
130
+ # Install: mkdir -p ~/.zfunc && mdss completions zsh > ~/.zfunc/_mdss
131
+ # (ensure ~/.zfunc is in fpath BEFORE compinit)
132
+ local -a commands options dir_flags file_flags
133
+ commands=(${cmdDescs})
134
+ options=(${ALL_FLAG_WORDS.map((f) => `'${f}'`).join(' ')})
135
+ dir_flags=(${DIR_FLAGS.map((f) => `'${f}'`).join(' ')})
136
+ file_flags=(${FILE_FLAGS.map((f) => `'${f}'`).join(' ')})
137
+
138
+ _mdss() {
139
+ local cur prev
140
+ cur="\${words[CURRENT]}"
141
+ prev="\${words[CURRENT-1]}"
142
+ if (( CURRENT == 2 )) && [[ "$cur" != -* ]]; then
143
+ _describe -t commands 'mdss command' commands
144
+ return
145
+ fi
146
+ if (( \${dir_flags[(Ie)$prev]} )); then
147
+ _directories
148
+ return
149
+ fi
150
+ if (( \${file_flags[(Ie)$prev]} )); then
151
+ _files
152
+ return
153
+ fi
154
+ if [[ "$cur" == -* ]]; then
155
+ _describe -t options 'mdss option' options
156
+ return
157
+ fi
158
+ }
159
+ compdef _mdss mdss md-semantic-search
160
+ `;
161
+ }
162
+ function fish() {
163
+ const lines = [
164
+ '# fish completion for mdss — generated by `mdss completions fish` (issue #107).',
165
+ '# Install: mdss completions fish > ~/.config/fish/completions/mdss.fish',
166
+ '',
167
+ `complete -c mdss -n '__fish_is_first_arg' -a '${COMMANDS.join(' ')}'`,
168
+ '',
169
+ ];
170
+ const seenShorts = new Set();
171
+ for (const f of FLAGS) {
172
+ const parts = ['complete -c mdss'];
173
+ if (f.alias && !f.alias.startsWith('--') && !seenShorts.has(f.alias)) {
174
+ parts.push(`-s ${f.alias.slice(1)}`);
175
+ seenShorts.add(f.alias);
176
+ }
177
+ parts.push(`-l ${f.name.slice(2)}`);
178
+ if (f.kind !== 'flag')
179
+ parts.push('-r');
180
+ if (f.kind === 'file')
181
+ parts.push('-F');
182
+ if (f.kind === 'dir')
183
+ parts.push('-a "(__fish_complete_directories)"');
184
+ lines.push(parts.join(' '));
185
+ }
186
+ return lines.join('\n') + '\n';
187
+ }
188
+ function powershell() {
189
+ return `# PowerShell completion for mdss — generated by \`mdss completions powershell\` (issue #107).
190
+ # Install: paste into your $PROFILE (or dot-source it from there).
191
+ Register-ArgumentCompleter -CommandName 'mdss','md-semantic-search' -Native -ScriptBlock {
192
+ param($wordToComplete, $commandAst, $cursorPosition)
193
+ $commands = @('${COMMANDS.join("','")}')
194
+ $dirFlags = @('${DIR_FLAGS.join("','")}')
195
+ $fileFlags = @('${FILE_FLAGS.join("','")}')
196
+ $flags = @('${ALL_FLAG_WORDS.join("','")}')
197
+
198
+ $elements = @($commandAst.CommandElements)
199
+ if ($elements.Count -le 1) {
200
+ $commands | Where-Object { $_ -like "$wordToComplete*" } |
201
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'Command', $_) }
202
+ return
203
+ }
204
+
205
+ $prev = ''
206
+ if ($elements.Count -ge 2) { $prev = $elements[$elements.Count - 2].ToString() }
207
+
208
+ if (($dirFlags -contains $prev) -or ($fileFlags -contains $prev)) {
209
+ return # defer path completion to PowerShell's native provider
210
+ }
211
+
212
+ if ("$wordToComplete".StartsWith('-')) {
213
+ $flags | Where-Object { $_ -like "$wordToComplete*" } |
214
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) }
215
+ return
216
+ }
217
+
218
+ if ($elements.Count -eq 2) {
219
+ $commands | Where-Object { $_ -like "$wordToComplete*" } |
220
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'Command', $_) }
221
+ }
222
+ }
223
+ `;
224
+ }
225
+ const GENERATORS = { bash, zsh, fish, powershell };
226
+ /** Generate a self-contained completion script; throws on an unknown shell. */
227
+ export function generateCompletion(shell) {
228
+ const gen = GENERATORS[shell];
229
+ if (!gen) {
230
+ throw new Error(`unknown shell "${shell}" — use one of: ${COMPLETION_SHELLS.join('|')}`);
231
+ }
232
+ return gen();
233
+ }
package/dist/core.d.ts CHANGED
@@ -4,6 +4,11 @@ import { parseFrontmatter, DocumentMetadata } from './frontmatter.js';
4
4
  import { asymmetricCosineInt8 } from './quantization.js';
5
5
  export { PARSER_VERSION, parseFrontmatter, resolveModel, DocumentMetadata, ModelAdapter, Pooling, DType, asymmetricCosineInt8 };
6
6
  export declare const LOCK_FILENAME = ".mdss.lock";
7
+ export type DownloadProgressListener = (e: import('./download-progress.js').DownloadProgressEvent) => void;
8
+ /** Observe model-file download events from every pipeline load (issue #108). Pass null to detach. */
9
+ export declare function setDownloadProgressListener(cb: DownloadProgressListener | null): void;
10
+ /** Low-level emit used by the loaders; exported so tests/tools can simulate events. */
11
+ export declare function emitDownloadEvent(e: import('./download-progress.js').DownloadProgressEvent): void;
7
12
  export declare function pidAlive(pid: number | null): boolean;
8
13
  export interface LockInfo {
9
14
  pid: number;
package/dist/core.js CHANGED
@@ -13,6 +13,22 @@ export { PARSER_VERSION, parseFrontmatter, resolveModel, asymmetricCosineInt8 };
13
13
  const _extractors = new Map();
14
14
  export const LOCK_FILENAME = '.mdss.lock';
15
15
  const LOCK_STALE_MS = 10 * 60 * 1000;
16
+ let downloadListener = null;
17
+ /** Observe model-file download events from every pipeline load (issue #108). Pass null to detach. */
18
+ export function setDownloadProgressListener(cb) {
19
+ downloadListener = typeof cb === 'function' ? cb : null;
20
+ }
21
+ /** Low-level emit used by the loaders; exported so tests/tools can simulate events. */
22
+ export function emitDownloadEvent(e) {
23
+ if (!downloadListener)
24
+ return;
25
+ try {
26
+ downloadListener(e);
27
+ }
28
+ catch {
29
+ // a broken UI listener must never abort an in-flight model download
30
+ }
31
+ }
16
32
  export function pidAlive(pid) {
17
33
  if (!Number.isInteger(pid) || pid === null || pid <= 0)
18
34
  return false;
@@ -217,6 +233,7 @@ export async function getExtractor(model, cacheDir, offline = false, retryOpts =
217
233
  const pipeOpts = {
218
234
  revision,
219
235
  dtype: model.dtype || 'q8',
236
+ progress_callback: emitDownloadEvent,
220
237
  ...(sessionOptions ? { session_options: sessionOptions } : {}),
221
238
  };
222
239
  const ext = await pipeline('feature-extraction', source, pipeOpts);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Aggregated model-download progress (issue #108).
3
+ *
4
+ * transformers.js emits per-file events (`initiate`/`download`/`progress`/`done`)
5
+ * while a model directory streams in. A model is many files, so a per-file bar
6
+ * flickers uselessly — this aggregator folds them into ONE view: percent across
7
+ * every tracked file, smoothed MB/s, and an ETA over the bytes still missing.
8
+ *
9
+ * The "sawPartial" gate implements the warm-cache acceptance criterion: cached
10
+ * loads surface only instant complete-file events, so unless a real partial
11
+ * transfer was observed the aggregator stays silent.
12
+ */
13
+ export interface DownloadProgressEvent {
14
+ status?: string;
15
+ name?: string;
16
+ file?: string;
17
+ loaded?: number;
18
+ total?: number;
19
+ progress?: number;
20
+ }
21
+ export interface DownloadSnapshot {
22
+ /** True once a partial transfer was observed — gates ALL output. */
23
+ reportable: boolean;
24
+ /** True when every file with a known total has fully arrived. */
25
+ complete: boolean;
26
+ doneFiles: number;
27
+ activeFiles: number;
28
+ loadedBytes: number;
29
+ totalBytes: number;
30
+ /** 0..100 across files with known totals; null while no total is known yet. */
31
+ percent: number | null;
32
+ mbps: number | null;
33
+ etaSec: number | null;
34
+ currentFile: string | null;
35
+ }
36
+ export declare class DownloadProgressAggregator {
37
+ private files;
38
+ private sawPartial;
39
+ private lastSampleAt;
40
+ private lastSampleBytes;
41
+ private mbpsEma;
42
+ private lastRenderAt;
43
+ update(e: DownloadProgressEvent, nowMs?: number): void;
44
+ private sumLoaded;
45
+ snapshot(): DownloadSnapshot;
46
+ /**
47
+ * Render throttle for live views: pass through at most one line per
48
+ * interval, plus ALWAYS the transition into `complete`.
49
+ */
50
+ shouldRender(nowMs: number, minIntervalMs?: number): boolean;
51
+ private renderedComplete;
52
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Aggregated model-download progress (issue #108).
3
+ *
4
+ * transformers.js emits per-file events (`initiate`/`download`/`progress`/`done`)
5
+ * while a model directory streams in. A model is many files, so a per-file bar
6
+ * flickers uselessly — this aggregator folds them into ONE view: percent across
7
+ * every tracked file, smoothed MB/s, and an ETA over the bytes still missing.
8
+ *
9
+ * The "sawPartial" gate implements the warm-cache acceptance criterion: cached
10
+ * loads surface only instant complete-file events, so unless a real partial
11
+ * transfer was observed the aggregator stays silent.
12
+ */
13
+ const SPEED_SMOOTHING = 0.3; // EMA weight of the newest instantaneous sample
14
+ export class DownloadProgressAggregator {
15
+ files = new Map();
16
+ sawPartial = false;
17
+ lastSampleAt = 0;
18
+ lastSampleBytes = 0;
19
+ mbpsEma = null;
20
+ lastRenderAt = 0;
21
+ update(e, nowMs = Date.now()) {
22
+ if (!e || typeof e.file !== 'string' || e.file.length === 0)
23
+ return;
24
+ let f = this.files.get(e.file);
25
+ if (!f) {
26
+ f = { loaded: 0, total: 0 };
27
+ this.files.set(e.file, f);
28
+ }
29
+ if (typeof e.total === 'number' && Number.isFinite(e.total) && e.total > f.total) {
30
+ f.total = e.total;
31
+ }
32
+ if (typeof e.loaded === 'number' && Number.isFinite(e.loaded) && e.loaded >= 0) {
33
+ // clamp against the known total so a stray event can't overshoot to >100%
34
+ f.loaded = f.total > 0 ? Math.min(e.loaded, f.total) : e.loaded;
35
+ }
36
+ const midTransfer = (e.status === 'download' || e.status === 'progress') && f.total > 0 && f.loaded < f.total;
37
+ if (midTransfer)
38
+ this.sawPartial = true;
39
+ // speed sampling on cumulative byte growth
40
+ const sum = this.sumLoaded();
41
+ if (this.lastSampleAt === 0) {
42
+ this.lastSampleAt = nowMs;
43
+ this.lastSampleBytes = sum;
44
+ }
45
+ else if (sum > this.lastSampleBytes) {
46
+ const dtSec = (nowMs - this.lastSampleAt) / 1000;
47
+ if (dtSec > 0.05) {
48
+ const inst = (sum - this.lastSampleBytes) / dtSec / (1024 * 1024);
49
+ this.mbpsEma = this.mbpsEma === null ? inst : this.mbpsEma * (1 - SPEED_SMOOTHING) + inst * SPEED_SMOOTHING;
50
+ this.lastSampleAt = nowMs;
51
+ this.lastSampleBytes = sum;
52
+ }
53
+ }
54
+ }
55
+ sumLoaded() {
56
+ let sum = 0;
57
+ for (const f of this.files.values())
58
+ sum += f.loaded;
59
+ return sum;
60
+ }
61
+ snapshot() {
62
+ let loadedBytes = 0;
63
+ let totalBytes = 0;
64
+ let doneFiles = 0;
65
+ let activeFiles = 0;
66
+ for (const f of this.files.values()) {
67
+ loadedBytes += f.loaded;
68
+ if (f.total > 0) {
69
+ totalBytes += f.total;
70
+ if (f.loaded >= f.total)
71
+ doneFiles += 1;
72
+ else
73
+ activeFiles += 1;
74
+ }
75
+ }
76
+ const percent = totalBytes > 0 ? Math.min(100, (loadedBytes / totalBytes) * 100) : null;
77
+ const remaining = totalBytes - loadedBytes;
78
+ const etaSec = this.mbpsEma !== null && this.mbpsEma > 0 && remaining > 0 ? remaining / (1024 * 1024) / this.mbpsEma : null;
79
+ return {
80
+ reportable: this.sawPartial,
81
+ complete: totalBytes > 0 && activeFiles === 0,
82
+ doneFiles,
83
+ activeFiles,
84
+ loadedBytes,
85
+ totalBytes,
86
+ percent,
87
+ mbps: this.sawPartial ? this.mbpsEma : null,
88
+ etaSec: this.sawPartial ? etaSec : null,
89
+ currentFile: [...this.files.entries()].find(([, f]) => f.total === 0 || f.loaded < f.total)?.[0] ?? null,
90
+ };
91
+ }
92
+ /**
93
+ * Render throttle for live views: pass through at most one line per
94
+ * interval, plus ALWAYS the transition into `complete`.
95
+ */
96
+ shouldRender(nowMs, minIntervalMs = 400) {
97
+ if (!this.sawPartial)
98
+ return false;
99
+ const justCompleted = this.snapshot().complete && !this.renderedComplete;
100
+ if (justCompleted) {
101
+ this.renderedComplete = true;
102
+ this.lastRenderAt = nowMs;
103
+ return true;
104
+ }
105
+ if (nowMs - this.lastRenderAt >= minIntervalMs) {
106
+ this.lastRenderAt = nowMs;
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ renderedComplete = false;
112
+ }
package/dist/index.d.ts CHANGED
@@ -35,3 +35,5 @@ export { createFileWatcher, FileWatcher, WatcherOptions, } from './watcher.js';
35
35
  export { askQuestion, synthesizeAnswer, extractAnswerFallback, AskOptions, SynthesisResult, Citation, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
36
36
  export { extractKeyphraseTags, summarizePassage, analyzeNote, NoteAnalysis, } from './summarize.js';
37
37
  export { runTui, TuiOptions } from './tui.js';
38
+ export { generateCompletion, FLAGS, COMMANDS, COMPLETION_SHELLS, } from './completions.js';
39
+ export type { FlagSpec, CompletionShell } from './completions.js';
package/dist/index.js CHANGED
@@ -35,3 +35,4 @@ export { createFileWatcher, } from './watcher.js';
35
35
  export { askQuestion, synthesizeAnswer, extractAnswerFallback, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
36
36
  export { extractKeyphraseTags, summarizePassage, analyzeNote, } from './summarize.js';
37
37
  export { runTui } from './tui.js';
38
+ export { generateCompletion, FLAGS, COMMANDS, COMPLETION_SHELLS, } from './completions.js';
package/dist/mcp.js CHANGED
@@ -11,6 +11,11 @@ import { loadIndex, searchIndex } from './search.js';
11
11
  import { searchFederated } from './federation.js';
12
12
  import { askQuestion } from './rag.js';
13
13
  import { globToRegExp } from './core.js';
14
+ /**
15
+ * Package version reported via MCP initialize → serverInfo.
16
+ * Single source of truth: package.json (resolved relative to dist/mcp.js).
17
+ */
18
+ const MCP_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
14
19
  export const MCP_TOOLS = [
15
20
  {
16
21
  name: 'search_markdown',
@@ -90,7 +95,7 @@ export async function handleMcpRequest(req, state) {
90
95
  },
91
96
  serverInfo: {
92
97
  name: 'md-semantic-search',
93
- version: '1.0.0',
98
+ version: MCP_VERSION,
94
99
  },
95
100
  },
96
101
  };
package/dist/open.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ export interface OpenTarget {
2
+ file: string;
3
+ line?: number;
4
+ }
5
+ export interface OpenCommand {
6
+ command: string;
7
+ args: string[];
8
+ /** Human-readable source of the decision, e.g. "$EDITOR=vim" or "GUI opener". */
9
+ via: string;
10
+ }
11
+ export interface ResolveOptions {
12
+ /** $MDSS_EDITOR / $VISUAL / $EDITOR value, if any. */
13
+ editor?: string | null;
14
+ platform?: NodeJS.Platform;
15
+ }
16
+ export interface RunOptions extends ResolveOptions {
17
+ runner?: OpenRunner;
18
+ }
19
+ export type OpenRunner = (command: string, args: string[], opts: {
20
+ detached: boolean;
21
+ stdio: 'ignore';
22
+ }) => unknown;
23
+ /**
24
+ * Decide HOW to open `target`.
25
+ * - editor given: first token is the binary, remaining tokens are forwarded
26
+ * verbatim. VS Code family gets `--goto file:line`, plus-line editors get
27
+ * `+<line> file`, anything else just gets the file.
28
+ * - No editor configured → platform GUI opener (win32 `cmd /c start ""`,
29
+ * darwin `open`, else `xdg-open`); a line cannot be passed there.
30
+ * Returns null only on an unsupported platform without an editor.
31
+ */
32
+ export declare function resolveOpenCommand(target: OpenTarget, opts?: ResolveOptions): OpenCommand | null;
33
+ /** Launch via the default detached spawn; inject a fake in tests. */
34
+ export declare function defaultOpenRunner(command: string, args: string[], opts: {
35
+ detached: boolean;
36
+ stdio: 'ignore';
37
+ }): void;
38
+ /**
39
+ * Resolve + run. Returns the OpenCommand used (or null when nothing could be
40
+ * resolved). Throws when the runner reports a launch failure — callers turn
41
+ * that into a friendly die().
42
+ */
43
+ export declare function openHit(target: OpenTarget, opts?: RunOptions): OpenCommand | null;
package/dist/open.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Editor jump for search hits (issue #110): open a hit's file at its exact
3
+ * `startLine` in $MDSS_EDITOR / $VISUAL / $EDITOR, with smart argument styles
4
+ * for known editor families and a platform GUI opener as the last resort.
5
+ *
6
+ * Resolution is PURE (unit-testable); launching goes through an injected
7
+ * runner so tests never spawn real processes.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ const VSCODE_FAMILY = /^(code|code-insiders|codium|cursor|windsurf)$/i;
11
+ /** Terminal editors taking a "+<line>" positional before the file. */
12
+ const PLUS_LINE_EDITORS = /^(vim|vi|nvim|nano|ne|emacs|emacsclient|micro|hx)$/i;
13
+ function baseName(cmd) {
14
+ // handle BOTH separators so a Windows full path resolves its family even
15
+ // when mdss runs on POSIX CI (issue #110)
16
+ const last = cmd.split(/[\\/]/).pop() ?? cmd;
17
+ return last.replace(/\.(exe|cmd|bat)$/i, '');
18
+ }
19
+ function splitEditorCommand(editor) {
20
+ // A value containing a path separator is ONE binary path (Windows installs
21
+ // live under "C:\Program Files\..."); only bare names may carry extra flags.
22
+ if (/[/\\]/.test(editor))
23
+ return [editor];
24
+ return editor.trim().split(/\s+/);
25
+ }
26
+ /**
27
+ * Decide HOW to open `target`.
28
+ * - editor given: first token is the binary, remaining tokens are forwarded
29
+ * verbatim. VS Code family gets `--goto file:line`, plus-line editors get
30
+ * `+<line> file`, anything else just gets the file.
31
+ * - No editor configured → platform GUI opener (win32 `cmd /c start ""`,
32
+ * darwin `open`, else `xdg-open`); a line cannot be passed there.
33
+ * Returns null only on an unsupported platform without an editor.
34
+ */
35
+ export function resolveOpenCommand(target, opts = {}) {
36
+ const { editor, platform = process.platform } = opts;
37
+ const line = Number.isInteger(target.line) && target.line > 0 ? target.line : undefined;
38
+ if (editor && editor.trim()) {
39
+ const parts = splitEditorCommand(editor.trim());
40
+ const command = parts[0];
41
+ const extra = parts.slice(1);
42
+ const name = baseName(command);
43
+ if (VSCODE_FAMILY.test(name)) {
44
+ return {
45
+ command,
46
+ args: [...extra, '--goto', `${target.file}${line ? `:${line}` : ''}`],
47
+ via: `$EDITOR=${editor}`,
48
+ };
49
+ }
50
+ if (PLUS_LINE_EDITORS.test(name)) {
51
+ return { command, args: [...extra, ...(line ? [`+${line}`] : []), target.file], via: `$EDITOR=${editor}` };
52
+ }
53
+ return { command, args: [...extra, target.file], via: `$EDITOR=${editor}` };
54
+ }
55
+ if (platform === 'win32') {
56
+ // empty title arg prevents `start` from mangling quoted paths
57
+ return { command: 'cmd', args: ['/c', 'start', '', target.file], via: 'GUI opener' };
58
+ }
59
+ if (platform === 'darwin') {
60
+ return { command: 'open', args: [target.file], via: 'GUI opener' };
61
+ }
62
+ if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
63
+ return { command: 'xdg-open', args: [target.file], via: 'GUI opener' };
64
+ }
65
+ return null;
66
+ }
67
+ /** Launch via the default detached spawn; inject a fake in tests. */
68
+ export function defaultOpenRunner(command, args, opts) {
69
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
70
+ child.unref();
71
+ }
72
+ /**
73
+ * Resolve + run. Returns the OpenCommand used (or null when nothing could be
74
+ * resolved). Throws when the runner reports a launch failure — callers turn
75
+ * that into a friendly die().
76
+ */
77
+ export function openHit(target, opts = {}) {
78
+ const { runner = defaultOpenRunner, ...resolveOpts } = opts;
79
+ const resolved = resolveOpenCommand(target, resolveOpts);
80
+ if (!resolved)
81
+ return null;
82
+ runner(resolved.command, resolved.args, { detached: true, stdio: 'ignore' });
83
+ return resolved;
84
+ }
@@ -0,0 +1,21 @@
1
+ import { ModelAdapter } from './models.js';
2
+ export type EmbedderRole = 'query' | 'passage';
3
+ export type ExternalEmbedFn = (texts: string[], role: EmbedderRole, model: ModelAdapter, cacheDir: string, offline: boolean) => Promise<number[][]>;
4
+ export interface ProviderConfig {
5
+ embedder: 'ollama' | 'openai';
6
+ /** Provider-side model name, e.g. `nomic-embed-text` or `text-embedding-3-small`. */
7
+ model: string;
8
+ baseUrl?: string;
9
+ keyFile?: string;
10
+ /** Injected fetch for tests; defaults to globalThis.fetch. */
11
+ fetchImpl?: typeof fetch;
12
+ }
13
+ export interface ResolvedEmbedder {
14
+ id: string;
15
+ model: string;
16
+ embedFn: ExternalEmbedFn;
17
+ /** Synthetic adapter (probes the endpoint once for dim). Await before indexing. */
18
+ descriptor(): Promise<ModelAdapter>;
19
+ }
20
+ /** Build an external embedder; dim is probed once on first use and cached. */
21
+ export declare function resolveExternalEmbedder(cfg: ProviderConfig): ResolvedEmbedder;