1973 lines
93 KiB
Markdown
1973 lines
93 KiB
Markdown
# QMD Local Search Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the Python Qwen retrieval stack with QMD (Qwen3 embedding, Qwen3 reranker, QMD query expansion) behind the existing Yes/No/Auto local-models question, with a strict `semantic-search` skill for Claude and Codex.
|
||
|
||
**Architecture:** Node scripts in `shared/qmd/` drive QMD through its SDK: a token-protected loopback daemon keeps the three models loaded and exits after 30 idle minutes; a search wrapper talks to it and falls back to `qmd query --json`; a SessionStart hook warms the daemon and the repository collection. A Python installer library (`scripts/lib/qmd.py`, with `.sh`/`.ps1` wrappers, the same pattern as the removed `semantic-retrieval.py`) installs, benchmarks, migrates, and removes setup-owned QMD state.
|
||
|
||
**Tech Stack:** Node.js 22+ (ESM, `node:test`), `@tobilu/qmd@2.8.3` SDK and its bundled `yaml` and `node-llama-cpp` packages, Python 3.12 stdlib (`unittest`), Bash and PowerShell 5.1/7.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-08-qmd-local-search-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- Models (exact URIs): embed `hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf`; rerank `hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf`; generate `hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf`.
|
||
- QMD package: `@tobilu/qmd@2.8.3`; Node.js 22 or newer; `npm` required.
|
||
- No Python code loads or runs a model. Python is used only by the installer library, as elsewhere in `scripts/lib/`.
|
||
- Auto thresholds: median search latency at most 5 s (`QMD_BENCHMARK_MAX_QUERY_SECONDS`); embedding at least 20 chunks/s (`QMD_BENCHMARK_MIN_CHUNKS_PER_SECOND`); GPU first, then CPU; 10-minute timeout per device.
|
||
- Idle stop: `QMD_IDLE_MINUTES`, default 30.
|
||
- Platforms: Linux and Windows are both first-class (the CI runner is Ubuntu; Windows is verified locally in Task 11). Spawned processes use `windowsHide`, `npm`/`qmd` run with `shell: true` only on Windows, hooks have `.sh` and `.ps1` entry points.
|
||
- Multiple GPUs and CPUs: the setup never narrows the device set. `gpu` mode uses llama.cpp's automatic backend, which spreads layers over every GPU of that backend; `cpu` mode uses every logical core (node-llama-cpp default). The setup never sets `CUDA_VISIBLE_DEVICES`, `GGML_VK_VISIBLE_DEVICES`, or a thread limit. Mixed-vendor systems use the one backend llama.cpp picks (CUDA before Vulkan); the benchmark reports the GPU names it used.
|
||
- Repository collection: `repo-` + first 12 hex chars of SHA-256 of the resolved absolute repository path; mask `**/*.{md,py,cs,ts,tsx,js,jsx,mjs,go,rs,java,ps1,sh,json,yml,yaml,xml,sql}`; chunk strategy `auto`; `includeByDefault: false`.
|
||
- State: `~/.my-ai-configuration/qmd.json` (installer state) and `~/.my-ai-configuration/qmd/` (installed scripts, `daemon.json`, `collections.json`, `warm.log`). Selection key stays `semantic_retrieval`; parameters stay `-SemanticRetrieval` / `--semantic-retrieval`.
|
||
- The setup removes only what it created: QMD package, model files, repo collections, the `models:` block it wrote. Foreign installs and collections survive.
|
||
- Every hook has `.sh` and `.ps1` entry points; PowerShell code stays 5.1-compatible. The `.mjs` scripts are invoked as `node <script>` from both shells.
|
||
- All repository content and script output in English. Commit after each task with Conventional Commits and the trailer `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`; never push.
|
||
|
||
## Review Focus
|
||
|
||
- A repository path containing spaces or non-ASCII characters: the collection name must be stable and the search must still work. Test in Task 1 (`collectionName` with spaces/umlauts) and Task 3 (wrapper on such a path).
|
||
- A stale `daemon.json` after a crash or reboot (dead PID, reused port, wrong token): the wrapper must recover by starting a new daemon or falling back to the CLI, never hang. Test in Task 3.
|
||
- Two sessions starting at once in the same repository: only one indexing run and one daemon. Test in Task 4 (lock) and Task 2 (second daemon start is a no-op).
|
||
- An existing user `index.yml` with collections and comments: the setup changes only `models:` and leaves the rest byte-for-byte equivalent in meaning. Test in Task 1 (`setModels`/`unsetModels`).
|
||
- Answer No after a previous Yes on a machine where QMD was installed by the user before the setup: QMD and the user's collections must survive. Test in Task 6.
|
||
- A machine with several GPUs or several CPU sockets: no device may be excluded by the setup's environment. Test in Task 1 (`deviceEnv` never sets visibility or thread variables) and Task 5 (`describeDevices` lists every GPU name).
|
||
|
||
---
|
||
|
||
### Task 1: Shared Node library (`qmd-lib.mjs`) and `index.yml` model block
|
||
|
||
**Files:**
|
||
- Create: `shared/qmd/qmd-lib.mjs`
|
||
- Create: `shared/qmd/qmd-config.mjs`
|
||
- Test: `scripts/tests/test-qmd-lib.mjs`
|
||
|
||
**Interfaces:**
|
||
- Produces (`qmd-lib.mjs`):
|
||
- `MODELS: {embed, rerank, generate}` (strings above)
|
||
- `MASK: string`
|
||
- `collectionName(repoPath: string): string`
|
||
- `stateDir(home?: string): string` → `<home>/.my-ai-configuration/qmd`
|
||
- `setupStatePath(home?: string): string` → `<home>/.my-ai-configuration/qmd.json`
|
||
- `qmdConfigPath(env?: object, home?: string): string`
|
||
- `qmdDbPath(env?: object, home?: string): string`
|
||
- `deviceEnv(device: 'gpu'|'cpu', env?: object): object`
|
||
- `readJson(path: string, fallback: any): any`
|
||
- `writePrivateJson(path: string, value: any): void`
|
||
- `qmdPackageDir(): string` (honors `QMD_PACKAGE_DIR`)
|
||
- `importQmd(subpath?: string): Promise<module>` (default `dist/index.js`)
|
||
- `idleMinutes(env?: object): number`
|
||
- Produces (`qmd-config.mjs`): CLI `node qmd-config.mjs set-models|unset-models` and exported `setModels(text: string, YAML): string`, `unsetModels(text: string, YAML): string`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
// scripts/tests/test-qmd-lib.mjs
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import * as YAML from './fixtures/yaml-shim.mjs';
|
||
import { collectionName, deviceEnv, idleMinutes, qmdConfigPath, writePrivateJson, readJson, MODELS } from '../../shared/qmd/qmd-lib.mjs';
|
||
import { setModels, unsetModels } from '../../shared/qmd/qmd-config.mjs';
|
||
|
||
test('collection name is stable, prefixed, and path-safe', () => {
|
||
const a = collectionName('/tmp/My Repo/ü');
|
||
assert.match(a, /^repo-[0-9a-f]{12}$/);
|
||
assert.equal(a, collectionName('/tmp/My Repo/ü'));
|
||
assert.notEqual(a, collectionName('/tmp/My Repo/u'));
|
||
});
|
||
|
||
test('device env forces CPU or enables GPU auto', () => {
|
||
assert.equal(deviceEnv('cpu', { QMD_LLAMA_GPU: 'cuda' }).QMD_FORCE_CPU, '1');
|
||
assert.equal(deviceEnv('cpu', { QMD_LLAMA_GPU: 'cuda' }).QMD_LLAMA_GPU, undefined);
|
||
assert.equal(deviceEnv('gpu', { QMD_FORCE_CPU: '1' }).QMD_LLAMA_GPU, 'auto');
|
||
assert.equal(deviceEnv('gpu', { QMD_FORCE_CPU: '1' }).QMD_FORCE_CPU, undefined);
|
||
});
|
||
|
||
test('device env never narrows GPUs or threads', () => {
|
||
for (const device of ['gpu', 'cpu']) {
|
||
const env = deviceEnv(device, {});
|
||
for (const key of ['CUDA_VISIBLE_DEVICES', 'GGML_VK_VISIBLE_DEVICES', 'HIP_VISIBLE_DEVICES', 'OMP_NUM_THREADS']) assert.equal(env[key], undefined);
|
||
}
|
||
});
|
||
|
||
test('idle minutes default to 30 and reject nonsense', () => {
|
||
assert.equal(idleMinutes({}), 30);
|
||
assert.equal(idleMinutes({ QMD_IDLE_MINUTES: '5' }), 5);
|
||
assert.equal(idleMinutes({ QMD_IDLE_MINUTES: '-1' }), 30);
|
||
});
|
||
|
||
test('config path honors QMD_CONFIG_DIR then XDG_CONFIG_HOME', () => {
|
||
assert.equal(qmdConfigPath({ QMD_CONFIG_DIR: '/c' }, '/h'), join('/c', 'index.yml'));
|
||
assert.equal(qmdConfigPath({ XDG_CONFIG_HOME: '/x' }, '/h'), join('/x', 'qmd', 'index.yml'));
|
||
assert.equal(qmdConfigPath({}, '/h'), join('/h', '.config', 'qmd', 'index.yml'));
|
||
});
|
||
|
||
test('private json round-trips', () => {
|
||
const file = join(mkdtempSync(join(tmpdir(), 'qmd-lib-')), 'a', 'b.json');
|
||
writePrivateJson(file, { x: 1 });
|
||
assert.deepEqual(readJson(file, null), { x: 1 });
|
||
assert.equal(readJson(file + '.missing', 'fallback'), 'fallback');
|
||
});
|
||
|
||
test('setModels keeps collections and comments, unsetModels removes only models', () => {
|
||
const original = '# mine\ncollections:\n notes:\n path: /n # keep\n';
|
||
const withModels = setModels(original, YAML);
|
||
assert.match(withModels, /# mine/);
|
||
assert.match(withModels, /path: \/n # keep/);
|
||
assert.match(withModels, new RegExp(MODELS.embed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||
const restored = unsetModels(withModels, YAML);
|
||
assert.doesNotMatch(restored, /models:/);
|
||
assert.match(restored, /path: \/n # keep/);
|
||
});
|
||
|
||
test('setModels on an empty file creates only the models block', () => {
|
||
const text = setModels('', YAML);
|
||
assert.match(text, /^models:/m);
|
||
});
|
||
```
|
||
|
||
```js
|
||
// scripts/tests/fixtures/yaml-shim.mjs — loads QMD's bundled yaml package for tests.
|
||
import { qmdPackageDir } from '../../../shared/qmd/qmd-lib.mjs';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { join } from 'node:path';
|
||
const YAML = await import(pathToFileURL(join(qmdPackageDir(), 'node_modules', 'yaml', 'dist', 'index.js')).href);
|
||
export const parseDocument = YAML.parseDocument;
|
||
export default YAML;
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-lib.mjs`
|
||
Expected: FAIL with `Cannot find module '.../shared/qmd/qmd-lib.mjs'`
|
||
|
||
- [ ] **Step 3: Implement `qmd-lib.mjs`**
|
||
|
||
```js
|
||
// shared/qmd/qmd-lib.mjs
|
||
import { createHash } from 'node:crypto';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||
import { homedir } from 'node:os';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
|
||
export const MODELS = {
|
||
embed: 'hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf',
|
||
rerank: 'hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf',
|
||
generate: 'hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf',
|
||
};
|
||
export const MASK = '**/*.{md,py,cs,ts,tsx,js,jsx,mjs,go,rs,java,ps1,sh,json,yml,yaml,xml,sql}';
|
||
|
||
export function collectionName(repoPath) {
|
||
return 'repo-' + createHash('sha256').update(resolve(repoPath), 'utf8').digest('hex').slice(0, 12);
|
||
}
|
||
|
||
export const stateDir = (home = homedir()) => join(home, '.my-ai-configuration', 'qmd');
|
||
export const setupStatePath = (home = homedir()) => join(home, '.my-ai-configuration', 'qmd.json');
|
||
|
||
export function qmdConfigPath(env = process.env, home = homedir()) {
|
||
const dir = env.QMD_CONFIG_DIR || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'qmd') : join(home, '.config', 'qmd'));
|
||
return join(dir, 'index.yml');
|
||
}
|
||
|
||
export function qmdDbPath(env = process.env, home = homedir()) {
|
||
return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'qmd', 'index.sqlite');
|
||
}
|
||
|
||
export function deviceEnv(device, env = process.env) {
|
||
const result = { ...env };
|
||
if (device === 'cpu') {
|
||
result.QMD_FORCE_CPU = '1';
|
||
delete result.QMD_LLAMA_GPU;
|
||
} else {
|
||
result.QMD_LLAMA_GPU = 'auto';
|
||
delete result.QMD_FORCE_CPU;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export function idleMinutes(env = process.env) {
|
||
const value = Number(env.QMD_IDLE_MINUTES);
|
||
return Number.isFinite(value) && value > 0 ? value : 30;
|
||
}
|
||
|
||
export function readJson(path, fallback) {
|
||
try {
|
||
return JSON.parse(readFileSync(path, 'utf8'));
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
// The state directory lives in the user profile; mode 0600 restricts the token on POSIX.
|
||
export function writePrivateJson(path, value) {
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
const temporary = `${path}.${process.pid}.tmp`;
|
||
writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
||
renameSync(temporary, path);
|
||
}
|
||
|
||
export function qmdPackageDir() {
|
||
if (process.env.QMD_PACKAGE_DIR) return process.env.QMD_PACKAGE_DIR;
|
||
const root = execFileSync('npm', ['root', '--global'], { encoding: 'utf8', shell: process.platform === 'win32' }).trim();
|
||
return join(root, '@tobilu', 'qmd');
|
||
}
|
||
|
||
export function importQmd(subpath = join('dist', 'index.js')) {
|
||
return import(pathToFileURL(join(qmdPackageDir(), subpath)).href);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `qmd-config.mjs`**
|
||
|
||
```js
|
||
// shared/qmd/qmd-config.mjs — edits only the models: block of QMD's index.yml.
|
||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { MODELS, qmdConfigPath, qmdPackageDir } from './qmd-lib.mjs';
|
||
|
||
export function setModels(text, YAML) {
|
||
const document = YAML.parseDocument(text || '');
|
||
if (document.errors.length) throw new Error(`index.yml cannot be parsed: ${document.errors[0].message}`);
|
||
if (document.contents === null) document.contents = document.createNode({});
|
||
document.set('models', document.createNode({ ...MODELS }));
|
||
return document.toString();
|
||
}
|
||
|
||
export function unsetModels(text, YAML) {
|
||
const document = YAML.parseDocument(text || '');
|
||
if (document.errors.length) throw new Error(`index.yml cannot be parsed: ${document.errors[0].message}`);
|
||
if (document.contents !== null) document.delete('models');
|
||
return document.contents === null || document.contents.items?.length === 0 ? '' : document.toString();
|
||
}
|
||
|
||
async function main(action) {
|
||
if (!['set-models', 'unset-models'].includes(action)) throw new Error('usage: qmd-config.mjs set-models|unset-models');
|
||
const YAML = await import(pathToFileURL(join(qmdPackageDir(), 'node_modules', 'yaml', 'dist', 'index.js')).href);
|
||
const path = qmdConfigPath();
|
||
const current = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
||
const next = action === 'set-models' ? setModels(current, YAML) : unsetModels(current, YAML);
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
writeFileSync(path, next);
|
||
}
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||
main(process.argv[2]).catch((error) => { console.error(`qmd-config: ${error.message}`); process.exit(1); });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-lib.mjs`
|
||
Expected: PASS, 8 tests. (Needs `@tobilu/qmd` installed globally for the YAML shim; CI installs it in Task 10.)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add shared/qmd/qmd-lib.mjs shared/qmd/qmd-config.mjs scripts/tests/test-qmd-lib.mjs scripts/tests/fixtures/yaml-shim.mjs
|
||
git commit -m "feat(qmd): add shared Node helpers and index.yml model block editing"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Search daemon (`qmd-daemon.mjs`)
|
||
|
||
**Files:**
|
||
- Create: `shared/qmd/qmd-daemon.mjs`
|
||
- Test: `scripts/tests/test-qmd-daemon.mjs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `collectionName`, `MASK`, `stateDir`, `qmdConfigPath`, `qmdDbPath`, `writePrivateJson`, `readJson`, `idleMinutes`, `importQmd` from Task 1.
|
||
- Produces:
|
||
- `createServer({ store, extractSnippet, now, idleMs, onIdle, token, git }): { server, refresh(repo), search(body) }` (exported for tests)
|
||
- HTTP API on `127.0.0.1:<port>`, header `x-qmd-token: <token>` required:
|
||
- `GET /health` → `{ status: 'ok', service: 'ai-config-qmd', pid }`
|
||
- `POST /refresh` body `{ repo }` → `{ collection, updated: boolean }`
|
||
- `POST /search` body `{ repo, query, limit }` → `{ results: [{ path, line, score, snippet }] }`
|
||
- `POST /stop` → `{ stopping: true }`
|
||
- `daemon.json` in `stateDir()`: `{ port, token, pid }`
|
||
- `collections.json` in `stateDir()`: array of repo collection names the setup created.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
// scripts/tests/test-qmd-daemon.mjs
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { createServer } from '../../shared/qmd/qmd-daemon.mjs';
|
||
|
||
function fakeStore() {
|
||
const calls = [];
|
||
return {
|
||
calls,
|
||
listCollections: async () => [{ name: 'notes', includeByDefault: true }],
|
||
getDefaultCollectionNames: async () => ['notes', 'repo-aaaaaaaaaaaa'],
|
||
addCollection: async (name, opts) => calls.push(['add', name, opts]),
|
||
update: async (opts) => { calls.push(['update', opts]); return { indexed: 1 }; },
|
||
embed: async (opts) => { calls.push(['embed', opts]); return { chunksEmbedded: 1 }; },
|
||
search: async (opts) => { calls.push(['search', opts]); return [{ displayPath: 'src/a.ts', body: 'x\nretry here', bestChunkPos: 2, bestChunk: 'retry here', score: 0.91, file: 'qmd://repo/src/a.ts' }]; },
|
||
};
|
||
}
|
||
|
||
async function start(overrides = {}) {
|
||
const store = fakeStore();
|
||
let clock = 0;
|
||
const idle = [];
|
||
const daemon = createServer({
|
||
store, token: 't0k', idleMs: 1000, now: () => clock, onIdle: () => idle.push(clock),
|
||
extractSnippet: (body) => ({ line: 2, snippet: 'retry here' }),
|
||
git: () => 'fingerprint-1', excludeCollection: async () => {}, recordCollection: () => {}, ...overrides,
|
||
});
|
||
await new Promise((done) => daemon.server.listen(0, '127.0.0.1', done));
|
||
const port = daemon.server.address().port;
|
||
const call = (path, body, token = 't0k') => fetch(`http://127.0.0.1:${port}${path}`, {
|
||
method: body === undefined ? 'GET' : 'POST', headers: { 'x-qmd-token': token, 'content-type': 'application/json' },
|
||
body: body === undefined ? undefined : JSON.stringify(body) });
|
||
return { daemon, store, call, setClock: (v) => { clock = v; }, idle, close: () => daemon.server.close() };
|
||
}
|
||
|
||
test('rejects requests without the token', async () => {
|
||
const s = await start();
|
||
assert.equal((await s.call('/health', undefined, 'wrong')).status, 403);
|
||
s.close();
|
||
});
|
||
|
||
test('health identifies the service', async () => {
|
||
const s = await start();
|
||
assert.equal((await (await s.call('/health')).json()).service, 'ai-config-qmd');
|
||
s.close();
|
||
});
|
||
|
||
test('search adds the repo collection once, refreshes on change, and filters other repos', async () => {
|
||
const s = await start();
|
||
const body = await (await s.call('/search', { repo: '/r', query: 'where are retries', limit: 3 })).json();
|
||
assert.deepEqual(body.results, [{ path: 'src/a.ts', line: 2, score: 0.91, snippet: 'retry here' }]);
|
||
const search = s.store.calls.find((c) => c[0] === 'search')[1];
|
||
assert.equal(search.query, 'where are retries');
|
||
assert.ok(search.collections.includes('notes'));
|
||
assert.ok(!search.collections.includes('repo-aaaaaaaaaaaa'));
|
||
assert.equal(search.collections.filter((n) => n.startsWith('repo-')).length, 1);
|
||
assert.equal(s.store.calls.filter((c) => c[0] === 'add').length, 1);
|
||
await s.call('/search', { repo: '/r', query: 'again', limit: 3 });
|
||
assert.equal(s.store.calls.filter((c) => c[0] === 'update').length, 1, 'unchanged fingerprint skips update');
|
||
s.close();
|
||
});
|
||
|
||
test('rejects a missing query', async () => {
|
||
const s = await start();
|
||
assert.equal((await s.call('/search', { repo: '/r' })).status, 400);
|
||
s.close();
|
||
});
|
||
|
||
test('idle check calls onIdle only after the idle window', async () => {
|
||
const s = await start();
|
||
s.setClock(500); s.daemon.checkIdle(); assert.deepEqual(s.idle, []);
|
||
s.setClock(1500); s.daemon.checkIdle(); assert.deepEqual(s.idle, [1500]);
|
||
s.close();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-daemon.mjs`
|
||
Expected: FAIL with `Cannot find module '.../shared/qmd/qmd-daemon.mjs'`
|
||
|
||
- [ ] **Step 3: Implement `qmd-daemon.mjs`**
|
||
|
||
```js
|
||
// shared/qmd/qmd-daemon.mjs — keeps QMD's models loaded for fast searches; exits when idle.
|
||
import { execFileSync, spawnSync } from 'node:child_process';
|
||
import { randomBytes } from 'node:crypto';
|
||
import { createServer as createHttpServer } from 'node:http';
|
||
import { join, relative } from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { MASK, collectionName, idleMinutes, importQmd, qmdConfigPath, qmdDbPath, readJson, stateDir, writePrivateJson } from './qmd-lib.mjs';
|
||
|
||
const MAX_BODY = 64 * 1024;
|
||
|
||
function gitFingerprint(repo) {
|
||
try {
|
||
return execFileSync('git', ['-C', repo, 'status', '--porcelain=v1', '-uall'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
||
+ execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
||
} catch {
|
||
return String(Date.now());
|
||
}
|
||
}
|
||
|
||
export function createServer({ store, token, idleMs, now = Date.now, onIdle, extractSnippet, git = gitFingerprint,
|
||
excludeCollection, recordCollection }) {
|
||
let lastRequest = now();
|
||
const known = new Set();
|
||
const fingerprints = new Map();
|
||
|
||
async function refresh(repo) {
|
||
const name = collectionName(repo);
|
||
if (!known.has(name)) {
|
||
const existing = (await store.listCollections()).some((c) => c.name === name);
|
||
if (!existing) {
|
||
await store.addCollection(name, { path: repo, pattern: MASK });
|
||
await excludeCollection(name);
|
||
recordCollection(name);
|
||
}
|
||
known.add(name);
|
||
}
|
||
const fingerprint = git(repo);
|
||
if (fingerprints.get(name) === fingerprint) return { collection: name, updated: false };
|
||
await store.update({ collections: [name] });
|
||
await store.embed({ collection: name, chunkStrategy: 'auto' });
|
||
fingerprints.set(name, fingerprint);
|
||
return { collection: name, updated: true };
|
||
}
|
||
|
||
async function search({ repo, query, limit }) {
|
||
const { collection } = await refresh(repo);
|
||
const defaults = (await store.getDefaultCollectionNames()).filter((n) => !n.startsWith('repo-'));
|
||
const results = await store.search({ query, collections: [collection, ...defaults], limit: limit || 5, chunkStrategy: 'auto' });
|
||
return results.map((r) => {
|
||
const { line, snippet } = extractSnippet(r.body, query, 300, r.bestChunkPos, r.bestChunk.length);
|
||
const path = r.file?.startsWith(`qmd://${collection}/`) || !r.file?.startsWith('qmd://') ? r.displayPath : r.file;
|
||
return { path, line, score: Math.round(r.score * 100) / 100, snippet };
|
||
});
|
||
}
|
||
|
||
const server = createHttpServer(async (request, response) => {
|
||
const reply = (status, value) => {
|
||
response.writeHead(status, { 'content-type': 'application/json' });
|
||
response.end(JSON.stringify(value));
|
||
};
|
||
if (request.headers['x-qmd-token'] !== token) return reply(403, { error: 'forbidden' });
|
||
lastRequest = now();
|
||
try {
|
||
if (request.method === 'GET' && request.url === '/health') return reply(200, { status: 'ok', service: 'ai-config-qmd', pid: process.pid });
|
||
let raw = '';
|
||
for await (const chunk of request) {
|
||
raw += chunk;
|
||
if (raw.length > MAX_BODY) return reply(413, { error: 'body too large' });
|
||
}
|
||
const body = raw ? JSON.parse(raw) : {};
|
||
if (request.method === 'POST' && request.url === '/stop') {
|
||
reply(200, { stopping: true });
|
||
return onIdle();
|
||
}
|
||
if (typeof body.repo !== 'string' || !body.repo) return reply(400, { error: 'repo is required' });
|
||
if (request.method === 'POST' && request.url === '/refresh') return reply(200, await refresh(body.repo));
|
||
if (request.method === 'POST' && request.url === '/search') {
|
||
if (typeof body.query !== 'string' || !body.query.trim()) return reply(400, { error: 'query is required' });
|
||
return reply(200, { results: await search(body) });
|
||
}
|
||
return reply(404, { error: 'not found' });
|
||
} catch (error) {
|
||
return reply(500, { error: String(error?.message || error) });
|
||
} finally {
|
||
lastRequest = now();
|
||
}
|
||
});
|
||
|
||
const checkIdle = () => { if (now() - lastRequest >= idleMs) onIdle(); };
|
||
return { server, refresh, search, checkIdle };
|
||
}
|
||
|
||
async function main() {
|
||
const dir = stateDir();
|
||
const daemonFile = join(dir, 'daemon.json');
|
||
const current = readJson(daemonFile, null);
|
||
if (current) {
|
||
try {
|
||
const health = await fetch(`http://127.0.0.1:${current.port}/health`, { headers: { 'x-qmd-token': current.token }, signal: AbortSignal.timeout(2000) });
|
||
if (health.ok && (await health.json()).service === 'ai-config-qmd') return;
|
||
} catch { /* stale file: start a new daemon */ }
|
||
}
|
||
const { createStore, extractSnippet } = await importQmd();
|
||
const store = await createStore({ dbPath: qmdDbPath(), configPath: qmdConfigPath() });
|
||
const collectionsFile = join(dir, 'collections.json');
|
||
const token = randomBytes(32).toString('hex');
|
||
let stopping = false;
|
||
const stop = async () => {
|
||
if (stopping) return;
|
||
stopping = true;
|
||
const latest = readJson(daemonFile, null);
|
||
if (latest?.pid === process.pid) writePrivateJson(daemonFile, { stopped: true });
|
||
await store.close();
|
||
process.exit(0);
|
||
};
|
||
const daemon = createServer({
|
||
store, token, idleMs: idleMinutes() * 60_000, onIdle: stop, extractSnippet,
|
||
excludeCollection: async (name) => { spawnSync('qmd', ['collection', 'exclude', name], { shell: process.platform === 'win32', stdio: 'ignore' }); },
|
||
recordCollection: (name) => {
|
||
const names = new Set(readJson(collectionsFile, []));
|
||
names.add(name);
|
||
writePrivateJson(collectionsFile, [...names].sort());
|
||
},
|
||
});
|
||
daemon.server.listen(0, '127.0.0.1', () => {
|
||
writePrivateJson(daemonFile, { port: daemon.server.address().port, token, pid: process.pid });
|
||
});
|
||
setInterval(daemon.checkIdle, 60_000).unref();
|
||
process.on('SIGTERM', stop);
|
||
process.on('SIGINT', stop);
|
||
}
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||
main().catch((error) => { console.error(`qmd-daemon: ${error.message}`); process.exit(1); });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-daemon.mjs`
|
||
Expected: PASS, 5 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add shared/qmd/qmd-daemon.mjs scripts/tests/test-qmd-daemon.mjs
|
||
git commit -m "feat(qmd): add token-protected search daemon with idle exit"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Search wrapper (`qmd-search.mjs`)
|
||
|
||
**Files:**
|
||
- Create: `shared/qmd/qmd-search.mjs`
|
||
- Test: `scripts/tests/test-qmd-search.mjs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `stateDir`, `readJson`, `writePrivateJson`, `setupStatePath`, `deviceEnv`, `collectionName` (Task 1); daemon HTTP API (Task 2).
|
||
- Produces:
|
||
- CLI: `node qmd-search.mjs --query "<question>" [--top-k 5] [--root <repo>]` → stdout JSON array `[{path, line, score, snippet}]`; exit 1 with a one-line stderr message on failure.
|
||
- CLI: `node qmd-search.mjs --stop` → asks a running daemon to stop; exit 0 even when none runs.
|
||
- Exported `runSearch({ root, query, topK, daemon, startDaemon, cli }): Promise<results>` for tests, where `daemon(path, body)` returns parsed JSON or throws, `startDaemon()` returns a promise, and `cli(args)` returns parsed JSON.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
// scripts/tests/test-qmd-search.mjs
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { runSearch, toCliResults } from '../../shared/qmd/qmd-search.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://repo-x/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' }]);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-search.mjs`
|
||
Expected: FAIL with `Cannot find module '.../shared/qmd/qmd-search.mjs'`
|
||
|
||
- [ ] **Step 3: Implement `qmd-search.mjs`**
|
||
|
||
```js
|
||
// 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, 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();
|
||
}
|
||
|
||
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.pid === child.pid) return;
|
||
}
|
||
throw new Error('daemon did not start within 120 s');
|
||
}
|
||
|
||
function runCli(args) {
|
||
const device = readJson(setupStatePath(), {}).device || 'cpu';
|
||
const output = execFileSync('qmd', args, { encoding: 'utf8', env: deviceEnv(device), shell: process.platform === 'win32', maxBuffer: 16 * 1024 * 1024 });
|
||
return JSON.parse(output);
|
||
}
|
||
|
||
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(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); });
|
||
}
|
||
```
|
||
|
||
Note: the CLI fallback searches only the repository collection; a fresh repository without an index returns an empty list there, which the skill treats as "no hit".
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-search.mjs`
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add shared/qmd/qmd-search.mjs scripts/tests/test-qmd-search.mjs
|
||
git commit -m "feat(qmd): add search wrapper with daemon start and CLI fallback"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Warm-up script and SessionStart hook
|
||
|
||
**Files:**
|
||
- Create: `shared/qmd/qmd-warm.mjs`
|
||
- Create: `shared/hooks/scripts/qmd-warm.sh`, `shared/hooks/scripts/Start-QmdWarm.ps1`
|
||
- Modify: `adapters/claude/config/settings.json` (SessionStart), `adapters/codex/config/config.toml` (`[[hooks.SessionStart]]`, `writable_roots`)
|
||
- Modify: `scripts/lib/install-options.py` (`filter_options` gains `semantic_retrieval_enabled`), `scripts/lib/manifest.sh:131,208-215`, `scripts/lib/manifest.ps1:145,173,227` (pass the flag to the filter and strip the instruction line)
|
||
- Test: `scripts/tests/test-qmd-warm.mjs`, `scripts/tests/test-install-options.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: daemon API (Task 2), `startDaemon` behavior (Task 3).
|
||
- Produces:
|
||
- `node qmd-warm.mjs --root <repo>`: exits 0 always; logs to `stateDir()/warm.log`.
|
||
- Exported `withLock(lockPath, fn, now)`: runs `fn` only if no fresh lock (younger than 30 minutes) exists.
|
||
- `filter_options(path, flashbang_enabled=True, statusline_enabled=True, semantic_retrieval_enabled=True)` removes hooks whose command names `qmd-warm.sh` or `Start-QmdWarm.ps1` when disabled.
|
||
- `filter_instructions(text: str, semantic_retrieval_enabled: bool) -> str` removes lines that mention `` `semantic-search` `` when disabled.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
// scripts/tests/test-qmd-warm.mjs
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { withLock } from '../../shared/qmd/qmd-warm.mjs';
|
||
|
||
test('a fresh lock skips the second warm-up and is released afterwards', async () => {
|
||
const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock');
|
||
let runs = 0;
|
||
await withLock(lock, async () => {
|
||
runs += 1;
|
||
await withLock(lock, async () => { runs += 1; });
|
||
});
|
||
assert.equal(runs, 1);
|
||
assert.equal(existsSync(lock), false);
|
||
});
|
||
|
||
test('a stale lock older than 30 minutes is replaced', async () => {
|
||
const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock');
|
||
writeFileSync(lock, String(Date.now() - 31 * 60_000));
|
||
let runs = 0;
|
||
await withLock(lock, async () => { runs += 1; });
|
||
assert.equal(runs, 1);
|
||
});
|
||
```
|
||
|
||
```python
|
||
# scripts/tests/test-install-options.py — add to the existing TestCase class
|
||
def test_semantic_retrieval_disabled_removes_qmd_session_hook(self):
|
||
settings = Path(self.directory) / 'settings.json'
|
||
settings.write_text(json.dumps({'hooks': {'SessionStart': [
|
||
{'hooks': [{'type': 'command', 'command': 'bash "/c/hooks/scripts/show-session-state-pointer.sh"'}]},
|
||
{'hooks': [{'type': 'command', 'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'}]}]}}), encoding='utf-8')
|
||
result = json.loads(module.filter_options(settings, semantic_retrieval_enabled=False))
|
||
commands = [hook['command'] for group in result['hooks']['SessionStart'] for hook in group['hooks']]
|
||
self.assertEqual(commands, ['bash "/c/hooks/scripts/show-session-state-pointer.sh"'])
|
||
|
||
def test_semantic_retrieval_disabled_removes_codex_session_hook(self):
|
||
config = Path(self.directory) / 'config.toml'
|
||
config.write_text('[features]\nhooks = true\n\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\n'
|
||
'command = \'bash "/c/hooks/scripts/qmd-warm.sh"\'\ncommand_windows = \'pwsh "/c/hooks/scripts/Start-QmdWarm.ps1"\'\ntimeout = 4\n',
|
||
encoding='utf-8')
|
||
result = module.filter_options(config, semantic_retrieval_enabled=False)
|
||
self.assertNotIn('qmd-warm', result)
|
||
self.assertNotIn('hooks.SessionStart', result)
|
||
|
||
def test_instruction_line_only_with_semantic_search(self):
|
||
text = 'Line one.\nWhen `semantic-search` is installed, run it first.\nLine three.\n'
|
||
self.assertEqual(module.filter_instructions(text, False), 'Line one.\nLine three.\n')
|
||
self.assertEqual(module.filter_instructions(text, True), text)
|
||
```
|
||
|
||
(Use the module loader and `self.directory` temp-dir fixture the file already defines; if it names them differently, adapt the two identifiers only.)
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-warm.mjs` → FAIL (module missing).
|
||
Run: `python scripts/tests/test-install-options.py` → FAIL (`unexpected keyword argument 'semantic_retrieval_enabled'`).
|
||
|
||
- [ ] **Step 3: Implement `qmd-warm.mjs`**
|
||
|
||
```js
|
||
// shared/qmd/qmd-warm.mjs — background warm-up started by the SessionStart hook; never fails the session.
|
||
import { execFileSync } from 'node:child_process';
|
||
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { parseArgs } from 'node:util';
|
||
import { collectionName, readJson, stateDir } from './qmd-lib.mjs';
|
||
import { startDaemon } from './qmd-search.mjs';
|
||
|
||
const STALE_MS = 30 * 60_000;
|
||
|
||
export async function withLock(lockPath, fn, now = Date.now) {
|
||
try {
|
||
writeFileSync(lockPath, String(now()), { flag: 'wx' });
|
||
} catch {
|
||
const age = now() - Number(readFileSync(lockPath, 'utf8'));
|
||
if (!(age > STALE_MS)) return false;
|
||
writeFileSync(lockPath, String(now()));
|
||
}
|
||
try {
|
||
await fn();
|
||
return true;
|
||
} finally {
|
||
rmSync(lockPath, { force: true });
|
||
}
|
||
}
|
||
|
||
async function refresh(root) {
|
||
const info = readJson(join(stateDir(), 'daemon.json'), null);
|
||
if (!info?.port) throw new Error('daemon not running');
|
||
const response = await fetch(`http://127.0.0.1:${info.port}/refresh`, { method: 'POST',
|
||
headers: { 'x-qmd-token': info.token, 'content-type': 'application/json' }, body: JSON.stringify({ repo: root }),
|
||
signal: AbortSignal.timeout(35 * 60_000) });
|
||
if (!response.ok) throw new Error(`refresh answered ${response.status}`);
|
||
}
|
||
|
||
async function main() {
|
||
const { values } = parseArgs({ options: { root: { type: 'string' } } });
|
||
const root = resolve(values.root || execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim());
|
||
const dir = stateDir();
|
||
mkdirSync(dir, { recursive: true });
|
||
// A refresh through the daemon indexes the repository incrementally; start the daemon first if needed.
|
||
await withLock(join(dir, `${collectionName(root)}.lock`), async () => {
|
||
try {
|
||
await refresh(root);
|
||
} catch {
|
||
await startDaemon();
|
||
await refresh(root);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||
main().catch((error) => {
|
||
try { appendFileSync(join(stateDir(), 'warm.log'), `${new Date().toISOString()} ${error.message}
|
||
`); } catch { /* ignore */ }
|
||
}).finally(() => process.exit(0));
|
||
}
|
||
```
|
||
|
||
In `shared/qmd/qmd-search.mjs` (Task 3), change `async function startDaemon()` to `export async function startDaemon()` so the warm-up reuses the same starter.
|
||
|
||
- [ ] **Step 4: Add the hook entry points**
|
||
|
||
```bash
|
||
# shared/hooks/scripts/qmd-warm.sh
|
||
#!/usr/bin/env bash
|
||
# SessionStart: warm the QMD index and daemon in the background; never blocks or fails the session.
|
||
script="$HOME/.my-ai-configuration/qmd/qmd-warm.mjs"
|
||
if [ -f "$script" ] && command -v node >/dev/null 2>&1 && git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||
nohup node "$script" >/dev/null 2>&1 &
|
||
fi
|
||
exit 0
|
||
```
|
||
|
||
```powershell
|
||
# shared/hooks/scripts/Start-QmdWarm.ps1
|
||
# SessionStart: warm the QMD index and daemon in the background; never blocks or fails the session.
|
||
$ErrorActionPreference = 'SilentlyContinue'
|
||
$script = Join-Path $HOME '.my-ai-configuration/qmd/qmd-warm.mjs'
|
||
$node = Get-Command node -ErrorAction SilentlyContinue
|
||
if ((Test-Path -LiteralPath $script) -and $node) {
|
||
$null = git rev-parse --show-toplevel 2>$null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Start-Process -FilePath $node.Source -ArgumentList @("`"$script`"") -WindowStyle Hidden -WorkingDirectory (Get-Location).Path
|
||
}
|
||
}
|
||
exit 0
|
||
```
|
||
|
||
- [ ] **Step 5: Register the hooks in the adapters**
|
||
|
||
`adapters/claude/config/settings.json`, replace the `SessionStart` line with:
|
||
|
||
```json
|
||
"SessionStart": [
|
||
{"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__SESSION_POINTER_SCRIPT__\"", "timeout": 4}]},
|
||
{"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__QMD_WARM_SCRIPT__\"", "timeout": 4}]}
|
||
],
|
||
```
|
||
|
||
`adapters/codex/config/config.toml`, replace the `writable_roots` line and append the hook:
|
||
|
||
```toml
|
||
writable_roots = ['__AI_CONFIG_ROOT__/../.my-ai-configuration/qmd', '__AI_CONFIG_ROOT__/../.cache/qmd']
|
||
```
|
||
|
||
```toml
|
||
[[hooks.SessionStart]]
|
||
[[hooks.SessionStart.hooks]]
|
||
type = "command"
|
||
command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/scripts/qmd-warm.sh"'
|
||
command_windows = '__POWERSHELL_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/scripts/Start-QmdWarm.ps1"'
|
||
timeout = 4
|
||
```
|
||
|
||
Substitute `__QMD_WARM_SCRIPT__` wherever `__SESSION_POINTER_SCRIPT__` is substituted today (find it with `rg -n "__SESSION_POINTER_SCRIPT__" scripts`): `qmd-warm.sh` for Bash packages, `Start-QmdWarm.ps1` for PowerShell packages, mirroring the existing pair `show-session-state-pointer.sh` / `Show-SessionStatePointer.ps1`.
|
||
|
||
- [ ] **Step 6: Extend the install-time filter**
|
||
|
||
In `scripts/lib/install-options.py`:
|
||
|
||
```python
|
||
def is_qmd_warm(hook):
|
||
return any(re.search(r'(?:qmd-warm\.sh|Start-QmdWarm\.ps1)', str(hook.get(key, '')), re.I)
|
||
for key in ('command', 'command_windows'))
|
||
|
||
|
||
def without_hooks(groups, predicate):
|
||
remaining = []
|
||
for group in groups:
|
||
hooks = [hook for hook in group.get('hooks', []) if not predicate(hook)]
|
||
if hooks:
|
||
remaining.append(dict(group, hooks=hooks))
|
||
return remaining
|
||
|
||
|
||
def filter_instructions(text, semantic_retrieval_enabled):
|
||
if semantic_retrieval_enabled:
|
||
return text
|
||
return ''.join(line for line in text.splitlines(keepends=True) if '`semantic-search`' not in line)
|
||
```
|
||
|
||
Change the signature to `def filter_options(path, flashbang_enabled=True, statusline_enabled=True, semantic_retrieval_enabled=True):`. In the `settings.json` branch, after the flashbang block, add:
|
||
|
||
```python
|
||
if not semantic_retrieval_enabled:
|
||
remaining = without_hooks(settings.get('hooks', {}).get('SessionStart', []), is_qmd_warm)
|
||
if remaining:
|
||
settings['hooks']['SessionStart'] = remaining
|
||
else:
|
||
settings.get('hooks', {}).pop('SessionStart', None)
|
||
```
|
||
|
||
In the `config.toml` branch, after the flashbang block, add:
|
||
|
||
```python
|
||
if not semantic_retrieval_enabled:
|
||
sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', result)
|
||
kept = []
|
||
for section in sections:
|
||
if re.match(r'\s*\[\[hooks\.SessionStart\.hooks\]\]', section):
|
||
hook = tomllib.loads(section)['hooks']['SessionStart']['hooks'][0]
|
||
if is_qmd_warm(hook):
|
||
continue
|
||
kept.append(section)
|
||
result = ''.join(kept)
|
||
result = re.sub(r'(?m)^\[\[hooks\.SessionStart\]\][ \t]*\r?\n\s*(?=\[|\Z)(?!\[\[hooks\.SessionStart\.hooks\]\])', '', result)
|
||
```
|
||
|
||
Add `--semantic-retrieval true|false` to the existing `filter` subcommand's argument parser and pass it through. Add a `filter-instructions --path <file> --semantic-retrieval true|false` subcommand printing `filter_instructions(...)`.
|
||
|
||
- [ ] **Step 7: Pass the flag from the managed sync**
|
||
|
||
`scripts/lib/manifest.sh` (inside the loop around line 208): extend the filter condition and call:
|
||
|
||
```bash
|
||
if { [ "$flashbang_enabled" = false ] || [ "$statusline_enabled" = false ] || [ "$semantic_retrieval_enabled" = false ]; } && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then
|
||
content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang "$flashbang_enabled" --statusline "$statusline_enabled" --semantic-retrieval "$semantic_retrieval_enabled" && printf '\034') || return
|
||
content=${content%$'\034'}
|
||
needs_sub=true
|
||
fi
|
||
if [ "$semantic_retrieval_enabled" = false ] && [[ "$relative" = CLAUDE.md || "$relative" = AGENTS.md ]]; then
|
||
content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter-instructions --path "$source_file" --semantic-retrieval false && printf '\034') || return
|
||
content=${content%$'\034'}
|
||
needs_sub=true
|
||
fi
|
||
```
|
||
|
||
`scripts/lib/manifest.ps1`: make the equivalent change where it calls `install-options.py filter` (pass `--semantic-retrieval $SemanticRetrievalEnabled.ToString().ToLowerInvariant()`), and add the same `filter-instructions` call for `CLAUDE.md` / `AGENTS.md` when `-not $SemanticRetrievalEnabled`.
|
||
|
||
- [ ] **Step 8: Run tests to verify they pass**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-warm.mjs` → PASS, 2 tests.
|
||
Run: `python scripts/tests/test-install-options.py` → PASS.
|
||
Run: `bash scripts/tests/test-managed-install.sh --summary` and `pwsh -NoProfile -File scripts/tests/test-managed-install.ps1 -Summary` → PASS.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add shared/qmd/qmd-warm.mjs shared/qmd/qmd-search.mjs shared/hooks/scripts/qmd-warm.sh shared/hooks/scripts/Start-QmdWarm.ps1 \
|
||
adapters/claude/config/settings.json adapters/codex/config/config.toml scripts/lib/install-options.py scripts/lib/manifest.sh scripts/lib/manifest.ps1 \
|
||
scripts/tests/test-qmd-warm.mjs scripts/tests/test-install-options.py
|
||
git commit -m "feat(qmd): warm the index and daemon from a SessionStart hook"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Auto benchmark (`qmd-benchmark.mjs`)
|
||
|
||
**Files:**
|
||
- Create: `shared/qmd/qmd-benchmark.mjs`
|
||
- Test: `scripts/tests/test-qmd-benchmark.mjs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MODELS`, `deviceEnv`, `importQmd`, `qmdPackageDir` (Task 1).
|
||
- Produces:
|
||
- `node qmd-benchmark.mjs --detect` → stdout `{"gpu": "cuda"|"vulkan"|"metal"|false, "devices": [GPU names], "threads": <logical cores>}`
|
||
- `node qmd-benchmark.mjs --device gpu|cpu` → stdout `{"device", "querySeconds", "chunksPerSecond", "passed"}`; process env must already carry `deviceEnv(device)` (the Python caller sets it).
|
||
- Exported `passes({querySeconds, chunksPerSecond}, env): boolean`, `fixtureDocuments(count): Array<{name, text}>`, `describeDevices({gpu, devices, threads}): string`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
// scripts/tests/test-qmd-benchmark.mjs
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { passes, fixtureDocuments, describeDevices } from '../../shared/qmd/qmd-benchmark.mjs';
|
||
|
||
test('both thresholds must pass', () => {
|
||
assert.equal(passes({ querySeconds: 5, chunksPerSecond: 20 }, {}), true);
|
||
assert.equal(passes({ querySeconds: 5.01, chunksPerSecond: 50 }, {}), false);
|
||
assert.equal(passes({ querySeconds: 1, chunksPerSecond: 19.9 }, {}), false);
|
||
});
|
||
|
||
test('thresholds are overridable', () => {
|
||
assert.equal(passes({ querySeconds: 8, chunksPerSecond: 5 }, { QMD_BENCHMARK_MAX_QUERY_SECONDS: '10', QMD_BENCHMARK_MIN_CHUNKS_PER_SECOND: '4' }), true);
|
||
});
|
||
|
||
test('device description lists every GPU and the thread count', () => {
|
||
assert.equal(describeDevices({ gpu: 'cuda', devices: ['RTX 4090', 'RTX 3090'], threads: 64 }), 'CUDA: RTX 4090, RTX 3090; CPU threads: 64');
|
||
assert.equal(describeDevices({ gpu: false, devices: [], threads: 16 }), 'no GPU; CPU threads: 16');
|
||
});
|
||
|
||
test('fixture is deterministic and large enough for throughput', () => {
|
||
const a = fixtureDocuments(100);
|
||
assert.equal(a.length, 100);
|
||
assert.deepEqual(a, fixtureDocuments(100));
|
||
assert.ok(a.every((d) => d.text.length > 2500));
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-benchmark.mjs`
|
||
Expected: FAIL (module missing).
|
||
|
||
- [ ] **Step 3: Implement `qmd-benchmark.mjs`**
|
||
|
||
```js
|
||
// shared/qmd/qmd-benchmark.mjs — measures warm QMD search latency and embedding throughput on one device.
|
||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { availableParallelism, tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { parseArgs } from 'node:util';
|
||
import { MODELS, importQmd, qmdPackageDir } from './qmd-lib.mjs';
|
||
|
||
export function passes({ querySeconds, chunksPerSecond }, env = process.env) {
|
||
const maxQuery = Number(env.QMD_BENCHMARK_MAX_QUERY_SECONDS) || 5;
|
||
const minChunks = Number(env.QMD_BENCHMARK_MIN_CHUNKS_PER_SECOND) || 20;
|
||
return querySeconds <= maxQuery && chunksPerSecond >= minChunks;
|
||
}
|
||
|
||
const TOPICS = ['invoice rounding', 'upload retries', 'trial expiry', 'cache invalidation', 'session tokens'];
|
||
|
||
export function fixtureDocuments(count) {
|
||
return Array.from({ length: count }, (_, index) => {
|
||
const topic = TOPICS[index % TOPICS.length];
|
||
const sections = Array.from({ length: 6 }, (__, section) =>
|
||
`## ${topic} part ${section}\n\nThis section ${index}-${section} explains how ${topic} behaves when the service handles request ${index * 7 + section}. ` +
|
||
`It lists the edge cases, the configuration keys, and the error messages that operators see during incidents.\n`.repeat(2));
|
||
return { name: `doc-${String(index).padStart(3, '0')}.md`, text: `# ${topic} ${index}\n\n${sections.join('\n')}` };
|
||
});
|
||
}
|
||
|
||
export function describeDevices({ gpu, devices, threads }) {
|
||
const gpus = gpu ? `${String(gpu).toUpperCase()}: ${devices.join(', ') || 'unnamed device'}` : 'no GPU';
|
||
return `${gpus}; CPU threads: ${threads}`;
|
||
}
|
||
|
||
// llama.cpp's automatic backend uses every GPU of the chosen backend; nothing here narrows the device set.
|
||
async function detect() {
|
||
const { getLlama } = await import(pathToFileURL(join(qmdPackageDir(), 'node_modules', 'node-llama-cpp', 'dist', 'index.js')).href);
|
||
const llama = await getLlama({ gpu: 'auto' });
|
||
const gpu = llama.gpu;
|
||
const devices = gpu ? await llama.getGpuDeviceNames() : [];
|
||
await llama.dispose();
|
||
return { gpu, devices, threads: availableParallelism() };
|
||
}
|
||
|
||
async function measure(device) {
|
||
for (const [key, value] of Object.entries({ QMD_EMBED_MODEL: MODELS.embed, QMD_RERANK_MODEL: MODELS.rerank, QMD_GENERATE_MODEL: MODELS.generate })) {
|
||
process.env[key] = value;
|
||
}
|
||
const work = mkdtempSync(join(tmpdir(), 'ai-config-qmd-bench-'));
|
||
try {
|
||
const docs = join(work, 'docs');
|
||
mkdirSync(docs);
|
||
for (const doc of fixtureDocuments(100)) writeFileSync(join(docs, doc.name), doc.text);
|
||
const { createStore } = await importQmd();
|
||
const store = await createStore({ dbPath: join(work, 'index.sqlite'), config: { collections: { bench: { path: docs, pattern: '**/*.md' } } } });
|
||
try {
|
||
await store.update();
|
||
await store.searchVector('warm up the embedding model', { limit: 1 });
|
||
const embedded = await store.embed({ collection: 'bench' });
|
||
const chunksPerSecond = embedded.chunksEmbedded / Math.max(embedded.durationMs / 1000, 0.001);
|
||
const question = 'Which part explains how upload retries behave during incidents?';
|
||
await store.search({ query: question, collections: ['bench'], limit: 5 });
|
||
const times = [];
|
||
for (let run = 0; run < 3; run += 1) {
|
||
const started = performance.now();
|
||
await store.search({ query: `${question} (${run})`, collections: ['bench'], limit: 5, candidateLimit: 40 });
|
||
times.push((performance.now() - started) / 1000);
|
||
}
|
||
const querySeconds = times.sort((a, b) => a - b)[1];
|
||
const result = { device, querySeconds: Number(querySeconds.toFixed(3)), chunksPerSecond: Number(chunksPerSecond.toFixed(1)) };
|
||
return { ...result, passed: passes(result) };
|
||
} finally {
|
||
await store.close();
|
||
}
|
||
} finally {
|
||
rmSync(work, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const { values } = parseArgs({ options: { detect: { type: 'boolean' }, device: { type: 'string' } } });
|
||
if (values.detect) return console.log(JSON.stringify(await detect()));
|
||
if (!['gpu', 'cpu'].includes(values.device)) throw new Error('--device gpu|cpu or --detect is required');
|
||
console.log(JSON.stringify(await measure(values.device)));
|
||
}
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||
main().catch((error) => { console.error(`qmd-benchmark: ${error.message}`); process.exit(1); });
|
||
}
|
||
```
|
||
|
||
Each query in the measured loop has a distinct suffix so QMD's expansion cache cannot hide model latency.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `node --test scripts/tests/test-qmd-benchmark.mjs`
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add shared/qmd/qmd-benchmark.mjs scripts/tests/test-qmd-benchmark.mjs
|
||
git commit -m "feat(qmd): add GPU and CPU benchmark for Auto selection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Installer library (`scripts/lib/qmd.py` with Bash and PowerShell wrappers)
|
||
|
||
**Files:**
|
||
- Create: `scripts/lib/qmd.py`, `scripts/lib/qmd.sh`, `scripts/lib/qmd.ps1`
|
||
- Test: `scripts/tests/test-qmd-setup.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Node scripts from Tasks 1–5 (copied from `shared/qmd/`).
|
||
- Produces:
|
||
- `python scripts/lib/qmd.py sync --root R --home H --client codex|claude|both --enabled true|false [--dry-run] [--update] [--summary]` → exit 0/1.
|
||
- `python scripts/lib/qmd.py benchmark --root R --home H [--summary]` → exit 0 (suitable; device stored), 2 (unsuitable; everything setup-owned removed), 1 (error).
|
||
- Bash: `sync_qmd <root> <client> <home> <enabled> <dry-run> <update> [summary]`, `test_qmd_device <root> <home> [summary]` (returns 0 suitable, 1 unsuitable, 2 error).
|
||
- PowerShell: `Sync-Qmd -RepositoryRoot -HomePath -Client -Enabled [-DryRun] [-Update] [-Summary]`, `Test-QmdDevice -RepositoryRoot -HomePath [-Summary]` → `$true`/`$false`, throws on error.
|
||
- Python functions (tested): `decide(results: list[dict]) -> str | None`, `node_version_ok(text: str) -> bool`, `class Setup(home, root, run, dry_run, summary)` with `enable(clients)`, `disable(clients)`, `benchmark() -> bool`, `migrate()`.
|
||
- State `qmd.json`: `{"version": 1, "clients": [...], "device": "gpu"|"cpu"|null, "owned_package": bool, "owned_models": [file names], "models_block": bool, "files": {name: sha256}}`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# scripts/tests/test-qmd-setup.py
|
||
import importlib.util
|
||
import json
|
||
from pathlib import Path
|
||
import shutil
|
||
import tempfile
|
||
import unittest
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
spec = importlib.util.spec_from_file_location('qmd_setup', ROOT / 'scripts/lib/qmd.py')
|
||
qmd = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(qmd)
|
||
|
||
|
||
class FakeRunner:
|
||
def __init__(self, installed=False, gpu='cuda', gpu_result=None, cpu_result=None):
|
||
self.calls, self.installed, self.gpu = [], installed, gpu
|
||
self.results = {'gpu': gpu_result, 'cpu': cpu_result}
|
||
|
||
def __call__(self, arguments, env=None, timeout=None):
|
||
self.calls.append(list(arguments))
|
||
joined = ' '.join(arguments)
|
||
if arguments[:2] == ['node', '--version']:
|
||
return 'v22.4.0'
|
||
if 'npm list' in joined:
|
||
return json.dumps({'dependencies': {'@tobilu/qmd': {}} if self.installed else {}})
|
||
if 'npm install' in joined:
|
||
self.installed = True
|
||
if 'npm uninstall' in joined:
|
||
self.installed = False
|
||
if '--detect' in joined:
|
||
return json.dumps({'gpu': self.gpu, 'devices': ['GPU 0', 'GPU 1'] if self.gpu else [], 'threads': 32})
|
||
if '--device' in joined:
|
||
device = arguments[arguments.index('--device') + 1]
|
||
return json.dumps(self.results[device])
|
||
return ''
|
||
|
||
|
||
class SetupTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.home = Path(tempfile.mkdtemp())
|
||
self.addCleanup(shutil.rmtree, self.home, ignore_errors=True)
|
||
(self.home / '.cache/qmd/models').mkdir(parents=True)
|
||
|
||
def setup(self, runner, dry_run=False):
|
||
return qmd.Setup(self.home, ROOT, runner, dry_run=dry_run, summary=True)
|
||
|
||
def state(self):
|
||
return json.loads((self.home / '.my-ai-configuration/qmd.json').read_text(encoding='utf-8'))
|
||
|
||
def test_decide_prefers_gpu_then_cpu(self):
|
||
self.assertEqual(qmd.decide([{'device': 'gpu', 'passed': True}, {'device': 'cpu', 'passed': True}]), 'gpu')
|
||
self.assertEqual(qmd.decide([{'device': 'gpu', 'passed': False}, {'device': 'cpu', 'passed': True}]), 'cpu')
|
||
self.assertIsNone(qmd.decide([{'device': 'gpu', 'passed': False}, {'device': 'cpu', 'passed': False}]))
|
||
self.assertIsNone(qmd.decide([]))
|
||
|
||
def test_node_version(self):
|
||
self.assertTrue(qmd.node_version_ok('v22.0.0'))
|
||
self.assertFalse(qmd.node_version_ok('v20.11.1'))
|
||
self.assertFalse(qmd.node_version_ok('garbage'))
|
||
|
||
def test_enable_installs_package_scripts_and_records_ownership(self):
|
||
runner = FakeRunner()
|
||
self.setup(runner).enable(['claude'])
|
||
state = self.state()
|
||
self.assertTrue(state['owned_package'])
|
||
self.assertEqual(state['device'], 'gpu')
|
||
self.assertTrue(state['models_block'])
|
||
self.assertTrue((self.home / '.my-ai-configuration/qmd/qmd-search.mjs').is_file())
|
||
self.assertIn(['npm', 'install', '--global', '@tobilu/qmd@2.8.3'], runner.calls)
|
||
|
||
def test_enable_keeps_foreign_package_unowned(self):
|
||
runner = FakeRunner(installed=True)
|
||
self.setup(runner).enable(['claude'])
|
||
self.assertFalse(self.state()['owned_package'])
|
||
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
|
||
|
||
def test_disable_never_removes_foreign_package(self):
|
||
runner = FakeRunner(installed=True)
|
||
setup = self.setup(runner)
|
||
setup.enable(['claude'])
|
||
setup.disable(['claude'])
|
||
self.assertFalse(any('npm uninstall' in ' '.join(c) for c in runner.calls))
|
||
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||
|
||
def test_disable_removes_owned_models_and_collections(self):
|
||
runner = FakeRunner()
|
||
setup = self.setup(runner)
|
||
setup.enable(['claude'])
|
||
state = self.state()
|
||
model = self.home / '.cache/qmd/models' / 'qwen3-embedding.gguf'
|
||
model.write_text('x')
|
||
state['owned_models'] = ['qwen3-embedding.gguf']
|
||
(self.home / '.my-ai-configuration/qmd.json').write_text(json.dumps(state))
|
||
(self.home / '.my-ai-configuration/qmd/collections.json').write_text(json.dumps(['repo-abcdefabcdef']))
|
||
setup.disable(['claude'])
|
||
self.assertFalse(model.exists())
|
||
self.assertIn(['qmd', 'collection', 'remove', 'repo-abcdefabcdef'], runner.calls)
|
||
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||
|
||
def test_benchmark_unsuitable_cleans_up(self):
|
||
runner = FakeRunner(gpu=False, cpu_result={'device': 'cpu', 'passed': False, 'querySeconds': 9, 'chunksPerSecond': 3})
|
||
self.assertFalse(self.setup(runner).benchmark())
|
||
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||
self.assertFalse(any('--device' in c and 'gpu' in c for c in runner.calls), 'no GPU means no GPU run')
|
||
|
||
def test_benchmark_falls_back_to_cpu(self):
|
||
runner = FakeRunner(gpu='vulkan', gpu_result={'device': 'gpu', 'passed': False, 'querySeconds': 7, 'chunksPerSecond': 30},
|
||
cpu_result={'device': 'cpu', 'passed': True, 'querySeconds': 4, 'chunksPerSecond': 25})
|
||
self.assertTrue(self.setup(runner).benchmark())
|
||
self.assertEqual(self.state()['device'], 'cpu')
|
||
|
||
def test_migration_removes_python_retrieval_and_adopts_ledger_package(self):
|
||
base = self.home / '.my-ai-configuration'
|
||
(base / 'semantic-retrieval/.venv').mkdir(parents=True)
|
||
(base / 'semantic-retrieval.json').write_text('{"version": 1, "clients": [], "files": {}}')
|
||
(base / 'extensions.json').write_text(json.dumps({'version': 1, 'resources': [
|
||
{'client': 'shared', 'name': 'QMD', 'kind': 'qmd', 'package': '@tobilu/qmd', 'clients': ['codex']},
|
||
{'client': 'claude', 'name': 'QMD', 'kind': 'plugin', 'selector': 'qmd@qmd'}]}))
|
||
runner = FakeRunner(installed=True)
|
||
self.setup(runner).enable(['claude'])
|
||
self.assertFalse((base / 'semantic-retrieval').exists())
|
||
self.assertFalse((base / 'semantic-retrieval.json').exists())
|
||
ledger = json.loads((base / 'extensions.json').read_text())
|
||
self.assertEqual([r['kind'] for r in ledger['resources']], ['plugin'], 'plugin record stays so reconcile uninstalls qmd@qmd')
|
||
self.assertTrue(self.state()['owned_package'], 'ledger-owned package is adopted')
|
||
|
||
def test_dry_run_changes_nothing(self):
|
||
runner = FakeRunner()
|
||
self.setup(runner, dry_run=True).enable(['claude'])
|
||
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `python scripts/tests/test-qmd-setup.py`
|
||
Expected: FAIL with `FileNotFoundError` for `scripts/lib/qmd.py`.
|
||
|
||
- [ ] **Step 3: Implement `scripts/lib/qmd.py`**
|
||
|
||
```python
|
||
"""Install, benchmark, migrate, and remove the setup-owned QMD local search."""
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
|
||
PACKAGE = '@tobilu/qmd'
|
||
VERSION = '2.8.3'
|
||
SCRIPTS = ('qmd-lib.mjs', 'qmd-config.mjs', 'qmd-daemon.mjs', 'qmd-search.mjs', 'qmd-warm.mjs', 'qmd-benchmark.mjs')
|
||
DEVICE_TIMEOUT = 600
|
||
WINDOWS = os.name == 'nt'
|
||
|
||
|
||
def run(arguments, env=None, timeout=1800):
|
||
result = subprocess.run(arguments, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||
timeout=timeout, shell=WINDOWS and arguments[0] in ('npm', 'qmd'))
|
||
if result.returncode != 0:
|
||
raise ValueError(f'{" ".join(arguments[:3])} failed: {(result.stderr or result.stdout).strip()[-300:]}')
|
||
return result.stdout
|
||
|
||
|
||
def decide(results):
|
||
for device in ('gpu', 'cpu'):
|
||
if any(r.get('device') == device and r.get('passed') is True for r in results):
|
||
return device
|
||
return None
|
||
|
||
|
||
def node_version_ok(text):
|
||
match = re.fullmatch(r'v(\d+)\.\d+\.\d+', text.strip())
|
||
return bool(match) and int(match.group(1)) >= 22
|
||
|
||
|
||
def digest(path):
|
||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||
|
||
|
||
def atomic_json(path, value):
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=path.parent, delete=False) as stream:
|
||
json.dump(value, stream, indent=2)
|
||
stream.write('\n')
|
||
os.replace(stream.name, path)
|
||
|
||
|
||
class Setup:
|
||
def __init__(self, home, root, runner=run, dry_run=False, summary=False):
|
||
self.home, self.root, self.run, self.dry_run, self.summary = Path(home), Path(root), runner, dry_run, summary
|
||
self.base = self.home / '.my-ai-configuration'
|
||
self.state_path = self.base / 'qmd.json'
|
||
self.scripts = self.base / 'qmd'
|
||
self.models = self.home / '.cache' / 'qmd' / 'models'
|
||
|
||
def say(self, text):
|
||
if not self.summary:
|
||
print(text)
|
||
|
||
def load(self):
|
||
if not self.state_path.exists():
|
||
return {'version': 1, 'clients': [], 'device': None, 'owned_package': False, 'owned_models': [], 'models_block': False, 'files': {}}
|
||
state = json.loads(self.state_path.read_text(encoding='utf-8'))
|
||
if state.get('version') != 1 or state.get('device') not in (None, 'gpu', 'cpu'):
|
||
raise ValueError('Invalid QMD setup state.')
|
||
return state
|
||
|
||
def node(self, script, *arguments, env=None, timeout=1800):
|
||
return self.run(['node', str(self.scripts / script), *arguments], env=env, timeout=timeout)
|
||
|
||
def package_installed(self):
|
||
listing = json.loads(self.run(['npm', 'list', '--global', '--depth=0', '--json']) or '{}')
|
||
return PACKAGE in (listing.get('dependencies') or {})
|
||
|
||
def require_node(self):
|
||
if not node_version_ok(self.run(['node', '--version'])):
|
||
raise ValueError('QMD local search needs Node.js 22 or newer.')
|
||
|
||
def migrate(self, state):
|
||
legacy_state = self.base / 'semantic-retrieval.json'
|
||
legacy = self.base / 'semantic-retrieval'
|
||
if legacy_state.exists() or legacy.exists():
|
||
if legacy.is_symlink():
|
||
raise ValueError('Legacy semantic retrieval directory must not be a symbolic link.')
|
||
shutil.rmtree(legacy, ignore_errors=True)
|
||
legacy_state.unlink(missing_ok=True)
|
||
self.say('Removed the previous Python semantic retrieval installation.')
|
||
ledger_path = self.base / 'extensions.json'
|
||
if ledger_path.exists():
|
||
ledger = json.loads(ledger_path.read_text(encoding='utf-8'))
|
||
owned = [r for r in ledger.get('resources', []) if r.get('kind') == 'qmd']
|
||
if owned:
|
||
ledger['resources'] = [r for r in ledger['resources'] if r.get('kind') != 'qmd']
|
||
atomic_json(ledger_path, ledger)
|
||
state['owned_package'] = True
|
||
|
||
def install_scripts(self, state):
|
||
self.scripts.mkdir(parents=True, exist_ok=True)
|
||
for name in SCRIPTS:
|
||
target = self.scripts / name
|
||
expected = state['files'].get(name)
|
||
if target.exists() and expected and digest(target) != expected:
|
||
raise ValueError(f'Setup-owned QMD script changed: {name}')
|
||
shutil.copy2(self.root / 'shared' / 'qmd' / name, target)
|
||
state['files'][name] = digest(target)
|
||
|
||
def install(self, state):
|
||
"""Steps shared by Yes and Auto: package, scripts, models block, model download."""
|
||
self.require_node()
|
||
self.migrate(state)
|
||
if not self.package_installed():
|
||
self.run(['npm', 'install', '--global', f'{PACKAGE}@{VERSION}'])
|
||
state['owned_package'] = True
|
||
self.install_scripts(state)
|
||
atomic_json(self.state_path, state)
|
||
if not state['models_block']:
|
||
self.node('qmd-config.mjs', 'set-models')
|
||
state['models_block'] = True
|
||
atomic_json(self.state_path, state)
|
||
before = {p.name for p in self.models.iterdir()} if self.models.is_dir() else set()
|
||
self.run(['qmd', 'pull'], timeout=3600)
|
||
after = {p.name for p in self.models.iterdir()} if self.models.is_dir() else set()
|
||
state['owned_models'] = sorted(set(state['owned_models']) | (after - before))
|
||
atomic_json(self.state_path, state)
|
||
|
||
def detect_gpu(self):
|
||
found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}')
|
||
gpu = found.get('gpu') or False
|
||
names = ', '.join(found.get('devices') or []) or 'unnamed device'
|
||
print(f"QMD devices: {f'{str(gpu).upper()}: {names}' if gpu else 'no GPU'}; CPU threads: {found.get('threads', '?')}")
|
||
return gpu
|
||
|
||
def enable(self, clients):
|
||
state = self.load()
|
||
if self.dry_run:
|
||
self.say('DRYRUN enable QMD local search: Qwen3-Embedding-0.6B, Qwen3-Reranker-0.6B, QMD query expansion 1.7B')
|
||
return
|
||
self.install(state)
|
||
if state['device'] is None:
|
||
state['device'] = 'gpu' if self.detect_gpu() else 'cpu'
|
||
state['clients'] = sorted(set(state['clients']) | set(clients))
|
||
atomic_json(self.state_path, state)
|
||
self.say(f'QMD local search enabled on {state["device"].upper()}.')
|
||
|
||
def remove_all(self, state):
|
||
try:
|
||
if self.scripts.joinpath('qmd-search.mjs').exists():
|
||
self.node('qmd-search.mjs', '--stop', timeout=60)
|
||
except (ValueError, subprocess.SubprocessError):
|
||
pass
|
||
collections = self.scripts / 'collections.json'
|
||
names = json.loads(collections.read_text(encoding='utf-8')) if collections.exists() else []
|
||
for name in names:
|
||
if re.fullmatch(r'repo-[0-9a-f]{12}', name):
|
||
try:
|
||
self.run(['qmd', 'collection', 'remove', name])
|
||
except ValueError:
|
||
pass
|
||
if state['models_block'] and self.scripts.joinpath('qmd-config.mjs').exists():
|
||
self.node('qmd-config.mjs', 'unset-models')
|
||
for name in state['owned_models']:
|
||
path = self.models / name
|
||
if path.is_file() and not path.is_symlink():
|
||
path.unlink()
|
||
if state['owned_package']:
|
||
self.run(['npm', 'uninstall', '--global', PACKAGE])
|
||
if self.scripts.exists() and not self.scripts.is_symlink():
|
||
shutil.rmtree(self.scripts)
|
||
self.state_path.unlink(missing_ok=True)
|
||
|
||
def disable(self, clients):
|
||
state = self.load()
|
||
if not self.state_path.exists():
|
||
if not self.dry_run:
|
||
self.migrate(state)
|
||
return
|
||
if self.dry_run:
|
||
self.say('DRYRUN disable QMD local search')
|
||
return
|
||
state['clients'] = [c for c in state['clients'] if c not in clients]
|
||
if state['clients']:
|
||
atomic_json(self.state_path, state)
|
||
return
|
||
self.remove_all(state)
|
||
self.say('QMD local search removed.')
|
||
|
||
def benchmark(self):
|
||
state = self.load()
|
||
if self.dry_run:
|
||
self.say('DRYRUN benchmark QMD local search on GPU, then CPU')
|
||
return True
|
||
self.install(state)
|
||
results = []
|
||
devices = (['gpu'] if self.detect_gpu() else []) + ['cpu']
|
||
for device in devices:
|
||
env = dict(os.environ)
|
||
env.pop('QMD_FORCE_CPU' if device == 'gpu' else 'QMD_LLAMA_GPU', None)
|
||
env['QMD_LLAMA_GPU' if device == 'gpu' else 'QMD_FORCE_CPU'] = 'auto' if device == 'gpu' else '1'
|
||
try:
|
||
result = json.loads(self.node('qmd-benchmark.mjs', '--device', device, env=env, timeout=DEVICE_TIMEOUT))
|
||
except (ValueError, subprocess.TimeoutExpired) as error:
|
||
result = {'device': device, 'passed': False, 'error': str(error)[:200]}
|
||
results.append(result)
|
||
print(f'QMD benchmark {device.upper()}: ' + ('passed' if result.get('passed') else 'failed')
|
||
+ f" (search {result.get('querySeconds', '-')} s, embedding {result.get('chunksPerSecond', '-')} chunks/s)")
|
||
if result.get('passed'):
|
||
break
|
||
device = decide(results)
|
||
if device is None:
|
||
self.remove_all(state)
|
||
print('QMD local search is not suitable for this computer; removed what the setup installed.')
|
||
return False
|
||
state['device'] = device
|
||
atomic_json(self.state_path, state)
|
||
return True
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument('action', choices=('sync', 'benchmark'))
|
||
parser.add_argument('--root', required=True, type=Path)
|
||
parser.add_argument('--home', required=True, type=Path)
|
||
parser.add_argument('--client', choices=('codex', 'claude', 'both'))
|
||
parser.add_argument('--enabled', choices=('true', 'false'))
|
||
parser.add_argument('--dry-run', action='store_true')
|
||
parser.add_argument('--update', action='store_true')
|
||
parser.add_argument('--summary', action='store_true')
|
||
args = parser.parse_args()
|
||
setup = Setup(args.home.resolve(), args.root.resolve(), run, args.dry_run, args.summary)
|
||
if args.action == 'benchmark':
|
||
raise SystemExit(0 if setup.benchmark() else 2)
|
||
if args.client is None or args.enabled is None:
|
||
parser.error('sync requires --client and --enabled')
|
||
clients = ['codex', 'claude'] if args.client == 'both' else [args.client]
|
||
(setup.enable if args.enabled == 'true' else setup.disable)(clients)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
try:
|
||
main()
|
||
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
||
print(f'QMD local search: {error}', file=sys.stderr)
|
||
raise SystemExit(1)
|
||
```
|
||
|
||
- [ ] **Step 4: Add the shell wrappers**
|
||
|
||
```bash
|
||
# scripts/lib/qmd.sh
|
||
#!/usr/bin/env bash
|
||
|
||
sync_qmd() {
|
||
local root=$1 client=$2 home_path=$3 enabled=$4 dry_run=$5 update=$6 summary=${7:-false}
|
||
local arguments=("$root/scripts/lib/qmd.py" sync --root "$root" --home "$home_path" --client "$client" --enabled "$enabled")
|
||
[ "$dry_run" != true ] || arguments+=(--dry-run)
|
||
[ "$update" != true ] || arguments+=(--update)
|
||
[ "$summary" != true ] || arguments+=(--summary)
|
||
python3 "${arguments[@]}"
|
||
}
|
||
|
||
test_qmd_device() {
|
||
local root=$1 home_path=$2 summary=${3:-false}
|
||
local arguments=("$root/scripts/lib/qmd.py" benchmark --root "$root" --home "$home_path")
|
||
[ "$summary" != true ] || arguments+=(--summary)
|
||
local status
|
||
if python3 "${arguments[@]}" >&2; then return 0; else status=$?; fi
|
||
[ "$status" -eq 2 ] && return 1
|
||
return 2
|
||
}
|
||
```
|
||
|
||
```powershell
|
||
# scripts/lib/qmd.ps1
|
||
function Sync-Qmd {
|
||
param([Parameter(Mandatory=$true)][string]$RepositoryRoot,[Parameter(Mandatory=$true)][string]$HomePath,[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client,[Parameter(Mandatory=$true)][bool]$Enabled,[switch]$DryRun,[switch]$Update,[switch]$Summary)
|
||
$arguments = @((Join-Path $PSScriptRoot 'qmd.py'), 'sync', '--root', $RepositoryRoot, '--home', $HomePath, '--client', $Client.ToLowerInvariant(), '--enabled', $Enabled.ToString().ToLowerInvariant())
|
||
if ($DryRun) { $arguments += '--dry-run' }; if ($Update) { $arguments += '--update' }
|
||
if ($Summary) { $arguments += '--summary' }
|
||
& python @arguments
|
||
if ($LASTEXITCODE -ne 0) { throw 'QMD local search reconciliation failed.' }
|
||
}
|
||
|
||
function Test-QmdDevice {
|
||
param([Parameter(Mandatory=$true)][string]$RepositoryRoot,[Parameter(Mandatory=$true)][string]$HomePath,[switch]$Summary)
|
||
& python (Join-Path $PSScriptRoot 'qmd.py') benchmark --root $RepositoryRoot --home $HomePath $(if ($Summary) { '--summary' }) | Write-Host
|
||
if ($LASTEXITCODE -eq 0) { return $true }
|
||
if ($LASTEXITCODE -eq 2) { return $false }
|
||
throw 'QMD benchmark failed.'
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `python scripts/tests/test-qmd-setup.py`
|
||
Expected: PASS, 10 tests. Fix only `qmd.py` if a test fails; the tests encode the spec.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add scripts/lib/qmd.py scripts/lib/qmd.sh scripts/lib/qmd.ps1 scripts/tests/test-qmd-setup.py
|
||
git commit -m "feat(qmd): install, benchmark, migrate, and remove QMD local search"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Wire the installer into install, update, and uninstall
|
||
|
||
**Files:**
|
||
- Modify: `scripts/lib/install-options.sh:37-60`, `scripts/lib/install-options.ps1:27-45`
|
||
- Modify: `scripts/commands/install.sh`, `install.ps1`, `update.sh`, `update.ps1`, `uninstall.sh`, `uninstall.ps1`
|
||
- Test: `scripts/tests/test-install-guards.sh`, `scripts/tests/test-install-guards.ps1`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `sync_qmd`, `test_qmd_device`, `Sync-Qmd`, `Test-QmdDevice` (Task 6).
|
||
- Produces: the prompt text `Enable local QMD search models (Qwen3 embedding, Qwen3 reranker, QMD query expansion)? [y/N/a]` (or `[Y/n/a]` when the stored default is true).
|
||
|
||
- [ ] **Step 1: Write the failing guard tests**
|
||
|
||
Add to `scripts/tests/test-install-guards.sh` (follow the file's existing helper style for temp homes and assertions):
|
||
|
||
```bash
|
||
# QMD prompt: Auto maps benchmark exit codes; No never calls the benchmark.
|
||
(
|
||
root=$repo_root
|
||
home_path=$(mktemp -d)
|
||
. "$root/scripts/lib/install-options.sh"
|
||
test_qmd_device() { return 1; }
|
||
[ "$(printf 'a\n' | read_semantic_retrieval_option false 2>/dev/null)" = false ] || { echo 'Auto unsuitable must yield false' >&2; exit 1; }
|
||
test_qmd_device() { return 0; }
|
||
[ "$(printf 'a\n' | read_semantic_retrieval_option false 2>/dev/null)" = true ] || { echo 'Auto suitable must yield true' >&2; exit 1; }
|
||
test_qmd_device() { echo 'must not run' >&2; exit 9; }
|
||
[ "$(printf 'n\n' | read_semantic_retrieval_option true 2>/dev/null)" = false ] || { echo 'No must yield false' >&2; exit 1; }
|
||
printf 'n\n' | read_semantic_retrieval_option true 2>&1 >/dev/null | grep -q 'Enable local QMD search models' || { echo 'prompt text changed' >&2; exit 1; }
|
||
)
|
||
```
|
||
|
||
Add the equivalent block to `scripts/tests/test-install-guards.ps1`, mocking `Test-QmdDevice` with a function returning `$false`/`$true` and feeding input through `[Console]::SetIn([IO.StringReader]::new("a`n"))`.
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `bash scripts/tests/test-install-guards.sh --summary`
|
||
Expected: FAIL with `prompt text changed` (and the Auto assertions, because the prompt still calls `test_semantic_retrieval_device`).
|
||
|
||
- [ ] **Step 3: Update the prompt in both shells**
|
||
|
||
`scripts/lib/install-options.sh`, in `read_semantic_retrieval_option`: change the prompt line and the Auto call:
|
||
|
||
```bash
|
||
printf 'Enable local QMD search models (Qwen3 embedding, Qwen3 reranker, QMD query expansion)? [%s] ' "$hint" >&2
|
||
```
|
||
|
||
```bash
|
||
if test_qmd_device "$root" "$home_path"; then printf 'true\n'; return; else status=$?; fi
|
||
```
|
||
|
||
`scripts/lib/install-options.ps1`, in `Read-SemanticRetrievalOption`: same prompt text; Auto branch becomes `return Test-QmdDevice -RepositoryRoot $RepositoryRoot -HomePath $HomePath`.
|
||
|
||
- [ ] **Step 4: Replace the retrieval calls in the commands**
|
||
|
||
In each of `install.sh`, `update.sh`, `uninstall.sh`:
|
||
- `. "$root/scripts/lib/semantic-retrieval.sh"` → `. "$root/scripts/lib/qmd.sh"`
|
||
- `sync_semantic_retrieval ...` → `sync_qmd ...` with the same arguments.
|
||
- In `install.sh` and `update.sh`, move the `sync_qmd` line so it runs **before** `sync_configured_plugins`, so a ledger-owned QMD package is adopted before the extension reconcile would uninstall it.
|
||
|
||
In each of `install.ps1`, `update.ps1`, `uninstall.ps1`:
|
||
- `. (Join-Path $root 'scripts/lib/semantic-retrieval.ps1')` → `. (Join-Path $root 'scripts/lib/qmd.ps1')`
|
||
- `Sync-SemanticRetrieval ...` → `Sync-Qmd ...` with the same arguments, placed before the plugin sync call in install and update.
|
||
- `uninstall.sh`/`.ps1` message: `semantic retrieval setup` → `QMD local search setup`.
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `bash scripts/tests/test-install-guards.sh --summary` → PASS.
|
||
Run: `pwsh -NoProfile -File scripts/tests/test-install-guards.ps1 -Summary` → PASS.
|
||
Run: `bash scripts/commands/install.sh --summary --dry-run --client both --shell bash` → output contains no `semantic retrieval` and ends with `Install: PASS`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add scripts/lib/install-options.sh scripts/lib/install-options.ps1 scripts/commands/install.sh scripts/commands/install.ps1 \
|
||
scripts/commands/update.sh scripts/commands/update.ps1 scripts/commands/uninstall.sh scripts/commands/uninstall.ps1 \
|
||
scripts/tests/test-install-guards.sh scripts/tests/test-install-guards.ps1
|
||
git commit -m "feat(install): ask for QMD local search models and run the QMD installer"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Remove the Python retrieval stack and the QMD extension
|
||
|
||
**Files:**
|
||
- Delete: `shared/retrieval/` (all files), `scripts/lib/semantic-retrieval.py`, `.sh`, `.ps1`, `scripts/tests/test-retrieval-server.py`, `scripts/tests/test-semantic-retrieval.py`
|
||
- Modify: `adapters/plugins.tsv` (delete the `QMD` row), `scripts/lib/managed-extensions.py` (drop `ensure_qmd` and the `codex_method == 'qmd'` branch; keep accepting `kind: qmd` records in `validate` so old ledgers still load), `scripts/lib/plugins.sh:164-165`, `scripts/lib/plugins.ps1` (QMD detection), `scripts/tests/test-managed-extensions.py` (remove QMD install cases; keep a case that an old `qmd` record still validates)
|
||
- Test: `scripts/tests/test-managed-extensions.py`, `scripts/tests/test-plugin-selection.sh`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing new.
|
||
- Produces: the plugin selector no longer lists QMD.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `scripts/tests/test-managed-extensions.py`:
|
||
|
||
```python
|
||
def test_manifest_has_no_qmd_extension(self):
|
||
rows = (ROOT / 'adapters/plugins.tsv').read_text(encoding='utf-8').splitlines()
|
||
self.assertFalse(any(row.split('\t')[0] == 'QMD' for row in rows[1:]))
|
||
|
||
def test_legacy_qmd_record_still_validates(self):
|
||
home = Path(tempfile.mkdtemp())
|
||
self.addCleanup(shutil.rmtree, home, ignore_errors=True)
|
||
(home / '.my-ai-configuration').mkdir()
|
||
(home / '.my-ai-configuration/extensions.json').write_text(json.dumps({'version': 1, 'resources': [
|
||
{'client': 'shared', 'name': 'QMD', 'kind': 'qmd', 'package': '@tobilu/qmd', 'clients': ['codex']}]}))
|
||
module.ExtensionManager(home) # must not raise
|
||
```
|
||
|
||
(Use the module alias, `ROOT`, and manager class name the file already defines; adapt only those identifiers.)
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `python scripts/tests/test-managed-extensions.py`
|
||
Expected: FAIL on `test_manifest_has_no_qmd_extension`.
|
||
|
||
- [ ] **Step 3: Remove code and files**
|
||
|
||
```bash
|
||
git rm -r shared/retrieval scripts/lib/semantic-retrieval.py scripts/lib/semantic-retrieval.sh scripts/lib/semantic-retrieval.ps1 \
|
||
scripts/tests/test-retrieval-server.py scripts/tests/test-semantic-retrieval.py
|
||
```
|
||
|
||
Then edit: delete the `QMD` line from `adapters/plugins.tsv`; in `managed-extensions.py` delete `def ensure_qmd` and the `if entry['codex_method'] == 'qmd': self.ensure_qmd(client, entry)` branch (around line 307); delete the QMD branch in `plugins.sh` (lines 164-165) and its PowerShell equivalent in `plugins.ps1` (find with `rg -n "qmd" scripts/lib/plugins.ps1`); remove tests in `test-managed-extensions.py` that install QMD through `ensure_qmd`.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `python scripts/tests/test-managed-extensions.py` → PASS.
|
||
Run: `bash scripts/tests/test-plugin-selection.sh --summary` → PASS.
|
||
Run: `rg -n -i "semantic-retrieval|shared/retrieval|ensure_qmd" -g '!generated' -g '!docs/superpowers' .` → no matches outside docs updated in Task 9.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A adapters/plugins.tsv scripts/lib/managed-extensions.py scripts/lib/plugins.sh scripts/lib/plugins.ps1 scripts/tests/test-managed-extensions.py
|
||
git commit -m "refactor(retrieval): remove the Python Qwen stack and the QMD extension entry"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Strict skill, mandatory instruction line, documentation, prompt budget
|
||
|
||
**Files:**
|
||
- Modify: `shared/skills/research/semantic-search/SKILL.md` (rewrite)
|
||
- Modify: `shared/rules/general.md:9`
|
||
- Modify: `README.md`, `docs/plugins.md`, `docs/scripts.md`, `docs/skills.md`, `docs/hooks.md`
|
||
- Modify: `adapters/prompt-budget-baseline.json`, `adapters/prompt-budget-baseline.tsv` (after measuring)
|
||
|
||
**Interfaces:**
|
||
- Consumes: CLI of `qmd-search.mjs` (Task 3).
|
||
- Produces: skill name `semantic-search`, invocation `node "$HOME/.my-ai-configuration/qmd/qmd-search.mjs" --query "<question>" --top-k 5`.
|
||
|
||
- [ ] **Step 1: Rewrite the skill**
|
||
|
||
```markdown
|
||
---
|
||
name: semantic-search
|
||
description: MUST use before reading or grepping code whenever the exact symbol, file, or string is not already known - concept questions, unfamiliar areas, "where/how is X done", and bug reports that describe behavior. Use rg only for known identifiers, paths, or exact strings.
|
||
---
|
||
|
||
# Semantic Search
|
||
|
||
Search the current Git repository, plus the user's registered QMD collections, by meaning. QMD runs locally with Qwen3-Embedding-0.6B, Qwen3-Reranker-0.6B, and QMD Query Expansion 1.7B; a background daemon keeps them loaded.
|
||
|
||
## Rule
|
||
|
||
Run this search first whenever you do not already know the exact identifier, path, or string. Do not start with `rg` guesses for a concept. `rg` comes after the search, for callers and tests of verified hits.
|
||
|
||
## Run
|
||
|
||
The same command works in Bash and PowerShell:
|
||
|
||
node "$HOME/.my-ai-configuration/qmd/qmd-search.mjs" --query "<full question about behavior>" --top-k 5
|
||
|
||
Output: JSON list of `{path, line, score, snippet}`, best first. Repository paths are relative to the repository root; hits from other collections are `qmd://<collection>/<path>`.
|
||
|
||
## Use the results
|
||
|
||
1. Phrase the query as a full question about behavior, not a keyword list.
|
||
2. Open the top hits and verify them against the current files before acting.
|
||
3. Follow verified hits with `rg` for their symbols to find callers and tests.
|
||
4. If no hit is relevant, run one rephrased search before switching to `rg`.
|
||
|
||
## Failure
|
||
|
||
If the command exits non-zero, continue with `rg` and state the limitation in one sentence.
|
||
```
|
||
|
||
- [ ] **Step 2: Make the instruction line mandatory and separate**
|
||
|
||
In `shared/rules/general.md`, replace the paragraph on line 9 with two lines (the second is removed at install time when the skill is disabled, see Task 4):
|
||
|
||
```markdown
|
||
Use direct reads and `rg` for known paths, symbols, exact strings, error messages, and localized edits.
|
||
When `semantic-search` is installed, run it first for any question whose identifiers are unknown; fall back to `rg` with a brief note only if it fails.
|
||
```
|
||
|
||
- [ ] **Step 3: Update documentation**
|
||
|
||
- `README.md` line 33: `Qwen retrieval` → `local QMD search models`. Line 102: replace with `Optional local search for both clients through QMD with Qwen3-Embedding-0.6B, Qwen3-Reranker-0.6B, and QMD Query Expansion 1.7B (Yes/No/Auto; Auto benchmarks GPU, then CPU).` Line 111: replace the Python venv prerequisite with `Node.js 22 or newer and npm when local QMD search is enabled.` Lines 235, 266, 292: replace `semantic retrieval` wording with `local QMD search`. Lines 99 and 112: remove QMD from the extension list and its Node prerequisite sentence.
|
||
- `docs/plugins.md`: remove QMD from the baseline list and delete the QMD paragraph (line 19); replace lines 21-23 with a paragraph describing the question, Auto thresholds (search ≤ 5 s, embedding ≥ 20 chunks/s, GPU then CPU), the daemon (loopback, token, 30-minute idle exit), the SessionStart warm-up, ownership-only removal, and migration from the Python stack.
|
||
- `docs/scripts.md`: replace rows for `lib/semantic-retrieval.*`, `shared/retrieval/benchmark.py`, `tests/test-semantic-retrieval.py` with rows for `lib/qmd.py`, `lib/qmd.sh`/`lib/qmd.ps1`, `shared/qmd/*.mjs`, `tests/test-qmd-*.mjs`, `tests/test-qmd-setup.py`; rewrite line 43 to point at `qmd-benchmark.mjs`.
|
||
- `docs/skills.md` line 8: `installed only while local QMD search is enabled`.
|
||
- `docs/hooks.md`: add the row `| Codex and Claude | SessionStart | Warm the QMD index and search daemon in the background (only while local QMD search is enabled). |`.
|
||
|
||
- [ ] **Step 4: Rebuild and measure the prompt budget**
|
||
|
||
Run: `pwsh -NoProfile -File scripts/commands/build.ps1 -Summary` → `Build: PASS`.
|
||
Run: `pwsh -NoProfile -File scripts/commands/prompt-budget.ps1 -Summary`. If `skill_metadata_tokens` or `instruction_tokens` exceed baseline +10%, update the two baseline files with the measured values and note old → new in the commit body.
|
||
|
||
- [ ] **Step 5: Run checks**
|
||
|
||
Run: `pwsh -NoProfile -File scripts/tests/test-build-validation.ps1 -Summary` → PASS.
|
||
Run: `python scripts/tests/test-prompt-budget.py` → PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add shared/skills/research/semantic-search/SKILL.md shared/rules/general.md README.md docs/plugins.md docs/scripts.md docs/skills.md docs/hooks.md adapters/prompt-budget-baseline.json adapters/prompt-budget-baseline.tsv
|
||
git commit -m "docs(qmd): make semantic search mandatory and document QMD local search"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: CI job and A/B benchmark integration
|
||
|
||
**Files:**
|
||
- Modify: `.gitea/workflows/ci.yml` (replace the `retrieval` job; add Node tests to the `python` job's neighbor)
|
||
- Modify: `scripts/benchmarks/ab.py:82-88,279-306,349-351,380-381`
|
||
- Create: `scripts/tests/test-qmd-integration.mjs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything above.
|
||
- Produces: CI job `qmd` named `QMD local search integration`.
|
||
|
||
- [ ] **Step 1: Write the integration test**
|
||
|
||
```js
|
||
// scripts/tests/test-qmd-integration.mjs — real QMD, real models; run only in the qmd CI job or manually.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
const search = resolve('shared/qmd/qmd-search.mjs');
|
||
|
||
test('wrapper finds the retry logic through the daemon and stops it', { timeout: 1_800_000 }, () => {
|
||
const home = mkdtempSync(join(tmpdir(), 'qmd-it-home-'));
|
||
const repo = join(home, 'repo');
|
||
mkdirSync(join(repo, 'src'), { recursive: true });
|
||
writeFileSync(join(repo, 'src', 'upload.ts'), 'export async function sendWithRetry(send) {\n for (let attempt = 1; attempt <= 3; attempt++) {\n try { return await send(); } catch (e) { if (!e.transient || attempt === 3) throw e; }\n }\n}\n');
|
||
writeFileSync(join(repo, 'src', 'price.ts'), 'export const formatCents = (c) => (c / 100).toFixed(2);\n');
|
||
execFileSync('git', ['init', '-q'], { cwd: repo });
|
||
execFileSync('git', ['add', '-A'], { cwd: repo });
|
||
const env = { ...process.env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_CACHE_HOME: process.env.QMD_TEST_CACHE || join(home, '.cache'), QMD_FORCE_CPU: '1' };
|
||
execFileSync('node', ['shared/qmd/qmd-config.mjs', 'set-models'], { env });
|
||
const results = JSON.parse(execFileSync('node', [search, '--root', repo, '--query', 'Where are failed uploads retried after temporary errors?'], { env, encoding: 'utf8', timeout: 1_700_000 }));
|
||
assert.equal(results[0].path.replace(/\\/g, '/'), 'src/upload.ts');
|
||
execFileSync('node', [search, '--stop'], { env });
|
||
});
|
||
```
|
||
|
||
Note: the scripts read `homedir()`, which follows `HOME` on POSIX (the CI runner is Ubuntu).
|
||
|
||
- [ ] **Step 2: Replace the CI job**
|
||
|
||
Replace the whole `retrieval:` job in `.gitea/workflows/ci.yml` with:
|
||
|
||
```yaml
|
||
qmd:
|
||
name: QMD local search integration
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 45
|
||
needs: [python]
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v22.16.0
|
||
with:
|
||
node-version: '24'
|
||
- name: Install QMD
|
||
run: npm install --global @tobilu/qmd@2.8.3
|
||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .cache/qmd-models
|
||
key: qmd-models-${{ runner.os }}-2.8.3-qwen3
|
||
- name: Run QMD unit tests
|
||
run: node --test scripts/tests/test-qmd-lib.mjs scripts/tests/test-qmd-daemon.mjs scripts/tests/test-qmd-search.mjs scripts/tests/test-qmd-warm.mjs scripts/tests/test-qmd-benchmark.mjs
|
||
- name: Run QMD integration test with real models
|
||
env:
|
||
QMD_TEST_CACHE: ${{ github.workspace }}/.cache/qmd-models
|
||
run: node --test scripts/tests/test-qmd-integration.mjs
|
||
```
|
||
|
||
The `python` job already runs every `scripts/tests/test-*.py`, so `test-qmd-setup.py` runs there without changes.
|
||
|
||
- [ ] **Step 3: Update `ab.py`**
|
||
|
||
- `claude_flags`, arm `setup-no-search`: `'Bash(*semantic-retrieval*)', 'PowerShell(*semantic-retrieval*)'` → `'Bash(*qmd-search*)', 'PowerShell(*qmd-search*)'`; the appended system prompt says `The semantic-search skill and the QMD search wrapper are unavailable in this session.`
|
||
- Replace `RETRIEVAL` and `warm_index` with:
|
||
|
||
```python
|
||
QMD_SCRIPTS = Path.home() / '.my-ai-configuration/qmd'
|
||
|
||
|
||
def warm_index(work):
|
||
"""Index the fixture and start the QMD daemon before the session, as in a repository that was searched before."""
|
||
script = QMD_SCRIPTS / 'qmd-warm.mjs'
|
||
if script.is_file():
|
||
subprocess.run(['node', str(script), '--root', str(work)], capture_output=True, timeout=1800)
|
||
```
|
||
|
||
- `searches` count: `'semantic-retrieval' in command` → `'qmd-search' in command`.
|
||
- `--warm-index` help text: `start the QMD daemon and index the fixture before setup-arm sessions, outside the timing`.
|
||
|
||
- [ ] **Step 4: Run checks**
|
||
|
||
Run: `python -m py_compile scripts/benchmarks/ab.py` → no output.
|
||
Run: `python scripts/benchmarks/ab.py --clients claude --tasks 01-* --repetitions 1 --dry-run` → preflight lines, `2 runs ->`, two listed runs.
|
||
Run locally once (requires installed QMD and downloaded models): `node --test scripts/tests/test-qmd-integration.mjs` → PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add .gitea/workflows/ci.yml scripts/benchmarks/ab.py scripts/tests/test-qmd-integration.mjs
|
||
git commit -m "ci(qmd): replace the Qwen retrieval job with a QMD integration job"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: End-to-end verification on this machine
|
||
|
||
**Files:** none changed unless a check fails.
|
||
|
||
- [ ] **Step 1: Full local checks**
|
||
|
||
Run each and record the result line:
|
||
|
||
```bash
|
||
node --test scripts/tests/test-qmd-*.mjs
|
||
python scripts/tests/test-qmd-setup.py
|
||
python scripts/tests/test-install-options.py
|
||
python scripts/tests/test-managed-extensions.py
|
||
bash scripts/tests/test-install-guards.sh --summary
|
||
pwsh -NoProfile -File scripts/tests/test-install-guards.ps1 -Summary
|
||
pwsh -NoProfile -File scripts/commands/build.ps1 -Summary
|
||
pwsh -NoProfile -File scripts/commands/prompt-budget.ps1 -Summary
|
||
pwsh -NoProfile -File scripts/tests/test-build-validation.ps1 -Summary
|
||
bash scripts/commands/install.sh --summary --dry-run --client both --shell bash
|
||
```
|
||
|
||
Expected: every line PASS.
|
||
|
||
On Windows, additionally confirm the hook entry point and detached daemon start: `pwsh -NoProfile -File shared/hooks/scripts/Start-QmdWarm.ps1` returns immediately with exit code 0 inside this repository, and `node shared/qmd/qmd-benchmark.mjs --detect` lists every installed GPU.
|
||
|
||
- [ ] **Step 2: Report**
|
||
|
||
Report each result to the user. Do **not** run a real install or update on this machine: the repository's instructions forbid changing the local machine outside the repository. Tell the user the exact command to run themselves to try it: `pwsh -File scripts/commands/update.ps1` and answer `a` at the QMD question.
|