sweet-search 2.4.2 → 2.5.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.
Files changed (43) hide show
  1. package/core/cli.js +19 -5
  2. package/core/embedding/embedding-cache.js +177 -15
  3. package/core/embedding/embedding-service.js +18 -4
  4. package/core/graph/graph-expansion.js +52 -12
  5. package/core/graph/graph-extractor.js +30 -1
  6. package/core/indexing/ast-chunker.js +331 -16
  7. package/core/indexing/chunking/chunk-builder.js +34 -1
  8. package/core/indexing/index.js +6 -3
  9. package/core/indexing/indexer-ann.js +45 -6
  10. package/core/indexing/indexer-build.js +9 -1
  11. package/core/indexing/indexer-phases.js +6 -4
  12. package/core/indexing/indexing-file-policy.js +140 -0
  13. package/core/indexing/li-skip-policy.js +11 -220
  14. package/core/infrastructure/codebase-repository.js +21 -0
  15. package/core/infrastructure/config/embedding.js +20 -1
  16. package/core/infrastructure/config/graph.js +2 -2
  17. package/core/infrastructure/config/ranking.js +10 -0
  18. package/core/infrastructure/config/vector-store.js +1 -1
  19. package/core/infrastructure/coreml-cascade.js +236 -30
  20. package/core/infrastructure/coreml-cascade.json +25 -0
  21. package/core/infrastructure/index.js +15 -0
  22. package/core/infrastructure/init-config.js +78 -0
  23. package/core/infrastructure/language-patterns/registry-core.js +18 -0
  24. package/core/infrastructure/model-registry.js +12 -0
  25. package/core/infrastructure/native-inference.js +143 -51
  26. package/core/infrastructure/tree-sitter-provider.js +92 -2
  27. package/core/ranking/cascaded-scorer.js +6 -2
  28. package/core/ranking/file-kind-ranking.js +264 -0
  29. package/core/ranking/late-interaction-index.js +10 -4
  30. package/core/ranking/late-interaction-policy.js +304 -0
  31. package/core/search/context-expander.js +267 -28
  32. package/core/search/index.js +4 -0
  33. package/core/search/search-cli.js +3 -1
  34. package/core/search/search-pattern.js +4 -3
  35. package/core/search/search-postprocess.js +189 -8
  36. package/core/search/search-read-semantic.js +717 -0
  37. package/core/search/search-read.js +481 -0
  38. package/core/search/search-server.js +6 -4
  39. package/core/search/sweet-search.js +119 -15
  40. package/mcp/server.js +41 -0
  41. package/mcp/tool-handlers.js +117 -6
  42. package/package.json +9 -7
  43. package/scripts/init.js +386 -5
