diff --git a/scripts/tests/test-qmd-search.mjs b/scripts/tests/test-qmd-search.mjs new file mode 100644 index 0000000..63466ea --- /dev/null +++ b/scripts/tests/test-qmd-search.mjs @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { runSearch, toCliResults } from '../../shared/qmd/qmd-search.mjs'; +import { collectionName } from '../../shared/qmd/qmd-lib.mjs'; + +const hit = [{ path: 'src/a.ts', line: 3, score: 0.9, snippet: 's' }]; + +test('uses the daemon when it answers', async () => { + const results = await runSearch({ root: '/My Repo/ü', query: 'q', topK: 5, + daemon: async (path, body) => { assert.equal(path, '/search'); assert.equal(body.repo, '/My Repo/ü'); return { results: hit }; }, + startDaemon: async () => assert.fail('must not start'), cli: async () => assert.fail('must not fall back') }); + assert.deepEqual(results, hit); +}); + +test('starts the daemon once when the first call fails, then retries', async () => { + let calls = 0; let started = 0; + const results = await runSearch({ root: '/r', query: 'q', topK: 5, + daemon: async () => { calls += 1; if (calls === 1) throw new Error('ECONNREFUSED'); return { results: hit }; }, + startDaemon: async () => { started += 1; }, cli: async () => assert.fail('must not fall back') }); + assert.deepEqual(results, hit); + assert.equal(started, 1); +}); + +test('falls back to the CLI when the daemon cannot start', async () => { + const results = await runSearch({ root: '/r', query: 'q', topK: 2, + daemon: async () => { throw new Error('stale'); }, startDaemon: async () => { throw new Error('spawn failed'); }, + cli: async (args) => { assert.ok(args.includes('--json')); return [{ file: `qmd://${collectionName('/r')}/src/b.ts`, line: 7, score: 0.5, snippet: 'b' }]; } }); + assert.deepEqual(results, [{ path: 'src/b.ts', line: 7, score: 0.5, snippet: 'b' }]); +}); + +test('CLI results keep foreign collections as qmd URIs', () => { + assert.deepEqual(toCliResults([{ file: 'qmd://notes/x.md', line: 1, score: 0.4, snippet: 'n' }], 'repo-x'), + [{ path: 'qmd://notes/x.md', line: 1, score: 0.4, snippet: 'n' }]); +}); diff --git a/shared/qmd/qmd-search.mjs b/shared/qmd/qmd-search.mjs new file mode 100644 index 0000000..f50dde9 --- /dev/null +++ b/shared/qmd/qmd-search.mjs @@ -0,0 +1,76 @@ +// shared/qmd/qmd-search.mjs — search the current repository through the QMD daemon, with a CLI fallback. +import { execFileSync, spawn } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { collectionName, deviceEnv, readJson, runQmdCli, setupStatePath, stateDir } from './qmd-lib.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const daemonFile = () => join(stateDir(), 'daemon.json'); + +export function toCliResults(items, collection) { + return items.map((item) => { + const prefix = `qmd://${collection}/`; + const path = String(item.file || '').startsWith(prefix) ? item.file.slice(prefix.length) : item.file; + return { path, line: item.line ?? null, score: item.score, snippet: item.snippet ?? '' }; + }); +} + +async function callDaemon(path, body) { + const info = readJson(daemonFile(), null); + if (!info?.port || !info?.token) throw new Error('daemon not running'); + const response = await fetch(`http://127.0.0.1:${info.port}${path}`, { + method: 'POST', headers: { 'x-qmd-token': info.token, 'content-type': 'application/json' }, + body: JSON.stringify(body), signal: AbortSignal.timeout(600_000) }); + if (!response.ok) throw new Error(`daemon answered ${response.status}`); + return response.json(); +} + +export async function startDaemon() { + const device = readJson(setupStatePath(), {}).device || 'cpu'; + const child = spawn(process.execPath, [join(here, 'qmd-daemon.mjs')], { detached: true, stdio: 'ignore', windowsHide: true, env: deviceEnv(device) }); + child.unref(); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + await new Promise((done) => setTimeout(done, 500)); + const info = readJson(daemonFile(), null); + if (info?.port && info?.token && info.pid === child.pid) return; + } + throw new Error('daemon did not start within 120 s'); +} + +function runCli(args) { + const device = readJson(setupStatePath(), {}).device || 'cpu'; + return JSON.parse(runQmdCli(args, { env: deviceEnv(device), windowsHide: true })); +} + +export async function runSearch({ root, query, topK, daemon = callDaemon, startDaemon: start = startDaemon, cli = runCli }) { + const body = { repo: root, query, limit: topK }; + try { + return (await daemon('/search', body)).results; + } catch { + try { + await start(); + return (await daemon('/search', body)).results; + } catch { + const collection = collectionName(root); + return toCliResults(await cli(['query', query, '--json', '-n', String(topK), '-c', collection, '--chunk-strategy', 'auto']), collection); + } + } +} + +async function main() { + const { values } = parseArgs({ options: { query: { type: 'string' }, 'top-k': { type: 'string', default: '5' }, root: { type: 'string' }, stop: { type: 'boolean' } } }); + if (values.stop) { + try { await callDaemon('/stop', {}); } catch { /* nothing running */ } + return; + } + if (!values.query?.trim()) throw new Error('--query is required'); + const root = resolve(values.root || execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()); + const topK = Math.min(Math.max(Number(values['top-k']) || 5, 1), 20); + process.stdout.write(JSON.stringify(await runSearch({ root, query: values.query, topK }), null, 2) + '\n'); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().catch((error) => { console.error(`semantic-search: ${error.message}`); process.exit(1); }); +}