@@ -0,0 +1,481 @@
1
+ /**
2
+ * sweet-search read — filesystem-grounded file reader.
3
+ *
4
+ * Returns exact bytes from disk. The vectors index may attach symbol/chunk
5
+ * metadata for indexed files, but the returned `text` always comes from
6
+ * `node:fs`, never from the (truncated) DB column.
7
+ *
8
+ * Design notes:
9
+ * - Filesystem is ground truth. Never return DB-stored text as content.
10
+ * - Batch up to 20 files; per-file errors do not fail the batch.
11
+ * - Warm-process cache keyed by `path|size|mtimeMs` avoids re-reading hot
12
+ * files; line-offset table lets line-range reads avoid materialising the
13
+ * whole content for large files.
14
+ *
15
+ * DDD: this module lives in the search/ application layer (allowed to import
16
+ * infrastructure for filesystem grounding and chunk metadata).
17
+ */
18
+
19
+ import { promises as fs, statSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { CodebaseRepository } from '../infrastructure/codebase-repository.js';
22
+ import { DB_PATHS } from '../infrastructure/config/index.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Cache — keyed by absolutePath|size|mtimeMs (any change invalidates).
26
+ // Bounded LRU. Entries hold either the full text + line-offset table, or just
27
+ // the line-offset table for very large files where we deliberately avoid
28
+ // caching the whole content.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const CACHE_MAX_ENTRIES = 64;
32
+ const CACHE_LARGE_FILE_BYTES = 4 * 1024 * 1024; // 4MB — switch to range-read mode
33
+ const _cache = new Map(); // key -> { text|null, lineOffsets, size, mtimeMs }
34
+
35
+ function _cacheKey(absPath, size, mtimeMs) {
36
+ return `${absPath}|${size}|${mtimeMs}`;
37
+ }
38
+
39
+ function _cacheTouch(key, value) {
40
+ if (_cache.has(key)) _cache.delete(key);
41
+ _cache.set(key, value);
42
+ while (_cache.size > CACHE_MAX_ENTRIES) {
43
+ const oldest = _cache.keys().next().value;
44
+ _cache.delete(oldest);
45
+ }
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Repository singleton — lazy and tolerant of a missing/empty DB.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ let _repo = null;
53
+ function _getRepo() {
54
+ if (_repo === null) {
55
+ try { _repo = new CodebaseRepository(DB_PATHS.codebase); }
56
+ catch { _repo = false; }
57
+ }
58
+ return _repo || null;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Path resolution helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function _resolvePath(p, projectRoot) {
66
+ if (!p) throw new Error('path is required');
67
+ if (path.isAbsolute(p)) return p;
68
+ return path.resolve(projectRoot || process.cwd(), p);
69
+ }
70
+
71
+ function _projectRelative(absPath, projectRoot) {
72
+ const root = projectRoot || process.cwd();
73
+ const rel = path.relative(root, absPath);
74
+ // Inside the project root → use relative form (matches vectors.file_path).
75
+ // Outside → keep the absolute path (no chunks will match anyway).
76
+ return rel.startsWith('..') || path.isAbsolute(rel) ? absPath : rel;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Line-offset table — index of byte offsets where each line starts.
81
+ // lineOffsets[i] = byte offset of start of line (i+1). lineOffsets has
82
+ // totalLines entries. To slice lines [a..b] (1-based, inclusive):
83
+ // start = lineOffsets[a-1]
84
+ // end = (b < totalLines) ? lineOffsets[b] : buffer.length
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function _buildLineOffsets(buf) {
88
+ const offsets = [0];
89
+ for (let i = 0; i < buf.length; i++) {
90
+ if (buf[i] === 0x0A /* \n */) offsets.push(i + 1);
91
+ }
92
+ // If the file ends without a trailing newline, the final offset isn't a
93
+ // line start — strip it. The line count is offsets.length.
94
+ if (offsets[offsets.length - 1] === buf.length) offsets.pop();
95
+ return offsets;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Read implementation
100
+ // ---------------------------------------------------------------------------
101
+
102
+ async function _readFromDisk(absPath) {
103
+ // statSync is OK here — async stat costs more than the sync syscall.
104
+ let stat;
105
+ try { stat = statSync(absPath); }
106
+ catch (err) { throw new Error(`stat failed: ${err.code || err.message}`); }
107
+ if (!stat.isFile()) throw new Error('not a regular file');
108
+
109
+ const key = _cacheKey(absPath, stat.size, stat.mtimeMs);
110
+ const cached = _cache.get(key);
111
+ if (cached) {
112
+ _cacheTouch(key, cached);
113
+ return { ...cached, key, size: stat.size, mtimeMs: stat.mtimeMs };
114
+ }
115
+
116
+ // For large files we still read fully on first call (Node fs has no
117
+ // efficient line-aware streaming primitive), but subsequent line-range
118
+ // reads will reuse the cached offset table without re-reading from disk.
119
+ // If the file is enormous and the caller asked for a range, we read just
120
+ // enough bytes to cover the range — see _sliceLines().
121
+ const buf = await fs.readFile(absPath);
122
+ const lineOffsets = _buildLineOffsets(buf);
123
+ const isLarge = stat.size > CACHE_LARGE_FILE_BYTES;
124
+ const entry = {
125
+ text: isLarge ? null : buf.toString('utf8'),
126
+ bufferRef: isLarge ? null : null, // not held — text is the canonical form
127
+ lineOffsets,
128
+ size: stat.size,
129
+ mtimeMs: stat.mtimeMs,
130
+ };
131
+ _cacheTouch(key, entry);
132
+
133
+ // Even for large files we return the freshly-read text on this call so the
134
+ // first read is correct; subsequent calls can stream by line range.
135
+ return {
136
+ text: entry.text ?? buf.toString('utf8'),
137
+ lineOffsets,
138
+ size: stat.size,
139
+ mtimeMs: stat.mtimeMs,
140
+ key,
141
+ };
142
+ }
143
+
144
+ function _normalizeLineRange(lineOffsets, startLine, endLine) {
145
+ // Returns the exact disk bytes for lines [startLine..endLine] (1-based,
146
+ // inclusive). Trailing newlines that are present on disk are preserved —
147
+ // we are a filesystem-grounded reader and must never silently mutate
148
+ // returned content.
149
+ const total = lineOffsets.length;
150
+ if (total === 0) return { startLine: 1, endLine: 0, totalLines: 0, startByte: 0, endByte: 0 };
151
+ const s = Math.max(1, startLine | 0);
152
+ const eRaw = (endLine == null) ? total : (endLine | 0);
153
+ const e = Math.min(total, Math.max(s, eRaw));
154
+ const startByte = lineOffsets[s - 1];
155
+ return { startLine: s, endLine: e, totalLines: total, startByte, endByte: null };
156
+ }
157
+
158
+ function _sliceLines(text, lineOffsets, startLine, endLine) {
159
+ const range = _normalizeLineRange(lineOffsets, startLine, endLine);
160
+ if (range.totalLines === 0) return { text: '', startLine: 1, endLine: 0, totalLines: 0 };
161
+ const endByte = (range.endLine < range.totalLines)
162
+ ? lineOffsets[range.endLine]
163
+ : Buffer.byteLength(text, 'utf8');
164
+ // Slice on bytes via Buffer view to handle multibyte UTF-8 safely.
165
+ const buf = Buffer.from(text, 'utf8');
166
+ const slice = buf.subarray(range.startByte, endByte).toString('utf8');
167
+ return { text: slice, startLine: range.startLine, endLine: range.endLine, totalLines: range.totalLines };
168
+ }
169
+
170
+ async function _sliceLinesFromDisk(absPath, lineOffsets, fileSize, startLine, endLine) {
171
+ const range = _normalizeLineRange(lineOffsets, startLine, endLine);
172
+ if (range.totalLines === 0) return { text: '', startLine: 1, endLine: 0, totalLines: 0 };
173
+ const endByte = (range.endLine < range.totalLines) ? lineOffsets[range.endLine] : fileSize;
174
+ const len = Math.max(0, endByte - range.startByte);
175
+ const handle = await fs.open(absPath, 'r');
176
+ try {
177
+ const buf = Buffer.allocUnsafe(len);
178
+ await handle.read(buf, 0, len, range.startByte);
179
+ return {
180
+ text: buf.toString('utf8'),
181
+ startLine: range.startLine,
182
+ endLine: range.endLine,
183
+ totalLines: range.totalLines,
184
+ };
185
+ } finally {
186
+ await handle.close();
187
+ }
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Index metadata enrichment
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function _parseMeta(rawMeta) {
195
+ if (!rawMeta) return null;
196
+ if (typeof rawMeta === 'object') return rawMeta;
197
+ try { return JSON.parse(rawMeta); } catch { return null; }
198
+ }
199
+
200
+ function _metaSymbol(meta) {
201
+ return meta.name ?? meta.symbol ?? null;
202
+ }
203
+
204
+ function _metaType(meta) {
205
+ return meta.type ?? meta.chunk_type ?? null;
206
+ }
207
+
208
+ function _metaStartLine(meta) {
209
+ return typeof meta.startLine === 'number' ? meta.startLine
210
+ : typeof meta.line_start === 'number' ? meta.line_start
211
+ : null;
212
+ }
213
+
214
+ function _metaEndLine(meta) {
215
+ return typeof meta.endLine === 'number' ? meta.endLine
216
+ : typeof meta.line_end === 'number' ? meta.line_end
217
+ : null;
218
+ }
219
+
220
+ function _attachIndexMetadata(filePathRel) {
221
+ const repo = _getRepo();
222
+ if (!repo) return { indexed: false, chunks: [], language: null };
223
+
224
+ const rows = repo.getChunksByFilePath(filePathRel);
225
+ if (rows.length === 0) return { indexed: false, chunks: [], language: null };
226
+
227
+ const chunks = [];
228
+ let language = null;
229
+ for (const row of rows) {
230
+ const meta = _parseMeta(row.metadata) || {};
231
+ if (!language && meta.language) language = meta.language;
232
+ chunks.push({
233
+ id: row.id,
234
+ symbol: _metaSymbol(meta),
235
+ type: _metaType(meta),
236
+ startLine: _metaStartLine(meta),
237
+ endLine: _metaEndLine(meta),
238
+ signature: meta.signature ?? null,
239
+ });
240
+ }
241
+ // Order by startLine for predictable consumption.
242
+ chunks.sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
243
+ return { indexed: true, chunks, language };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Public API — single read
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Read one file (or one line range of one file).
252
+ *
253
+ * @param {Object} req
254
+ * @param {string} req.path - File path. Absolute or relative to projectRoot.
255
+ * @param {number} [req.startLine] - 1-based, inclusive
256
+ * @param {number} [req.endLine] - 1-based, inclusive
257
+ * @param {string} [req.projectRoot] - default: process.cwd()
258
+ * @param {boolean} [req.includeMetadata=true] - attach index chunks/language
259
+ * @returns {Promise<Object>}
260
+ */
261
+ export async function readFile(req) {
262
+ const t0 = performance.now();
263
+ const projectRoot = req.projectRoot || process.cwd();
264
+ const absPath = _resolvePath(req.path, projectRoot);
265
+ const relForIndex = _projectRelative(absPath, projectRoot);
266
+
267
+ let disk;
268
+ try {
269
+ disk = await _readFromDisk(absPath);
270
+ } catch (err) {
271
+ return {
272
+ file: req.path,
273
+ ok: false,
274
+ error: err.message || String(err),
275
+ exact: true,
276
+ indexed: false,
277
+ };
278
+ }
279
+
280
+ const wantsRange = req.startLine != null || req.endLine != null;
281
+ const fullText = !wantsRange && disk.text == null
282
+ ? await fs.readFile(absPath, 'utf8')
283
+ : disk.text;
284
+ const sliced = wantsRange
285
+ ? (disk.text == null
286
+ ? await _sliceLinesFromDisk(absPath, disk.lineOffsets, disk.size, req.startLine ?? 1, req.endLine ?? null)
287
+ : _sliceLines(disk.text, disk.lineOffsets, req.startLine ?? 1, req.endLine ?? null))
288
+ : { text: fullText, startLine: 1, endLine: disk.lineOffsets.length, totalLines: disk.lineOffsets.length };
289
+
290
+ let language = null;
291
+ let chunks = [];
292
+ let indexed = false;
293
+ if (req.includeMetadata !== false) {
294
+ const meta = _attachIndexMetadata(relForIndex);
295
+ indexed = meta.indexed;
296
+ chunks = meta.chunks;
297
+ language = meta.language;
298
+ }
299
+
300
+ // If a line range was requested, narrow attached chunks to the overlap.
301
+ if (wantsRange && chunks.length) {
302
+ chunks = chunks.filter(c =>
303
+ c.startLine == null || c.endLine == null
304
+ ? true
305
+ : (c.endLine >= sliced.startLine && c.startLine <= sliced.endLine),
306
+ );
307
+ }
308
+
309
+ return {
310
+ file: req.path,
311
+ absolutePath: absPath,
312
+ ok: true,
313
+ exact: true,
314
+ indexed,
315
+ language,
316
+ totalLines: sliced.totalLines,
317
+ bytes: disk.size,
318
+ mtimeMs: disk.mtimeMs,
319
+ range: wantsRange ? { startLine: sliced.startLine, endLine: sliced.endLine } : null,
320
+ text: sliced.text,
321
+ chunks,
322
+ timings: { totalMs: +(performance.now() - t0).toFixed(2) },
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Batch read — up to 20 files in parallel. Per-file failures are returned
328
+ * inline; the batch never throws unless `files` is malformed.
329
+ *
330
+ * @param {Object[]} files - [{ path, startLine?, endLine? }, ...]
331
+ * @param {Object} [opts]
332
+ * @param {string} [opts.projectRoot]
333
+ * @param {boolean} [opts.includeMetadata=true]
334
+ * @returns {Promise<{files: Object[], totalMs: number}>}
335
+ */
336
+ export async function readFiles(files, opts = {}) {
337
+ if (!Array.isArray(files) || files.length === 0) {
338
+ return { files: [], totalMs: 0 };
339
+ }
340
+ if (files.length > 20) {
341
+ throw new Error(`read accepts at most 20 files; got ${files.length}`);
342
+ }
343
+ const t0 = performance.now();
344
+ const results = await Promise.all(files.map(f => readFile({
345
+ path: f.path,
346
+ startLine: f.startLine,
347
+ endLine: f.endLine,
348
+ projectRoot: opts.projectRoot,
349
+ includeMetadata: opts.includeMetadata !== false,
350
+ })));
351
+ return { files: results, totalMs: +(performance.now() - t0).toFixed(2) };
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Formatting
356
+ // ---------------------------------------------------------------------------
357
+
358
+ function _formatAgent(result) {
359
+ if (!result.ok) {
360
+ return `### ${result.file}\n[error] ${result.error}\n`;
361
+ }
362
+ const fence = result.language ? '```' + result.language : '```';
363
+ const range = result.range
364
+ ? ` (lines ${result.range.startLine}-${result.range.endLine} of ${result.totalLines})`
365
+ : ` (${result.totalLines} lines)`;
366
+ let symbolHint = '';
367
+ if (result.chunks && result.chunks.length > 0 && result.chunks.length <= 12) {
368
+ const names = result.chunks
369
+ .map(c => c.symbol ? `${c.type || 'symbol'}:${c.symbol}` : null)
370
+ .filter(Boolean);
371
+ if (names.length) symbolHint = `\nsymbols: ${names.join(', ')}`;
372
+ }
373
+ return `### ${result.file}${range}${symbolHint}\n${fence}\n${result.text}\n\`\`\`\n`;
374
+ }
375
+
376
+ export function formatReadResults(results, format = 'agent') {
377
+ if (format === 'json') {
378
+ return JSON.stringify({ files: results.files, totalMs: results.totalMs }, null, 2);
379
+ }
380
+ if (format === 'raw') {
381
+ return results.files.map(r => r.ok ? r.text : `[error: ${r.file}] ${r.error}`).join('\n\n');
382
+ }
383
+ return results.files.map(_formatAgent).join('\n');
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // CLI handler
388
+ // Usage:
389
+ // sweet-search read path/to/file.ts
390
+ // sweet-search read path/to/file.ts --lines 45-92
391
+ // sweet-search read a.ts b.ts c.ts
392
+ // sweet-search read path/to/file.ts --json
393
+ // sweet-search read path/to/file.ts --raw
394
+ // ---------------------------------------------------------------------------
395
+
396
+ function _parseLineRange(spec) {
397
+ // Accepts "45-92", "45:92", "45" (single line), or "45-" (open end).
398
+ if (!spec) return [null, null];
399
+ const m = String(spec).match(/^(\d+)(?:[-:](\d+)?)?$/);
400
+ if (!m) throw new Error(`invalid --lines spec: ${spec}`);
401
+ const start = +m[1];
402
+ const end = m[2] != null ? +m[2] : (spec.includes('-') || spec.includes(':') ? null : start);
403
+ return [start, end];
404
+ }
405
+
406
+ function _parseArgs(args) {
407
+ const positional = [];
408
+ let format = 'agent';
409
+ let startLine = null;
410
+ let endLine = null;
411
+ let includeMetadata = true;
412
+ for (let i = 0; i < args.length; i++) {
413
+ const a = args[i];
414
+ if (a === '--json') format = 'json';
415
+ else if (a === '--raw') format = 'raw';
416
+ else if (a === '--agent') format = 'agent';
417
+ else if (a === '--no-metadata') includeMetadata = false;
418
+ else if (a === '--lines') {
419
+ const [s, e] = _parseLineRange(args[++i]);
420
+ startLine = s; endLine = e;
421
+ } else if (a === '--help' || a === '-h') {
422
+ return { help: true };
423
+ } else if (a.startsWith('--')) {
424
+ // Unknown flag — surface clearly rather than silently swallowing.
425
+ throw new Error(`unknown flag: ${a}`);
426
+ } else {
427
+ positional.push(a);
428
+ }
429
+ }
430
+ return { positional, format, startLine, endLine, includeMetadata };
431
+ }
432
+
433
+ function _printHelp() {
434
+ process.stdout.write([
435
+ 'sweet-search read — filesystem-grounded file reader',
436
+ '',
437
+ 'Usage:',
438
+ ' sweet-search read <path> [...path] Read 1-20 files',
439
+ ' sweet-search read <path> --lines 45-92',
440
+ '',
441
+ 'Options:',
442
+ ' --lines <a-b> 1-based inclusive range. Use "45-" for open end, "45" for one line.',
443
+ ' --json Emit JSON (machine-readable)',
444
+ ' --raw Emit raw text only (no fences/headers)',
445
+ ' --agent Default — markdown fenced block + symbol hints',
446
+ ' --no-metadata Skip index metadata attachment',
447
+ '',
448
+ ].join('\n'));
449
+ }
450
+
451
+ export async function handleReadCli(args) {
452
+ let parsed;
453
+ try { parsed = _parseArgs(args); }
454
+ catch (err) { process.stderr.write(`[sweet-search read] ${err.message}\n`); process.exit(2); }
455
+ if (parsed.help || !parsed.positional || parsed.positional.length === 0) {
456
+ _printHelp();
457
+ process.exit(parsed.help ? 0 : 2);
458
+ }
459
+ const wantsRange = parsed.startLine != null || parsed.endLine != null;
460
+ if (wantsRange && parsed.positional.length > 1) {
461
+ process.stderr.write('[sweet-search read] --lines requires exactly one path\n');
462
+ process.exit(2);
463
+ }
464
+ const files = parsed.positional.map(p => ({
465
+ path: p,
466
+ startLine: wantsRange ? parsed.startLine : undefined,
467
+ endLine: wantsRange ? parsed.endLine : undefined,
468
+ }));
469
+ const out = await readFiles(files, { includeMetadata: parsed.includeMetadata });
470
+ process.stdout.write(formatReadResults(out, parsed.format));
471
+ if (parsed.format !== 'json') process.stdout.write('\n');
472
+ // Non-zero exit if every file failed (so shell pipelines see the error).
473
+ const allFailed = out.files.length > 0 && out.files.every(f => !f.ok);
474
+ process.exit(allFailed ? 1 : 0);
475
+ }
476
+
477
+ // Test-only export — clears caches between unit tests.
478
+ export function __resetReadCachesForTests() {
479
+ _cache.clear();
480
+ _repo = null;
481
+ }
@@ -220,7 +220,7 @@ export async function startServer() {
220
220
 
221
221
  // Agent mode: context packaging (ColGrep agent format)
222
222
  const rawFormat = url.searchParams.get('format');
223
- const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full']);
223
+ const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full', 'agent_full_xl']);
224
224
  const agentFormat = AGENT_FORMATS.has(rawFormat) ? rawFormat : undefined;
225
225
  const tokenBudget = url.searchParams.has('budget')
226
226
  ? parseInt(url.searchParams.get('budget'), 10)
@@ -519,13 +519,15 @@ export async function autoSpawnServer() {
519
519
  const { fileURLToPath } = await import('url');
520
520
  const path = await import('path');
521
521
 
522
- // Dynamic import to get the sweet-search.js file path
522
+ // Spawn the real CLI entrypoint with --serve. sweet-search.js is a library
523
+ // module and does not process argv, so launching it directly never starts
524
+ // the daemon.
523
525
  const __filename = fileURLToPath(import.meta.url);
524
- const sweetSearchPath = path.join(path.dirname(__filename), 'sweet-search.js');
526
+ const sweetSearchPath = path.join(path.dirname(__filename), '..', 'cli.js');
525
527
 
526
528
  console.error('[AutoStart] Starting warm server in background...');
527
529
 
528
- // Spawn detached process — run sweet-search.js with --serve
530
+ // Spawn detached process — run sweet-search with --serve
529
531
  const child = spawn(process.execPath, [sweetSearchPath, '--serve'], {
530
532
  detached: true,
531
533
  stdio: 'ignore',