Compare commits
48
Commits
No files matched your search
+25
-26
@@ -64,23 +64,23 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- name: Check PowerShell runtime
|
- name: Install PowerShell runtime
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if command -v pwsh >/dev/null 2>&1; then
|
set -euo pipefail
|
||||||
echo 'POWERSHELL_AVAILABLE=true' >> "$GITHUB_ENV"
|
if ! command -v pwsh >/dev/null 2>&1; then
|
||||||
exit 0
|
# Microsoft's signed apt repository; the job fails when PowerShell cannot be installed.
|
||||||
fi
|
. /etc/os-release
|
||||||
if sudo apt-get update && sudo apt-get install --yes --no-install-recommends powershell; then
|
wget -q -O "$RUNNER_TEMP/packages-microsoft-prod.deb" "https://packages.microsoft.com/config/$ID/$VERSION_ID/packages-microsoft-prod.deb"
|
||||||
echo 'POWERSHELL_AVAILABLE=true' >> "$GITHUB_ENV"
|
sudo dpkg -i "$RUNNER_TEMP/packages-microsoft-prod.deb"
|
||||||
else
|
sudo apt-get update
|
||||||
echo 'POWERSHELL_AVAILABLE=false' >> "$GITHUB_ENV"
|
sudo apt-get install --yes --no-install-recommends powershell
|
||||||
echo '::warning::PowerShell is unavailable; PowerShell checks skipped for this runner.'
|
|
||||||
fi
|
fi
|
||||||
|
pwsh -NoLogo -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.ToString()'
|
||||||
- name: Run PowerShell tests
|
- name: Run PowerShell tests
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ "${POWERSHELL_AVAILABLE:-false}" != true ]; then exit 0; fi
|
set -euo pipefail
|
||||||
pwsh -NoLogo -NoProfile -NonInteractive -File ./scripts/commands/check-capabilities.ps1
|
pwsh -NoLogo -NoProfile -NonInteractive -File ./scripts/commands/check-capabilities.ps1
|
||||||
pwsh -NoLogo -NoProfile -NonInteractive -File ./scripts/commands/build.ps1 -Summary
|
pwsh -NoLogo -NoProfile -NonInteractive -File ./scripts/commands/build.ps1 -Summary
|
||||||
for file in scripts/tests/test-*.ps1; do
|
for file in scripts/tests/test-*.ps1; do
|
||||||
@@ -172,29 +172,28 @@ jobs:
|
|||||||
AI_CONFIG_REQUIRE_CLIENTS: '1'
|
AI_CONFIG_REQUIRE_CLIENTS: '1'
|
||||||
run: timeout 900 python scripts/tests/test-client-smoke.py -v
|
run: timeout 900 python scripts/tests/test-client-smoke.py -v
|
||||||
|
|
||||||
retrieval:
|
qmd:
|
||||||
name: Qwen retrieval integration
|
name: QMD local search integration
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 45
|
||||||
needs: [python]
|
needs: [python]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v22.16.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
node-version: '24'
|
||||||
cache: pip
|
- name: Install QMD
|
||||||
cache-dependency-path: shared/retrieval/requirements.txt
|
run: npm install --global @tobilu/qmd@2.8.3
|
||||||
- name: Install retrieval dependencies
|
- name: Run QMD unit tests
|
||||||
run: python -m pip install -r shared/retrieval/requirements.txt
|
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
|
||||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: .cache/huggingface
|
path: .cache/qmd-models
|
||||||
key: qwen-retrieval-${{ runner.os }}-${{ hashFiles('shared/retrieval/requirements.txt') }}
|
key: qmd-models-${{ runner.os }}-2.8.3-qwen3
|
||||||
- name: Run retrieval tests with real models
|
- name: Run QMD integration test with real models
|
||||||
env:
|
env:
|
||||||
QWEN_TEST_MODEL_CACHE: ${{ github.workspace }}/.cache/huggingface
|
QMD_TEST_CACHE: ${{ github.workspace }}/.cache/qmd-models
|
||||||
HF_HUB_DISABLE_XET: '1'
|
run: node --test scripts/tests/test-qmd-integration.mjs
|
||||||
run: python scripts/tests/test-retrieval-server.py
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
name: CI configuration security checks
|
name: CI configuration security checks
|
||||||
|
|||||||
@@ -15,3 +15,7 @@ credentials.json
|
|||||||
secrets.json
|
secrets.json
|
||||||
!.env.example
|
!.env.example
|
||||||
.idea
|
.idea
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
@@ -30,7 +30,7 @@ Bash:
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
With the [requirements](#requirements) installed, clone the repository, build it, then install it.
|
With the [requirements](#requirements) installed, clone the repository, build it, then install it.
|
||||||
Stop if any command fails. Installation asks for the shell, client, Flashbang, status line, Qwen retrieval, and extension choices.
|
Stop if any command fails. Installation asks for the shell, client, Flashbang, status line, local QMD search models, and extension choices.
|
||||||
|
|
||||||
PowerShell:
|
PowerShell:
|
||||||
|
|
||||||
@@ -96,11 +96,10 @@ Install and update also rebuild internally; the explicit build above lets you ch
|
|||||||
- Separate install, update, and uninstall scripts: install refuses to run against an already-installed destination,
|
- Separate install, update, and uninstall scripts: install refuses to run against an already-installed destination,
|
||||||
update refuses to run against one that is not installed yet, and uninstall refuses to run against one that was
|
update refuses to run against one that is not installed yet, and uninstall refuses to run against one that was
|
||||||
never installed.
|
never installed.
|
||||||
- User-level extension installation for Ponytail, i-have-adhd, Superpowers, Context7, Caveman, QMD, MCPorter,
|
- User-level extension installation for Ponytail, i-have-adhd, Superpowers, Context7, Caveman, MCPorter,
|
||||||
Humanizer, Impeccable, and Anthropic Frontend Design, with an interactive extension toggle during both install and update. Only extensions
|
Humanizer, Impeccable, and Anthropic Frontend Design, with an interactive extension toggle during both install and update. Only extensions
|
||||||
installed and recorded by this setup can be removed through deselection.
|
installed and recorded by this setup can be removed through deselection.
|
||||||
- Optional local semantic retrieval for both clients using Qwen3-Embedding-0.6B embeddings and Qwen3-Reranker-0.6B
|
- 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).
|
||||||
reranking, disabled by default and removable through the install/update choice.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -108,8 +107,8 @@ Install and update also rebuild internally; the explicit build above lets you ch
|
|||||||
- Bash 4.3 or newer, with standard utilities such as `awk`, `sed`, and `sha256sum`.
|
- Bash 4.3 or newer, with standard utilities such as `awk`, `sed`, and `sha256sum`.
|
||||||
- Python 3.11 or newer for configuration validation and task-state hooks/helpers (`python` for PowerShell scripts, `python3` for Bash scripts).
|
- Python 3.11 or newer for configuration validation and task-state hooks/helpers (`python` for PowerShell scripts, `python3` for Bash scripts).
|
||||||
- `jq` for Claude's Bash status line; without it, that status line produces no output.
|
- `jq` for Claude's Bash status line; without it, that status line produces no output.
|
||||||
- Python `venv` and `pip` when the optional semantic retrieval layer is enabled.
|
- Node.js 22 or newer and `npm` when local QMD search is enabled.
|
||||||
- Node.js 22 or newer and `npm` when QMD or MCPorter is selected.
|
- Node.js 22 or newer and `npm` when MCPorter is selected.
|
||||||
|
|
||||||
Core Bash setup scripts do not require PowerShell. The credential commands use Windows Credential Manager and require
|
Core Bash setup scripts do not require PowerShell. The credential commands use Windows Credential Manager and require
|
||||||
PowerShell, including when invoked through their Bash wrappers. The PowerShell scripts remain compatible with
|
PowerShell, including when invoked through their Bash wrappers. The PowerShell scripts remain compatible with
|
||||||
@@ -232,11 +231,11 @@ Dry runs still rebuild `generated/`, but do not modify installation destinations
|
|||||||
|
|
||||||
Selecting Codex also installs shared skills to `.agents/skills`. An interactive extension list includes plugins and
|
Selecting Codex also installs shared skills to `.agents/skills`. An interactive extension list includes plugins and
|
||||||
Codex skills; unchecked extensions are skipped on installation. Normal install and update ask whether to enable
|
Codex skills; unchecked extensions are skipped on installation. Normal install and update ask whether to enable
|
||||||
Flashbang, apply the custom status line, and enable Qwen3 semantic retrieval. Choices are saved for Quickupdate.
|
Flashbang, apply the custom status line, and enable local QMD search. Choices are saved for Quickupdate.
|
||||||
The shared skill catalog includes development skills and focused writing, image, site structure, psychology, and
|
The shared skill catalog includes development skills and focused writing, image, site structure, psychology, and
|
||||||
conversion skills. All retained skills are included in both generated clients.
|
conversion skills. All retained skills are included in both generated clients.
|
||||||
The status line defaults to enabled to preserve existing behavior. Disabling it removes the custom client setting and,
|
The status line defaults to enabled to preserve existing behavior. Disabling it removes the custom client setting and,
|
||||||
for Claude, the setup-owned status line script. Disabling retrieval on update removes its setup-owned runtime and index.
|
for Claude, the setup-owned status line script. Disabling local QMD search on update removes only its setup-owned package, models, and repository collections.
|
||||||
Dry runs preview changes without updating extensions or retrieval. See [Plugins](docs/plugins.md) for client-specific behavior.
|
Dry runs preview changes without updating extensions or retrieval. See [Plugins](docs/plugins.md) for client-specific behavior.
|
||||||
|
|
||||||
## Update Script
|
## Update Script
|
||||||
@@ -263,7 +262,7 @@ setup; pre-existing or manually installed extensions are retained. Quickupdate r
|
|||||||
## Uninstall Script
|
## Uninstall Script
|
||||||
|
|
||||||
Remove everything this setup previously installed for the selected client(s): managed files tracked in each
|
Remove everything this setup previously installed for the selected client(s): managed files tracked in each
|
||||||
destination's manifest, extensions and skills recorded in the extension ledger, an enabled semantic retrieval setup,
|
destination's manifest, extensions and skills recorded in the extension ledger, an enabled local QMD search setup,
|
||||||
and the saved Quickupdate selection (once no destination remains installed for any client).
|
and the saved Quickupdate selection (once no destination remains installed for any client).
|
||||||
|
|
||||||
PowerShell:
|
PowerShell:
|
||||||
@@ -289,7 +288,7 @@ touched. Codex skill backups are kept under `.agents/.ai-config-skill-backups/`.
|
|||||||
deleted once it is fully empty; backups already on disk are always kept.
|
deleted once it is fully empty; backups already on disk are always kept.
|
||||||
Extensions and skills recorded in the ledger are removed the same way `update` removes a deselected extension:
|
Extensions and skills recorded in the ledger are removed the same way `update` removes a deselected extension:
|
||||||
pre-existing or manually installed ones outside the ledger are left alone, and a skill file modified since it was
|
pre-existing or manually installed ones outside the ledger are left alone, and a skill file modified since it was
|
||||||
installed is kept with a warning. An enabled semantic retrieval setup is disabled and its setup-owned runtime and
|
installed is kept with a warning. An enabled local QMD search setup is disabled and its setup-owned runtime and
|
||||||
index removed for the selected client(s). The saved Quickupdate selection (`~/.my-ai-configuration/selection.json`)
|
index removed for the selected client(s). The saved Quickupdate selection (`~/.my-ai-configuration/selection.json`)
|
||||||
is only removed once uninstalling leaves no client installed at all, so uninstalling just one client out of a
|
is only removed once uninstalling leaves no client installed at all, so uninstalling just one client out of a
|
||||||
`Both` installation keeps the saved choices for the client that remains.
|
`Both` installation keeps the saved choices for the client that remains.
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreCompact": [{"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__COMPACT_SCRIPT__\"", "timeout": 4}]}],
|
"PreCompact": [{"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__COMPACT_SCRIPT__\"", "timeout": 4}]}],
|
||||||
"SessionStart": [{"hooks": [{"type": "command", "command": "__HOOK_COMMAND__ \"__AI_CONFIG_ROOT__/hooks/scripts/__SESSION_POINTER_SCRIPT__\"", "timeout": 4}]}],
|
"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}]}
|
||||||
|
],
|
||||||
"Stop": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ sandbox_mode = "workspace-write"
|
|||||||
|
|
||||||
[sandbox_workspace_write]
|
[sandbox_workspace_write]
|
||||||
network_access = true
|
network_access = true
|
||||||
writable_roots = ['__AI_CONFIG_ROOT__/../.my-ai-configuration/semantic-retrieval/data']
|
writable_roots = ['__AI_CONFIG_ROOT__/../.my-ai-configuration/qmd', '__AI_CONFIG_ROOT__/../.cache/qmd']
|
||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
sandbox = "unelevated"
|
sandbox = "unelevated"
|
||||||
@@ -26,3 +26,10 @@ type = "command"
|
|||||||
command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__HOOK_SCRIPT__"'
|
command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__HOOK_SCRIPT__"'
|
||||||
command_windows = '__POWERSHELL_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__POWERSHELL_HOOK_SCRIPT__"'
|
command_windows = '__POWERSHELL_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__POWERSHELL_HOOK_SCRIPT__"'
|
||||||
timeout = 8
|
timeout = 8
|
||||||
|
|
||||||
|
[[hooks.SessionStart]]
|
||||||
|
[[hooks.SessionStart.hooks]]
|
||||||
|
type = "command"
|
||||||
|
command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/scripts/__QMD_WARM_SCRIPT__"'
|
||||||
|
command_windows = '__POWERSHELL_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/scripts/__QMD_WARM_SCRIPT__"'
|
||||||
|
timeout = 4
|
||||||
@@ -4,7 +4,6 @@ i-have-adhd ayghri/i-have-adhd i-have-adhd@i-have-adhd ayghri/i-have-adhd i-have
|
|||||||
Superpowers anthropics/claude-plugins-official superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - -
|
Superpowers anthropics/claude-plugins-official superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - -
|
||||||
Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - - context7 https://mcp.context7.com/mcp
|
Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - - context7 https://mcp.context7.com/mcp
|
||||||
Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - -
|
Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - -
|
||||||
QMD tobi/qmd qmd@qmd - - qmd @tobilu/qmd qmd
|
|
||||||
MCPorter - mcporter - mcporter cli mcporter -
|
MCPorter - mcporter - mcporter cli mcporter -
|
||||||
Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer humanizer
|
Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer humanizer
|
||||||
Impeccable pbakaus/impeccable impeccable@impeccable pbakaus/impeccable impeccable skill pbakaus/impeccable impeccable
|
Impeccable pbakaus/impeccable impeccable@impeccable pbakaus/impeccable impeccable skill pbakaus/impeccable impeccable
|
||||||
|
|||||||
|
@@ -9,6 +9,7 @@ The adapters register these events:
|
|||||||
| Codex and Claude | `Stop` | Run the finish notification (`flashbang`). |
|
| Codex and Claude | `Stop` | Run the finish notification (`flashbang`). |
|
||||||
| Claude | `PreCompact` | Record the compaction timestamp under the installed configuration root and point to existing workspace task state. |
|
| Claude | `PreCompact` | Record the compaction timestamp under the installed configuration root and point to existing workspace task state. |
|
||||||
| Claude | `SessionStart` | Print an absolute pointer if the workspace's `.ai-session/state.json` exists. |
|
| Claude | `SessionStart` | Print an absolute pointer if the workspace's `.ai-session/state.json` exists. |
|
||||||
|
| Codex and Claude | SessionStart | Warm the QMD index and search daemon in the background (only while local QMD search is enabled). |
|
||||||
|
|
||||||
The main agent alone maintains canonical task state. Hooks never write or summarize it, print its contents, or infer completed verification. They use the hook event's `cwd`, falling back to the process working directory when no workspace is supplied. Malformed event data does not fall back to another project. An absent state file is a successful no-op. Legacy state under the installed configuration root is not automatically adopted; confirm its task before migrating it. The scripts do not grant permissions, bypass approvals, or replace client permission systems.
|
The main agent alone maintains canonical task state. Hooks never write or summarize it, print its contents, or infer completed verification. They use the hook event's `cwd`, falling back to the process working directory when no workspace is supplied. Malformed event data does not fall back to another project. An absent state file is a successful no-op. Legacy state under the installed configuration root is not automatically adopted; confirm its task before migrating it. The scripts do not grant permissions, bypass approvals, or replace client permission systems.
|
||||||
|
|
||||||
|
|||||||
+2
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The user-level plugin manifest is `adapters/plugins.tsv`. It maps each logical extension to the client-specific plugin, skill, or MCP installation method.
|
The user-level plugin manifest is `adapters/plugins.tsv`. It maps each logical extension to the client-specific plugin, skill, or MCP installation method.
|
||||||
|
|
||||||
The current baseline includes Ponytail, i-have-adhd, Superpowers, Context7, Caveman, QMD, MCPorter, Humanizer, Impeccable, and Anthropic Frontend Design. Both scripts operate at user scope and show an extension selector unless running a dry run. Selections are collected before configuration changes. Both rebuild generated packages first.
|
The current baseline includes Ponytail, i-have-adhd, Superpowers, Context7, Caveman, MCPorter, Humanizer, Impeccable, and Anthropic Frontend Design. Both scripts operate at user scope and show an extension selector unless running a dry run. Selections are collected before configuration changes. Both rebuild generated packages first.
|
||||||
|
|
||||||
Selecting Context7 with MCPorter registers the server in MCPorter. Selecting Context7 without MCPorter installs its native client plugin; that explicit selection permits the native MCP route under the global instructions.
|
Selecting Context7 with MCPorter registers the server in MCPorter. Selecting Context7 without MCPorter installs its native client plugin; that explicit selection permits the native MCP route under the global instructions.
|
||||||
|
|
||||||
@@ -16,11 +16,8 @@ Codex receives Caveman through its native plugin adapter. Humanizer, Impeccable,
|
|||||||
|
|
||||||
On Windows, installation and update repair the known Caveman 1.0.0 hook launcher in setup-owned Codex installations so it resolves the plugin path under PowerShell as well as Command Prompt. Custom commands and foreign installations are preserved. Review the changed hook definitions in Codex `/hooks`; setup does not grant hook trust automatically.
|
On Windows, installation and update repair the known Caveman 1.0.0 hook launcher in setup-owned Codex installations so it resolves the plugin path under PowerShell as well as Command Prompt. Custom commands and foreign installations are preserved. Review the changed hook definitions in Codex `/hooks`; setup does not grant hook trust automatically.
|
||||||
|
|
||||||
[QMD](https://github.com/tobi/qmd) requires Node.js 22 or newer and `npm`. Selecting it installs `@tobilu/qmd` globally. Claude receives QMD's official `qmd@qmd` plugin; Codex can use the `qmd` CLI. Deselecting QMD removes setup-owned integrations and uninstalls the global package when the Codex-owned QMD record is removed.
|
|
||||||
|
|
||||||
Normal installation and update ask whether to enable Flashbang, apply the custom status line, and enable the optional Qwen3-Embedding-0.6B semantic retrieval layer. Retrieval uses a per-user virtual environment and index and is invoked through the installed `server.py search` CLI, disabled by default. While it is enabled, both clients receive the `semantic-search` skill, which the general rules point to for concept questions when its cost check favors it; disabling retrieval removes the skill. Codex may write only the retrieval `data/` directory from its sandbox (`sandbox_workspace_write.writable_roots`); the sandbox forbids the daemon's pipes, so each Codex search loads the models in-process and takes about half a minute. Quickupdate reuses the saved shell, client, status line, Flashbang, retrieval, and extension choices without opening menus. Disabling the status line removes its custom setting and setup-owned Claude script; disabling retrieval removes its setup-owned runtime and index. Only successful runs save selections; dry runs do not update extensions or retrieval. Install and update do not modify the Codex or Claude CLI installations.
|
Normal installation and update ask whether to enable Flashbang, apply the custom status line, and enable optional local QMD search (Yes/No/Auto, disabled by default). Local search runs [QMD](https://github.com/tobi/qmd) (`@tobilu/qmd@2.8.3`, Node.js 22 or newer, `npm`) with Qwen3-Embedding-0.6B, Qwen3-Reranker-0.6B, and QMD Query Expansion 1.7B. Auto benchmarks GPU first, then CPU, and enables search only when median search latency is at most 5 s and embedding runs at least 20 chunks/s. While it is enabled, both clients receive the `semantic-search` skill, which the general rules require for questions whose identifiers are unknown; disabling local search removes the skill and its instruction line. The skill calls `qmd-search.mjs`, which reuses a background daemon on loopback 127.0.0.1 protected by a per-start token; the daemon keeps the models loaded and exits after 30 idle minutes (`QMD_IDLE_MINUTES`). A SessionStart hook warms the index and daemon in the background. Models and the index live in `$XDG_CACHE_HOME/qmd` (default `~/.cache/qmd`); the Codex `writable_roots` cover only `~/.cache/qmd`, so a custom `XDG_CACHE_HOME` needs its own writable root. Under the Codex sandbox, which has not been verified, the wrapper falls back to the slower QMD CLI path when the sandbox blocks loopback connections or starting the daemon. Repository collections skip git-ignored paths; the daemon keeps that list as the collection's `ignore` entry in QMD's `index.yml`. The setup saves and restores a user's own QMD `models:` block, and removal touches only the setup-owned package, models, and repository collections; foreign installs and collections survive. Updating migrates from the earlier Python stack by removing `~/.my-ai-configuration/semantic-retrieval/` and old QMD extension ledger records. Quickupdate reuses the saved shell, client, status line, Flashbang, local search, and extension choices without opening menus. Disabling the status line removes its custom setting and setup-owned Claude script. Only successful runs save selections; dry runs do not update extensions or local search. Install and update do not modify the Codex or Claude CLI installations.
|
||||||
|
|
||||||
The `server.py search`/`rebuild` CLI transparently reuses a background daemon (spawned automatically on first use per `--data-dir` and code version, so an updated `server.py` never reuses an older daemon) that keeps the embedding and reranking models loaded in memory, so only the first query after a cold start or an idle period pays the model-loading cost; every call falls back to loading the models directly in-process if the daemon cannot be reached or started. The daemon exits on its own after 30 minutes of inactivity; `server.py stop` ends it at once. `server.py status` reports indexable, indexed, and stale files and whether the models are loaded, without loading models or starting the daemon, so agents can judge what the next search costs. Search requires a Git repository and indexes tracked files plus untracked files that Git does not ignore. Indexing covers common source, markup, style, SQL, XAML, project, and configuration file types, uses bfloat16 on GPUs that support it, and skips files larger than 512 KB and, per repository, stops after 2000 indexable files by default; set `SEMANTIC_RETRIEVAL_MAX_FILES` to raise that cap for larger monorepos. When the cap is reached, the CLI prints an explicit warning to stderr rather than silently omitting files.
|
|
||||||
|
|
||||||
Plugin installation requires the corresponding client CLI, network access, and any authentication required by the plugin. Context7 may open an OAuth flow. Codex or plugin hooks may require a trust review in `/hooks`. The scripts never install plugins during a build or doctor run and never install them when `--dry-run` or `-DryRun` is used.
|
Plugin installation requires the corresponding client CLI, network access, and any authentication required by the plugin. Context7 may open an OAuth flow. Codex or plugin hooks may require a trust review in `/hooks`. The scripts never install plugins during a build or doctor run and never install them when `--dry-run` or `-DryRun` is used.
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -23,7 +23,7 @@ Run scripts from the repository root. PowerShell and Bash commands have equivale
|
|||||||
| `commands/doctor.ps1`, `commands/doctor.sh` | Check generated output and installed configuration. |
|
| `commands/doctor.ps1`, `commands/doctor.sh` | Check generated output and installed configuration. |
|
||||||
| `commands/prompt-budget.ps1`, `commands/prompt-budget.sh` | Track permanent context, skill metadata, the rule catalog, and agent metadata; warn above 10,000 permanent-context tokens, fail when any token estimate exceeds its baseline by 10%, or permanent context exceeds 15,000 tokens. |
|
| `commands/prompt-budget.ps1`, `commands/prompt-budget.sh` | Track permanent context, skill metadata, the rule catalog, and agent metadata; warn above 10,000 permanent-context tokens, fail when any token estimate exceeds its baseline by 10%, or permanent context exceeds 15,000 tokens. |
|
||||||
| `commands/context-report.ps1`, `commands/context-report.sh` | Report the context installed Claude and Codex actually load, from Claude `/context`, `/skill-doctor`, and its init event, and Codex `debug prompt-input`: categories, active plugins, listed skills, installed skills missing from the listing, and skills listed without description context. `--check` fails on missing or description-less skills. No model request is made. |
|
| `commands/context-report.ps1`, `commands/context-report.sh` | Report the context installed Claude and Codex actually load, from Claude `/context`, `/skill-doctor`, and its init event, and Codex `debug prompt-input`: categories, active plugins, listed skills, installed skills missing from the listing, and skills listed without description context. `--check` fails on missing or description-less skills. No model request is made. |
|
||||||
| `commands/benchmark.ps1`, `commands/benchmark.sh` | Run the Python, C#/ASP.NET, and TypeScript tasks under `scripts/benchmarks/tasks/` with and without the installed setup. Both arms of a client use one pinned model and reasoning effort; a preflight records what each arm actually loads in `provenance.json` and stops when the arms are not comparable; `--arms setup setup-no-search --tasks 11-* 12-* 13-* --warm-index` compares Claude with and without semantic search on concept-search tasks in a large generated repository, and compare pass rate, rework turns, duration, tokens, tool calls, cost, and workflow checks (no planning ceremony for a small change, targeted verification for a bugfix, bounded delegation for a larger change). Starts real model sessions; use `--dry-run` to list runs. The Codex baseline needs one `codex login` with `CODEX_HOME` set to `--codex-baseline-home`. |
|
| `commands/benchmark.ps1`, `commands/benchmark.sh` | Run the Python, C#/ASP.NET, and TypeScript tasks under `scripts/benchmarks/tasks/` with and without the installed setup. Both arms of a client use one pinned model and reasoning effort; a preflight records what each arm actually loads in `provenance.json` and stops when the arms are not comparable; `--arms setup setup-no-search --tasks 11-* 12-* 13-* --warm-index` compares Claude with and without semantic search on concept-search tasks in a large generated repository, and compare pass rate, rework turns, duration, tokens, tool calls, cost, and workflow checks per task category (`category` in `task.json`: small change, larger implementation, search) (no planning ceremony for a small change, targeted verification for a bugfix, bounded delegation for a larger change). Starts real model sessions; use `--dry-run` to list runs. The Codex baseline needs one `codex login` with `CODEX_HOME` set to `--codex-baseline-home`. |
|
||||||
| `commands/repository-context.ps1`, `commands/repository-context.sh` | Refresh the local repository-context cache. |
|
| `commands/repository-context.ps1`, `commands/repository-context.sh` | Refresh the local repository-context cache. |
|
||||||
| `commands/session-state.ps1`, `commands/session-state.sh` | Read or update the local agent session state. |
|
| `commands/session-state.ps1`, `commands/session-state.sh` | Read or update the local agent session state. |
|
||||||
| `commands/telemetry.ps1`, `commands/telemetry.sh` | Append a local telemetry event. |
|
| `commands/telemetry.ps1`, `commands/telemetry.sh` | Append a local telemetry event. |
|
||||||
@@ -40,7 +40,7 @@ To undo an added instruction, pass the same text to `remove-instruction` with `-
|
|||||||
Credential commands currently require PowerShell and Windows Credential Manager, including their Bash wrappers. Use `~/.my-ai-configuration/bin/with-credential.ps1` or `.sh` to run a tool with a stored key available only in that child process. The key must be a valid environment-variable name; its value is never written to configuration.
|
Credential commands currently require PowerShell and Windows Credential Manager, including their Bash wrappers. Use `~/.my-ai-configuration/bin/with-credential.ps1` or `.sh` to run a tool with a stored key available only in that child process. The key must be a valid environment-variable name; its value is never written to configuration.
|
||||||
Use `remove-credentials` with `-Client`/`--client` and `-Key`/`--key` to delete only that credential. The shared `with-credential` helpers remain installed for other keys.
|
Use `remove-credentials` with `-Client`/`--client` and `-Key`/`--key` to delete only that credential. The shared `with-credential` helpers remain installed for other keys.
|
||||||
|
|
||||||
The A/B benchmark measures whole sessions. Qwen Auto selection uses `shared/retrieval/benchmark.py`; run it through `scripts/lib/semantic-retrieval.py benchmark --root . --home <home-directory>`. This warm-model check uses synthetic, repeated short documents. Its result does not measure model startup, first indexing, real code chunks, CLI latency, or memory use; do not use it as an end-to-end performance estimate.
|
The A/B benchmark measures whole sessions. Auto selection of local QMD search uses `shared/qmd/qmd-benchmark.mjs`, run through `scripts/lib/qmd.py`. It measures median search latency and embedding throughput on GPU, then CPU, and is not an end-to-end performance estimate.
|
||||||
|
|
||||||
## Checks and tests
|
## Checks and tests
|
||||||
|
|
||||||
@@ -63,8 +63,8 @@ Gitea Actions runs the repository checks from `.gitea/workflows/ci.yml`.
|
|||||||
| `tests/test-quick-update.py` | Validate saved Quickupdate choices. |
|
| `tests/test-quick-update.py` | Validate saved Quickupdate choices. |
|
||||||
| `tests/test-remove-customizations.py` | Validate targeted instruction and credential removal without accessing the real credential store. |
|
| `tests/test-remove-customizations.py` | Validate targeted instruction and credential removal without accessing the real credential store. |
|
||||||
| `tests/test-quick-update.ps1`, `tests/test-quick-update.sh` | Run the Quickupdate test through the relevant shell. |
|
| `tests/test-quick-update.ps1`, `tests/test-quick-update.sh` | Run the Quickupdate test through the relevant shell. |
|
||||||
| `tests/test-retrieval-server.py` | Validate retrieval indexing, ranking, and optional real-model execution. |
|
| `tests/test-qmd-*.mjs` | Validate the QMD library, search, daemon, warm-up, and benchmark scripts. |
|
||||||
| `tests/test-semantic-retrieval.py` | Validate retrieval installation and removal. |
|
| `tests/test-qmd-setup.py` | Validate QMD installation, migration, and removal. |
|
||||||
| `tests/test-session-state.py` | Validate session-state helpers. |
|
| `tests/test-session-state.py` | Validate session-state helpers. |
|
||||||
| `tests/test-shell-packages.ps1`, `tests/test-shell-packages.sh` | Validate generated package contents and client settings. |
|
| `tests/test-shell-packages.ps1`, `tests/test-shell-packages.sh` | Validate generated package contents and client settings. |
|
||||||
| `tests/test-runtime-status.ps1`, `tests/test-runtime-status.sh` | Validate build summaries and status-line output. |
|
| `tests/test-runtime-status.ps1`, `tests/test-runtime-status.sh` | Validate build summaries and status-line output. |
|
||||||
@@ -88,6 +88,6 @@ Gitea Actions runs the repository checks from `.gitea/workflows/ci.yml`.
|
|||||||
| `lib/remove-instruction.py` | Remove an exact client-specific instruction block. |
|
| `lib/remove-instruction.py` | Remove an exact client-specific instruction block. |
|
||||||
| `lib/selection-state.ps1`, `lib/selection-state.sh` | Persist choices used by Quickupdate. |
|
| `lib/selection-state.ps1`, `lib/selection-state.sh` | Persist choices used by Quickupdate. |
|
||||||
| `lib/selection-state.py` | Read and write Quickupdate state. |
|
| `lib/selection-state.py` | Read and write Quickupdate state. |
|
||||||
| `lib/semantic-retrieval.ps1`, `lib/semantic-retrieval.sh` | Invoke semantic retrieval from the installation flow. |
|
| `lib/qmd.sh`, `lib/qmd.ps1` | Invoke local QMD search from the installation flow. |
|
||||||
| `lib/semantic-retrieval.py` | Install, index, benchmark, and remove the local retrieval service. |
|
| `lib/qmd.py` | Install, migrate, benchmark, and remove local QMD search. |
|
||||||
| `shared/retrieval/benchmark.py` | Check warm Qwen embedding and reranking speed for Auto selection; not an end-to-end benchmark. |
|
| `shared/qmd/*.mjs` | Installed QMD scripts: search CLI, daemon, warm-up, benchmark, and shared library. |
|
||||||
+1
-1
@@ -5,7 +5,7 @@ Skills are reusable workflows. Rules are persistent constraints and agents are s
|
|||||||
The current structure is organized by purpose:
|
The current structure is organized by purpose:
|
||||||
|
|
||||||
- `planning/`: impact analysis and work-item definition
|
- `planning/`: impact analysis and work-item definition
|
||||||
- `research/`: technical and repository research, plus `semantic-search`, which is installed only while semantic retrieval is enabled
|
- `research/`: technical and repository research, plus `semantic-search`, which is installed only while local QMD search is enabled
|
||||||
- `reviews/`: security, performance, WPF, UI/UX, business, API, and EF Core reviews
|
- `reviews/`: security, performance, WPF, UI/UX, business, API, and EF Core reviews
|
||||||
- `implementation/`: SQL, unit-test, and integration-test writing
|
- `implementation/`: SQL, unit-test, and integration-test writing
|
||||||
- `verification/`: facts and work-item verification
|
- `verification/`: facts and work-item verification
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,168 @@
|
|||||||
|
# QMD local search design
|
||||||
|
|
||||||
|
Date: 2026-10-08
|
||||||
|
Status: approved in conversation, pending written-spec review
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the Python semantic retrieval stack with QMD as the only local search engine, used by Claude Code and Codex alike.
|
||||||
|
|
||||||
|
| Role | Model | QMD URI |
|
||||||
|
|---|---|---|
|
||||||
|
| Embedding | Qwen3-Embedding-0.6B | `hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf` |
|
||||||
|
| Reranking | Qwen3-Reranker-0.6B | `hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf` |
|
||||||
|
| Query expansion | QMD Query Expansion 1.7B | `hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf` |
|
||||||
|
|
||||||
|
Success criteria:
|
||||||
|
|
||||||
|
- No Python model code, virtual environment, or Python model dependency remains.
|
||||||
|
- QMD is no longer an extension in the plugin selector; the local-models question alone controls it.
|
||||||
|
- When enabled, agents use the search skill for every question whose identifiers are unknown, and the search answers in seconds because the index is warm and the models are loaded.
|
||||||
|
- When disabled or unsuitable, no skill, hook, instruction line, QMD package, or model installed by the setup remains.
|
||||||
|
|
||||||
|
Motivation from the 2026-10-08 Claude A/B benchmark: the Python search was never used in the three concept-search tasks, while the setup's fixed context made every run about 2.5 times more expensive in input tokens. A permanent MCP server would add to that cost, so the integration is a skill over a CLI wrapper.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. Search scope: primarily the current Git repository; additionally user-registered QMD collections (`includeByDefault: true`), ranked together in one query.
|
||||||
|
2. Index location: one global QMD index (`~/.cache/qmd`). Each repository is a collection named `repo-<short hash of the absolute repository path>` with `includeByDefault: false`. No files are written into repositories.
|
||||||
|
3. Agent access: an own `semantic-search` skill for both clients that calls a Node wrapper. QMD's `qmd@qmd` plugin and MCP tools are not used.
|
||||||
|
4. Warm-up: a SessionStart hook indexes in the background and starts an own search daemon built on the QMD SDK so all three models stay loaded. QMD's HTTP daemon (`qmd mcp --http`) is not used: its `POST /query` accepts only pre-typed `lex`/`vec`/`hyde` searches and never runs query expansion.
|
||||||
|
5. Daemon lifetime: the daemon exits by itself after 30 idle minutes (`QMD_IDLE_MINUTES`).
|
||||||
|
6. Auto mode: benchmark GPU first, then CPU; both thresholds must pass; if no device passes, remove what the setup installed and continue as No.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `shared/retrieval/` (`server.py`, `benchmark.py`, `requirements.txt`).
|
||||||
|
- `scripts/lib/semantic-retrieval.py`, `.sh`, `.ps1`.
|
||||||
|
- `scripts/tests/test-retrieval-server.py`, `scripts/tests/test-semantic-retrieval.py`.
|
||||||
|
- The `retrieval` job in `.gitea/workflows/ci.yml`.
|
||||||
|
- The `QMD` row in `adapters/plugins.tsv` and QMD-specific ownership in `scripts/lib/managed-extensions.py`, `scripts/lib/plugins.sh`, and `scripts/lib/plugins.ps1`.
|
||||||
|
- Qwen/Python references in `README.md`, `docs/plugins.md`, `docs/scripts.md`, and other affected documentation.
|
||||||
|
|
||||||
|
### Added under `shared/qmd/`
|
||||||
|
|
||||||
|
- `qmd-daemon.mjs`: long-lived search process using the QMD SDK (`createStore({ dbPath, configPath })`, `store.search({ query, collections })` with expansion and reranking, `store.update()`, `store.embed()`). Listens on `127.0.0.1` only, on a port chosen at start; writes `{port, token, pid}` to `~/.my-ai-configuration/qmd/daemon.json` readable only by the user; rejects requests without the token header. Exits after `QMD_IDLE_MINUTES` (default 30) without a request. A second start while one is healthy is a no-op.
|
||||||
|
- `qmd-search.mjs`: ensures the repository collection, refreshes it incrementally, sends the search to the daemon, starts the daemon if it is not running, and falls back to `qmd query --json` when the daemon cannot be used. Collections searched: the repository collection plus every collection with `includeByDefault: true`. Output: JSON list of `{path, line, score, snippet}` with repository-relative paths, best first.
|
||||||
|
- `qmd-warm.mjs`: started detached by the SessionStart hook. Starts the daemon with the stored device if it is not running and asks it to refresh and embed the repository collection. A per-repository lock file prevents parallel indexing from concurrent sessions.
|
||||||
|
- `qmd-benchmark.mjs`: the Auto benchmark (see below).
|
||||||
|
- Thin `.sh` and `.ps1` entry points for every script, as the repository requires.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `shared/skills/research/semantic-search/SKILL.md`: rewritten for QMD; name kept so references and the A/B benchmark stay stable.
|
||||||
|
- `scripts/lib/qmd.sh`, `scripts/lib/qmd.ps1`: install, update, benchmark, and removal, called from `install` and `update`.
|
||||||
|
- Hooks: SessionStart entries for Claude and Codex are generated only when local models are enabled.
|
||||||
|
- `shared/rules/general.md` (and the generated global instructions): the semantic-search line becomes mandatory and is generated only when the skill is installed.
|
||||||
|
- `scripts/benchmarks/ab.py`: `setup-no-search` blocks the skill and wrapper instead of the Python CLI; `--warm-index` runs `qmd-warm.mjs`.
|
||||||
|
|
||||||
|
### Repository collection
|
||||||
|
|
||||||
|
- Name: `repo-<first 12 hex characters of SHA-256 of the absolute repository path>`.
|
||||||
|
- Mask: `**/*.{md,py,cs,ts,tsx,js,jsx,mjs,go,rs,java,ps1,sh,json,yml,yaml,xml,sql}`.
|
||||||
|
- Chunking: `--chunk-strategy auto` (AST chunking where QMD supports it; regex chunking elsewhere, including C#).
|
||||||
|
- QMD's built-in exclusions apply (`node_modules`, `.git`, `.cache`, `vendor`, `dist`, `build`).
|
||||||
|
|
||||||
|
### Configuration and state
|
||||||
|
|
||||||
|
- `~/.config/qmd/index.yml` (or `QMD_CONFIG_DIR`): the setup writes only the `models:` block; other keys and collections are preserved. An unparsable file aborts without writing. The daemon also writes the `ignore` list (the repository's git-ignored paths) of its own `repo-*` collections, because QMD re-syncs SQLite from this file and its `addCollection` write-through drops `ignore`.
|
||||||
|
- `~/.my-ai-configuration/qmd.json`: `{version, device: "gpu" | "cpu", owned_package: bool, owned_models: [...]}`.
|
||||||
|
- Device mapping when starting QMD: `gpu` sets `QMD_LLAMA_GPU=auto` (llama.cpp uses all detected GPUs); `cpu` sets `QMD_FORCE_CPU=1`.
|
||||||
|
|
||||||
|
## Install and update flow
|
||||||
|
|
||||||
|
Question, identical in Bash and PowerShell:
|
||||||
|
|
||||||
|
`Enable local QMD search models (Qwen3 embedding, Qwen3 reranker, QMD query expansion)? [y/N/a]`
|
||||||
|
|
||||||
|
- The default is the stored selection. The selection key `semantic_retrieval` and the `-SemanticRetrieval` / `--semantic-retrieval` parameters keep their names for compatibility.
|
||||||
|
- An update that keeps the stored selection does not rerun the benchmark; the benchmark runs only when `a` is chosen explicitly.
|
||||||
|
|
||||||
|
Prerequisites: Node.js 22 or newer and `npm`. Missing with Yes: abort with a clear message before any change. Missing with Auto: treat as unsuitable (No) and report the reason.
|
||||||
|
|
||||||
|
Yes:
|
||||||
|
|
||||||
|
1. `npm install -g @tobilu/qmd@<pinned version>`; record ownership only if QMD was not already installed.
|
||||||
|
2. Write the `models:` block into `index.yml`.
|
||||||
|
3. `qmd pull`.
|
||||||
|
4. Device: `gpu` when QMD's device probe (`qmd doctor`) reports a usable GPU, otherwise `cpu`. No benchmark.
|
||||||
|
5. Install skill, hook, scripts, and the mandatory instruction line.
|
||||||
|
|
||||||
|
Auto:
|
||||||
|
|
||||||
|
1. Steps 1 to 3 as for Yes.
|
||||||
|
2. Benchmark on GPU; if no GPU exists or it fails, benchmark on CPU.
|
||||||
|
3. The first passing device continues as Yes with that device.
|
||||||
|
4. If none passes: report the measurements, delete the models the setup downloaded, uninstall QMD only if the setup installed it, restore the configuration, store the selection as `false`, and continue as No.
|
||||||
|
|
||||||
|
No:
|
||||||
|
|
||||||
|
- Remove skill, hook, scripts, and instruction line; stop a running daemon.
|
||||||
|
- Remove the setup-owned QMD package and setup-owned models.
|
||||||
|
- Never remove a QMD installation, model, or collection the setup did not create.
|
||||||
|
|
||||||
|
Migration on the first update after this change:
|
||||||
|
|
||||||
|
- Remove the Python installation: `~/.my-ai-configuration/semantic-retrieval/` (virtual environment, model cache, data), its daemon, and `semantic-retrieval.json`.
|
||||||
|
- Remove previous QMD extension records (Claude plugin `qmd@qmd`, Codex QMD record) from the extension state. If the answer is Yes or a passing Auto, the existing npm package is adopted by the new path instead of being uninstalled.
|
||||||
|
|
||||||
|
Dry run: print every step as `DRYRUN ...`; no download, benchmark, or removal.
|
||||||
|
|
||||||
|
## Auto benchmark
|
||||||
|
|
||||||
|
- Fixture: a small fixed document set shipped with the scripts, indexed into a temporary named index (`qmd --index ai-config-benchmark`), removed afterwards.
|
||||||
|
- Models are warmed before measuring.
|
||||||
|
- Search: median latency of three `qmd query` runs with expansion, vector search, and reranking of 40 candidates must be at most 5 s (`QMD_BENCHMARK_MAX_QUERY_SECONDS`).
|
||||||
|
- Indexing: embedding throughput must be at least 20 chunks per second (`QMD_BENCHMARK_MIN_CHUNKS_PER_SECOND`).
|
||||||
|
- Order: GPU first, then CPU. Timeout per device: 10 minutes; a timeout fails that device.
|
||||||
|
- Result: JSON `{device, querySeconds, chunksPerSecond, passed}` per measured device, printed in the install summary.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
SessionStart hook (Claude and Codex, `.sh` and `.ps1`):
|
||||||
|
|
||||||
|
- Runs only inside a Git repository. Starts `qmd-warm.mjs` detached and returns immediately with exit code 0.
|
||||||
|
- Errors go to `~/.my-ai-configuration/logs/qmd-warm.log`; the hook never blocks or fails a session.
|
||||||
|
|
||||||
|
Search (`qmd-search.mjs`):
|
||||||
|
|
||||||
|
1. Refresh the repository collection when files changed since the last refresh (`git status` and modification times), then `qmd embed` for it.
|
||||||
|
2. Query the daemon; start it if it is not running; fall back to `qmd query --json` when the daemon cannot be reached or started.
|
||||||
|
3. Print the JSON results.
|
||||||
|
|
||||||
|
Strict skill:
|
||||||
|
|
||||||
|
- 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', bug reports that describe behavior. Use rg only for known identifiers, paths, or exact strings."
|
||||||
|
- Body: search first with a full behavioral question; open and verify the top hits; then use `rg` for callers and tests. If no relevant hit appears, run one rephrased search before switching to `rg`. The previous cost check (`status`, repository-size thresholds) is removed.
|
||||||
|
|
||||||
|
Global instruction line (only when the skill is installed): "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."
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Model download or `npm` failure during install or update: roll back what this run created. Yes fails the install; Auto treats it as unsuitable and reports the reason.
|
||||||
|
- Benchmark hang: per-device timeout of 10 minutes.
|
||||||
|
- Hook: detached, logged, always exit code 0. `qmd embed` uses QMD's 30-minute session cap and resumes incrementally on the next start.
|
||||||
|
- Wrapper failure: non-zero exit with a short message on stderr; the skill continues with `rg` and states the limitation.
|
||||||
|
- Stale `daemon.json` (process gone or token rejected): the wrapper deletes it and starts a new daemon; if that fails it uses the CLI fallback.
|
||||||
|
- `index.yml` that cannot be parsed: abort without writing.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Node unit tests (`node --test`): collection name and mask, benchmark decision (thresholds, GPU before CPU, unsuitable result), daemon idle exit with an injected clock, token rejection, wrapper fallback to the CLI, `index.yml` merge preserving foreign keys.
|
||||||
|
- Install guard tests in Bash and PowerShell with a mocked `qmd`: Yes, No, Auto pass and fail, dry run, ownership (a foreign QMD survives No), migration (Python installation removed), skill, hook, and instruction line present only when enabled.
|
||||||
|
- Integration job replacing the `retrieval` CI job: install QMD, pull models (cached), index a small fixture, assert the wrapper returns the expected hit, start and stop the daemon.
|
||||||
|
- `ab.py`: the `setup-no-search` arm and `--warm-index` use the QMD path.
|
||||||
|
- `prompt-budget`: the longer skill description changes the baseline; update it deliberately and report the difference.
|
||||||
|
|
||||||
|
## Risks to verify during implementation
|
||||||
|
|
||||||
|
- Whether the Codex `workspace-write` sandbox can reach the daemon on `127.0.0.1` and read `~/.cache/qmd`. If not, the Codex skill needs an approval rule for the wrapper or must run it outside the sandbox.
|
||||||
|
- Whether `qmd doctor`'s device probe is reliable enough to choose `gpu` for Yes without a benchmark.
|
||||||
|
- Disk and VRAM use: about 2.4 GB of models; the daemon holds them in VRAM until the idle stop.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Commands for managing user collections; users register them with `qmd collection add`.
|
||||||
|
- Any MCP integration of QMD.
|
||||||
+32
-24
@@ -51,11 +51,15 @@ def kill_tree(process):
|
|||||||
process.wait(timeout=30)
|
process.wait(timeout=30)
|
||||||
|
|
||||||
|
|
||||||
def run_jsonl(command, cwd, env, timeout):
|
def run_jsonl(command, cwd, env, timeout, prompt):
|
||||||
"""Run a client, return (events, seconds, timed_out); output is read on a thread so the deadline holds."""
|
"""Run a client, return (events, seconds, timed_out); output is read on a thread so the deadline holds.
|
||||||
|
|
||||||
|
The prompt goes through stdin: on Windows the clients start through .cmd shims that cut arguments at newlines.
|
||||||
|
"""
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
process = subprocess.Popen(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
process = subprocess.Popen(command, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL, text=True, encoding='utf-8', errors='replace')
|
stderr=subprocess.DEVNULL, text=True, encoding='utf-8', errors='replace')
|
||||||
|
threading.Thread(target=lambda: (process.stdin.write(prompt), process.stdin.close()), daemon=True).start()
|
||||||
lines = queue.Queue()
|
lines = queue.Queue()
|
||||||
threading.Thread(target=lambda: ([lines.put(line) for line in process.stdout], lines.put(None)), daemon=True).start()
|
threading.Thread(target=lambda: ([lines.put(line) for line in process.stdout], lines.put(None)), daemon=True).start()
|
||||||
events, timed_out = [], False
|
events, timed_out = [], False
|
||||||
@@ -80,8 +84,8 @@ def claude_flags(arm, args):
|
|||||||
if arm == 'baseline':
|
if arm == 'baseline':
|
||||||
flags += ['--setting-sources', '', '--strict-mcp-config']
|
flags += ['--setting-sources', '', '--strict-mcp-config']
|
||||||
if arm == 'setup-no-search':
|
if arm == 'setup-no-search':
|
||||||
flags += ['--disallowedTools', 'Skill(semantic-search)', 'Bash(*semantic-retrieval*)', 'PowerShell(*semantic-retrieval*)',
|
flags += ['--disallowedTools', 'Skill(semantic-search)', 'Bash(*qmd-search*)', 'PowerShell(*qmd-search*)',
|
||||||
'--append-system-prompt', 'The semantic-search skill and the semantic retrieval CLI are unavailable in this session.']
|
'--append-system-prompt', 'The semantic-search skill and the QMD search wrapper are unavailable in this session.']
|
||||||
return flags
|
return flags
|
||||||
|
|
||||||
|
|
||||||
@@ -90,12 +94,12 @@ def codex_env(arm, args):
|
|||||||
|
|
||||||
|
|
||||||
def claude_turn(prompt, cwd, arm, args, session):
|
def claude_turn(prompt, cwd, arm, args, session):
|
||||||
command = [shutil.which('claude'), '-p', prompt, '--output-format', 'stream-json', '--verbose',
|
command = [shutil.which('claude'), '-p', '--output-format', 'stream-json', '--verbose',
|
||||||
'--permission-mode', 'auto', '--allowedTools', *CLAUDE_TOOLS, *claude_flags(arm, args)]
|
'--permission-mode', 'auto', '--allowedTools', *CLAUDE_TOOLS, *claude_flags(arm, args)]
|
||||||
if session:
|
if session:
|
||||||
command += ['--resume', session]
|
command += ['--resume', session]
|
||||||
timeout = args.timeout
|
timeout = args.timeout
|
||||||
events, seconds, timed_out = run_jsonl(command, cwd, clean_env(), timeout)
|
events, seconds, timed_out = run_jsonl(command, cwd, clean_env(), timeout, prompt)
|
||||||
result = next((event for event in reversed(events) if event.get('type') == 'result'), {})
|
result = next((event for event in reversed(events) if event.get('type') == 'result'), {})
|
||||||
usage = result.get('usage', {})
|
usage = result.get('usage', {})
|
||||||
calls = [block for event in events if event.get('type') == 'assistant'
|
calls = [block for event in events if event.get('type') == 'assistant'
|
||||||
@@ -118,11 +122,12 @@ def codex_turn(prompt, cwd, arm, args, session):
|
|||||||
command = [shutil.which('codex'), 'exec']
|
command = [shutil.which('codex'), 'exec']
|
||||||
if session:
|
if session:
|
||||||
command += ['resume', session]
|
command += ['resume', session]
|
||||||
command += ['--json', '--skip-git-repo-check', '--sandbox', 'workspace-write',
|
# `exec resume` rejects --sandbox; the config key works for both forms.
|
||||||
|
command += ['--json', '--skip-git-repo-check', '-c', 'sandbox_mode="workspace-write"',
|
||||||
'-m', args.codex_model, '-c', f'model_reasoning_effort="{args.codex_effort}"']
|
'-m', args.codex_model, '-c', f'model_reasoning_effort="{args.codex_effort}"']
|
||||||
command += ['-C', str(cwd)] if not session else []
|
command += ['-C', str(cwd)] if not session else []
|
||||||
command.append(prompt)
|
command.append('-')
|
||||||
events, seconds, timed_out = run_jsonl(command, cwd, codex_env(arm, args), args.timeout)
|
events, seconds, timed_out = run_jsonl(command, cwd, codex_env(arm, args), args.timeout, prompt)
|
||||||
items = [event.get('item', {}) for event in events if event.get('type') == 'item.completed']
|
items = [event.get('item', {}) for event in events if event.get('type') == 'item.completed']
|
||||||
tools = [item for item in items if item.get('type') not in ('agent_message', 'reasoning', 'error')]
|
tools = [item for item in items if item.get('type') not in ('agent_message', 'reasoning', 'error')]
|
||||||
usage = [event.get('usage', {}) for event in events if event.get('type') == 'turn.completed']
|
usage = [event.get('usage', {}) for event in events if event.get('type') == 'turn.completed']
|
||||||
@@ -136,7 +141,7 @@ def codex_turn(prompt, cwd, arm, args, session):
|
|||||||
'final': next((str(item.get('text', '')) for item in reversed(items) if item.get('type') == 'agent_message'), ''),
|
'final': next((str(item.get('text', '')) for item in reversed(items) if item.get('type') == 'agent_message'), ''),
|
||||||
'models': [args.codex_model],
|
'models': [args.codex_model],
|
||||||
'error': next((str(event.get('message', event.get('error', '')))[:300] for event in events if event.get('type') in ('error', 'turn.failed')
|
'error': next((str(event.get('message', event.get('error', '')))[:300] for event in events if event.get('type') in ('error', 'turn.failed')
|
||||||
and not str(event.get('message', '')).startswith('Reconnecting')), '') if not usage else '',
|
and not str(event.get('message', '')).startswith('Reconnecting')), 'no turn.completed event') if not usage else '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +276,7 @@ def preflight(args, clients, arms):
|
|||||||
return provenance, problems
|
return provenance, problems
|
||||||
|
|
||||||
|
|
||||||
RETRIEVAL = Path.home() / '.my-ai-configuration/semantic-retrieval'
|
QMD_SCRIPTS = Path.home() / '.my-ai-configuration/qmd'
|
||||||
FILLER_TOPICS = [
|
FILLER_TOPICS = [
|
||||||
('statement', 'download', 'Render a statement download for the export queue.'),
|
('statement', 'download', 'Render a statement download for the export queue.'),
|
||||||
('trial', 'banner', 'Show the trial banner text in the marketing header.'),
|
('trial', 'banner', 'Show the trial banner text in the marketing header.'),
|
||||||
@@ -298,11 +303,10 @@ def generate_filler(work, count):
|
|||||||
|
|
||||||
|
|
||||||
def warm_index(work):
|
def warm_index(work):
|
||||||
"""Build the semantic index before the session, as in a repository that was searched before."""
|
"""Index the fixture and start the QMD daemon before the session, as in a repository that was searched before."""
|
||||||
server = RETRIEVAL / 'server.py'
|
script = QMD_SCRIPTS / 'qmd-warm.mjs'
|
||||||
if server.is_file():
|
if script.is_file():
|
||||||
subprocess.run([sys.executable, str(server), 'rebuild', '--root', str(work), '--data-dir', str(RETRIEVAL / 'data'),
|
subprocess.run(['node', str(script), '--root', str(work)], capture_output=True, timeout=1800)
|
||||||
'--model-cache', str(RETRIEVAL / 'model-cache')], capture_output=True, timeout=1800)
|
|
||||||
|
|
||||||
|
|
||||||
def check(work, task):
|
def check(work, task):
|
||||||
@@ -372,7 +376,7 @@ def run_one(job, args):
|
|||||||
'agents': sum(turn['agents'] for turn in turns), 'skills': [skill for turn in turns for skill in turn['skills']],
|
'agents': sum(turn['agents'] for turn in turns), 'skills': [skill for turn in turns for skill in turn['skills']],
|
||||||
'commands': [command for turn in turns for command in turn['commands']],
|
'commands': [command for turn in turns for command in turn['commands']],
|
||||||
'models': sorted({model for turn in turns for model in turn['models']}),
|
'models': sorted({model for turn in turns for model in turn['models']}),
|
||||||
'searches': sum('semantic-retrieval' in command for turn in turns for command in turn['commands'])
|
'searches': sum('qmd-search' in command for turn in turns for command in turn['commands'])
|
||||||
+ sum(skill == 'semantic-search' for turn in turns for skill in turn['skills']),
|
+ sum(skill == 'semantic-search' for turn in turns for skill in turn['skills']),
|
||||||
'timed_out': any(turn['timed_out'] for turn in turns), 'workflow_kind': spec['workflow'].get('kind'),
|
'timed_out': any(turn['timed_out'] for turn in turns), 'workflow_kind': spec['workflow'].get('kind'),
|
||||||
'workflow_violations': workflow_violations(spec['workflow'], turns), 'check_output': '' if passed else output,
|
'workflow_violations': workflow_violations(spec['workflow'], turns), 'check_output': '' if passed else output,
|
||||||
@@ -382,14 +386,18 @@ def run_one(job, args):
|
|||||||
def summarize(results):
|
def summarize(results):
|
||||||
errors = [result for result in results if result.get('error')]
|
errors = [result for result in results if result.get('error')]
|
||||||
results = [result for result in results if not result.get('error')]
|
results = [result for result in results if not result.get('error')]
|
||||||
lines = [f'Excluded {len(errors)} runs that ended with a client or API error (for example a usage limit).', '','| client | arm | runs | pass | first try | rework turns | median s | median input tok | median output tok | median tool calls | cost USD | workflow ok |',
|
lines = [f'Excluded {len(errors)} runs that ended with a client or API error (for example a usage limit).',
|
||||||
'|---|---|---|---|---|---|---|---|---|---|---|---|']
|
'Rows are per task category; an overall average would hide a category where one arm is worse.', '',
|
||||||
|
'| category | client | arm | runs | pass | first try | rework turns | median s | median input tok | median output tok | median tool calls | cost USD | workflow ok |',
|
||||||
|
'|---|---|---|---|---|---|---|---|---|---|---|---|---|']
|
||||||
groups = {}
|
groups = {}
|
||||||
for result in results:
|
for result in results:
|
||||||
groups.setdefault((result['client'], result['arm']), []).append(result)
|
spec = TASKS / result['task'] / 'task.json'
|
||||||
for (client, arm), runs in sorted(groups.items()):
|
category = json.loads(spec.read_text(encoding='utf-8')).get('category', '?') if spec.is_file() else '?'
|
||||||
|
groups.setdefault((category, result['client'], result['arm']), []).append(result)
|
||||||
|
for (category, client, arm), runs in sorted(groups.items()):
|
||||||
median = lambda key: statistics.median(run[key] for run in runs)
|
median = lambda key: statistics.median(run[key] for run in runs)
|
||||||
lines.append(f'| {client} | {arm} | {len(runs)} | {sum(run["passed"] for run in runs) / len(runs):.0%} | '
|
lines.append(f'| {category} | {client} | {arm} | {len(runs)} | {sum(run["passed"] for run in runs) / len(runs):.0%} | '
|
||||||
f'{sum(run["first_try"] for run in runs) / len(runs):.0%} | {sum(run["rework_turns"] for run in runs)} | '
|
f'{sum(run["first_try"] for run in runs) / len(runs):.0%} | {sum(run["rework_turns"] for run in runs)} | '
|
||||||
f'{median("seconds"):.0f} | {median("input_tokens"):.0f} | {median("output_tokens"):.0f} | {median("tool_calls"):.0f} | '
|
f'{median("seconds"):.0f} | {median("input_tokens"):.0f} | {median("output_tokens"):.0f} | {median("tool_calls"):.0f} | '
|
||||||
f'{sum(run["cost_usd"] for run in runs):.2f} | {sum(not run["workflow_violations"] for run in runs) / len(runs):.0%} |')
|
f'{sum(run["cost_usd"] for run in runs):.2f} | {sum(not run["workflow_violations"] for run in runs) / len(runs):.0%} |')
|
||||||
@@ -405,7 +413,7 @@ def main():
|
|||||||
parser.add_argument('--clients', nargs='+', choices=('claude', 'codex'), default=['claude', 'codex'])
|
parser.add_argument('--clients', nargs='+', choices=('claude', 'codex'), default=['claude', 'codex'])
|
||||||
parser.add_argument('--arms', nargs='+', choices=('setup', 'baseline', 'setup-no-search'), default=['setup', 'baseline'],
|
parser.add_argument('--arms', nargs='+', choices=('setup', 'baseline', 'setup-no-search'), default=['setup', 'baseline'],
|
||||||
help='setup-no-search is the setup with the semantic-search skill and CLI blocked (Claude only)')
|
help='setup-no-search is the setup with the semantic-search skill and CLI blocked (Claude only)')
|
||||||
parser.add_argument('--warm-index', action='store_true', help='build the semantic index before setup-arm sessions, outside the timing')
|
parser.add_argument('--warm-index', action='store_true', help='start the QMD daemon and index the fixture before setup-arm sessions, outside the timing')
|
||||||
parser.add_argument('--tasks', nargs='+', help='task directory names or patterns such as 11-* (default: all)')
|
parser.add_argument('--tasks', nargs='+', help='task directory names or patterns such as 11-* (default: all)')
|
||||||
parser.add_argument('--repetitions', type=int, default=3)
|
parser.add_argument('--repetitions', type=int, default=3)
|
||||||
parser.add_argument('--first-repetition', type=int, default=1, help='number of the first repetition, to rerun selected repetitions')
|
parser.add_argument('--first-repetition', type=int, default=1, help='number of the first repetition, to rerun selected repetitions')
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ def skill_names(root):
|
|||||||
|
|
||||||
|
|
||||||
def tokens(text):
|
def tokens(text):
|
||||||
text = text.strip().lstrip('~')
|
# Claude prints "< 20" for small nonzero counts; count it as its upper bound.
|
||||||
|
text = text.strip().lstrip('~<').strip()
|
||||||
if text in ('', '-'):
|
if text in ('', '-'):
|
||||||
return 0
|
return 0
|
||||||
return round(float(text[:-1]) * 1000) if text.endswith('k') else round(float(text))
|
return round(float(text[:-1]) * 1000) if text.endswith('k') else round(float(text))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "Change the default request timeout from 30 to 60 seconds.",
|
"prompt": "Change the default request timeout from 30 to 60 seconds.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "small change without planning ceremony",
|
"kind": "small change without planning ceremony",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "Users report that the last item is missing on the final page returned by paginate(). Fix the bug.",
|
"prompt": "Users report that the last item is missing on the final page returned by paginate(). Fix the bug.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "bugfix with targeted verification",
|
"kind": "bugfix with targeted verification",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "larger implementation",
|
||||||
"prompt": "Implement the three independent parsers in app/parsers (csv_parser, json_parser, ini_parser) according to their docstrings, and add unit tests for each. They share no code.",
|
"prompt": "Implement the three independent parsers in app/parsers (csv_parser, json_parser, ini_parser) according to their docstrings, and add unit tests for each. They share no code.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "larger change with justified delegation",
|
"kind": "larger change with justified delegation",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "find_user() fails for user names that contain an apostrophe, such as O'Brien. Fix it.",
|
"prompt": "find_user() fails for user names that contain an apostrophe, such as O'Brien. Fix it.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "security-relevant bugfix"
|
"kind": "security-relevant bugfix"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "larger implementation",
|
||||||
"prompt": "Add unittest-based unit tests for app/slug.py under tests/ that cover its edge cases.",
|
"prompt": "Add unittest-based unit tests for app/slug.py under tests/ that cover its edge cases.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "test writing"
|
"kind": "test writing"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "larger implementation",
|
||||||
"prompt": "Remove the duplicated formatting logic in app/report.py without changing its output.",
|
"prompt": "Remove the duplicated formatting logic in app/report.py without changing its output.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "behavior-preserving refactor"
|
"kind": "behavior-preserving refactor"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "save_settings() loses keys it does not know about when it updates the settings file. Fix it so unknown keys survive.",
|
"prompt": "save_settings() loses keys it does not know about when it updates the settings file. Fix it so unknown keys survive.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "data-preserving bugfix"
|
"kind": "data-preserving bugfix"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "Rename the retries parameter of app.api.fetch to max_retries.",
|
"prompt": "Rename the retries parameter of app.api.fetch to max_retries.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "API change with directly affected documentation"
|
"kind": "API change with directly affected documentation"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "Make read_upload() safe against path traversal. Legitimate names in nested folders must keep working.",
|
"prompt": "Make read_upload() safe against path traversal. Legitimate names in nested folders must keep working.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "security hardening"
|
"kind": "security hardening"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "dedupe() is far too slow for 100,000 items. Make it fast while keeping its behavior and the order of first occurrences.",
|
"prompt": "dedupe() is far too slow for 100,000 items. Make it fast while keeping its behavior and the order of first occurrences.",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"kind": "performance fix"
|
"kind": "performance fix"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "search",
|
||||||
"prompt": "Customers whose free trial has ended can still download their monthly statements. Block that by raising PermissionError, and keep it working for paying customers and for customers whose trial is still running.",
|
"prompt": "Customers whose free trial has ended can still download their monthly statements. Block that by raising PermissionError, and keep it working for paying customers and for customers whose trial is still running.",
|
||||||
"generate": {
|
"generate": {
|
||||||
"filler_modules": 250
|
"filler_modules": 250
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"prompt": "Uploads that fail with a temporary network error are never retried. Make at most three attempts in total before giving up with the last error; other errors must not be retried.",
|
"category": "search",
|
||||||
|
"prompt": "Fix the upload code: uploads that fail with a temporary network error are never retried. They should make at most three attempts in total before giving up with the last error; other errors must not be retried.",
|
||||||
"generate": {
|
"generate": {
|
||||||
"filler_modules": 250
|
"filler_modules": 250
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "search",
|
||||||
"prompt": "Invoice totals are sometimes one cent too low, for example 1.005 shows as 1.00. Round half amounts up to the next cent.",
|
"prompt": "Invoice totals are sometimes one cent too low, for example 1.005 shows as 1.00. Round half amounts up to the next cent.",
|
||||||
"generate": {
|
"generate": {
|
||||||
"filler_modules": 250
|
"filler_modules": 250
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "larger implementation",
|
||||||
"prompt": "POST /orders accepts orders with a zero or negative quantity and orders with an empty SKU. Reject those with HTTP 400 and a validation problem response; valid orders must still return 201.",
|
"prompt": "POST /orders accepts orders with a zero or negative quantity and orders with an empty SKU. Reject those with HTTP 400 and a validation problem response; valid orders must still return 201.",
|
||||||
"check": [
|
"check": [
|
||||||
"dotnet",
|
"dotnet",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "larger implementation",
|
||||||
"prompt": "Cancelling a report build does nothing: BuildAsync keeps running until every section is rendered. Make it honor cancellation promptly by throwing OperationCanceledException.",
|
"prompt": "Cancelling a report build does nothing: BuildAsync keeps running until every section is rendered. Make it honor cancellation promptly by throwing OperationCanceledException.",
|
||||||
"check": [
|
"check": [
|
||||||
"dotnet",
|
"dotnet",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "Requesting an unknown customer with GET /customers/{id} returns HTTP 500. Return 404 instead, and keep known customers working.",
|
"prompt": "Requesting an unknown customer with GET /customers/{id} returns HTTP 500. Return 404 instead, and keep known customers working.",
|
||||||
"check": [
|
"check": [
|
||||||
"dotnet",
|
"dotnet",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "Back-to-back bookings, where one ends exactly when the next starts, are wrongly reported as overlapping. Fix overlaps() without breaking real overlaps.",
|
"prompt": "Back-to-back bookings, where one ends exactly when the next starts, are wrongly reported as overlapping. Fix overlaps() without breaking real overlaps.",
|
||||||
"check": [
|
"check": [
|
||||||
"node",
|
"node",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "larger implementation",
|
||||||
"prompt": "mergeConfig() drops nested settings from the base configuration when the override sets only some keys of a nested object. Merge nested objects deeply; arrays from the override replace base arrays, and neither input may be mutated.",
|
"prompt": "mergeConfig() drops nested settings from the base configuration when the override sets only some keys of a nested object. Merge nested objects deeply; arrays from the override replace base arrays, and neither input may be mutated.",
|
||||||
"check": [
|
"check": [
|
||||||
"node",
|
"node",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"category": "small change",
|
||||||
"prompt": "debounce() calls the wrapped function with the arguments of the first call in a burst instead of the last one. Fix it so the trailing call uses the latest arguments.",
|
"prompt": "debounce() calls the wrapped function with the arguments of the first call in a burst instead of the last one. Fix it so the trailing call uses the latest arguments.",
|
||||||
"check": [
|
"check": [
|
||||||
"node",
|
"node",
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ foreach ($shell in @('powershell','bash')) {
|
|||||||
$allowRulesJson = ConvertTo-Json -InputObject @('Agent', "$shellTool(mcporter call context7.*)") -Compress
|
$allowRulesJson = ConvertTo-Json -InputObject @('Agent', "$shellTool(mcporter call context7.*)") -Compress
|
||||||
$compactScript = if ($shell -eq 'powershell') { 'Record-Compact.ps1' } else { 'record-compact.sh' }
|
$compactScript = if ($shell -eq 'powershell') { 'Record-Compact.ps1' } else { 'record-compact.sh' }
|
||||||
$pointerScript = if ($shell -eq 'powershell') { 'Show-SessionStatePointer.ps1' } else { 'show-session-state-pointer.sh' }
|
$pointerScript = if ($shell -eq 'powershell') { 'Show-SessionStatePointer.ps1' } else { 'show-session-state-pointer.sh' }
|
||||||
$content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__POWERSHELL_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__POWERSHELL_HOOK_SCRIPT__', $script).Replace('__COMPACT_SCRIPT__', $compactScript).Replace('__SESSION_POINTER_SCRIPT__', $pointerScript).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript).Replace('__CLAUDE_SANDBOX__', $claudeSandbox).Replace('__CLAUDE_DENY_RULES__', $denyRulesJson).Replace('__CLAUDE_ALLOW_RULES__', $allowRulesJson)
|
$qmdWarmScript = if ($shell -eq 'powershell') { 'Start-QmdWarm.ps1' } else { 'qmd-warm.sh' }
|
||||||
|
$content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__POWERSHELL_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__POWERSHELL_HOOK_SCRIPT__', $script).Replace('__COMPACT_SCRIPT__', $compactScript).Replace('__SESSION_POINTER_SCRIPT__', $pointerScript).Replace('__QMD_WARM_SCRIPT__', $qmdWarmScript).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript).Replace('__CLAUDE_SANDBOX__', $claudeSandbox).Replace('__CLAUDE_DENY_RULES__', $denyRulesJson).Replace('__CLAUDE_ALLOW_RULES__', $allowRulesJson)
|
||||||
Set-Content -LiteralPath $path -Value $content -Encoding UTF8
|
Set-Content -LiteralPath $path -Value $content -Encoding UTF8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,12 +195,14 @@ for client in codex claude; do
|
|||||||
statusline_script=statusline.sh
|
statusline_script=statusline.sh
|
||||||
compact_script=record-compact.sh
|
compact_script=record-compact.sh
|
||||||
pointer_script=show-session-state-pointer.sh
|
pointer_script=show-session-state-pointer.sh
|
||||||
|
qmd_warm_script=qmd-warm.sh
|
||||||
if [ "$shell" = powershell ]; then
|
if [ "$shell" = powershell ]; then
|
||||||
command='__POWERSHELL_COMMAND__'
|
command='__POWERSHELL_COMMAND__'
|
||||||
script=flashbang.ps1
|
script=flashbang.ps1
|
||||||
statusline_script=statusline.ps1
|
statusline_script=statusline.ps1
|
||||||
compact_script=Record-Compact.ps1
|
compact_script=Record-Compact.ps1
|
||||||
pointer_script=Show-SessionStatePointer.ps1
|
pointer_script=Show-SessionStatePointer.ps1
|
||||||
|
qmd_warm_script=Start-QmdWarm.ps1
|
||||||
fi
|
fi
|
||||||
path="$output/$client-$shell/$file"
|
path="$output/$client-$shell/$file"
|
||||||
content=$(<"$path")
|
content=$(<"$path")
|
||||||
@@ -217,6 +219,7 @@ for client in codex claude; do
|
|||||||
content=${content//__POWERSHELL_HOOK_SCRIPT__/$script}
|
content=${content//__POWERSHELL_HOOK_SCRIPT__/$script}
|
||||||
content=${content//__COMPACT_SCRIPT__/$compact_script}
|
content=${content//__COMPACT_SCRIPT__/$compact_script}
|
||||||
content=${content//__SESSION_POINTER_SCRIPT__/$pointer_script}
|
content=${content//__SESSION_POINTER_SCRIPT__/$pointer_script}
|
||||||
|
content=${content//__QMD_WARM_SCRIPT__/$qmd_warm_script}
|
||||||
content=${content//__STATUSLINE_COMMAND__/$command}
|
content=${content//__STATUSLINE_COMMAND__/$command}
|
||||||
content=${content//__STATUSLINE_SCRIPT__/$statusline_script}
|
content=${content//__STATUSLINE_SCRIPT__/$statusline_script}
|
||||||
content=${content//__CLAUDE_SANDBOX__/$sandbox}
|
content=${content//__CLAUDE_SANDBOX__/$sandbox}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
|||||||
. (Join-Path $root 'scripts/lib/install-targets.ps1')
|
. (Join-Path $root 'scripts/lib/install-targets.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/selection-state.ps1')
|
. (Join-Path $root 'scripts/lib/selection-state.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/install-options.ps1')
|
. (Join-Path $root 'scripts/lib/install-options.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/semantic-retrieval.ps1')
|
. (Join-Path $root 'scripts/lib/qmd.ps1')
|
||||||
|
|
||||||
$Shell = Read-InstallShell -Shell $Shell
|
$Shell = Read-InstallShell -Shell $Shell
|
||||||
$shell = $Shell.ToLowerInvariant()
|
$shell = $Shell.ToLowerInvariant()
|
||||||
@@ -44,8 +44,8 @@ foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath
|
|||||||
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -ClaudeConcurrency $claudeConcurrency -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -SemanticRetrievalEnabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -ClaudeConcurrency $claudeConcurrency -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -SemanticRetrievalEnabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Sync-Qmd -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
||||||
Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected
|
Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected
|
||||||
Sync-SemanticRetrieval -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
|
||||||
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -Flashbang $options.flashbang -StatusLine $options.statusline -SemanticRetrieval $options.semantic_retrieval }
|
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -Flashbang $options.flashbang -StatusLine $options.statusline -SemanticRetrieval $options.semantic_retrieval }
|
||||||
if ($Summary) { Write-Output "Install: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) }
|
if ($Summary) { Write-Output "Install: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) }
|
||||||
exit 0
|
exit 0
|
||||||
@@ -21,7 +21,7 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
|||||||
. "$root/scripts/lib/install-targets.sh"
|
. "$root/scripts/lib/install-targets.sh"
|
||||||
. "$root/scripts/lib/selection-state.sh"
|
. "$root/scripts/lib/selection-state.sh"
|
||||||
. "$root/scripts/lib/install-options.sh"
|
. "$root/scripts/lib/install-options.sh"
|
||||||
. "$root/scripts/lib/semantic-retrieval.sh"
|
. "$root/scripts/lib/qmd.sh"
|
||||||
|
|
||||||
shell=$(read_install_shell "$shell")
|
shell=$(read_install_shell "$shell")
|
||||||
client=$(read_install_client "$client")
|
client=$(read_install_client "$client")
|
||||||
@@ -61,8 +61,8 @@ while IFS='|' read -r source destination; do
|
|||||||
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" "$claude_concurrency" "$statusline" "$semantic_retrieval"
|
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" "$claude_concurrency" "$statusline" "$semantic_retrieval"
|
||||||
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
|
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
|
||||||
|
|
||||||
|
sync_qmd "$root" "$client" "$home_path" "$semantic_retrieval" "$dry_run" false "$summary"
|
||||||
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary"
|
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary"
|
||||||
sync_semantic_retrieval "$root" "$client" "$home_path" "$semantic_retrieval" "$dry_run" false "$summary"
|
|
||||||
if [ "$dry_run" = false ]; then save_update_selection; fi
|
if [ "$dry_run" = false ]; then save_update_selection; fi
|
||||||
|
|
||||||
if [ "$summary" = true ]; then
|
if [ "$summary" = true ]; then
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
|||||||
. (Join-Path $root 'scripts/lib/plugins.ps1')
|
. (Join-Path $root 'scripts/lib/plugins.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/manifest.ps1')
|
. (Join-Path $root 'scripts/lib/manifest.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/install-targets.ps1')
|
. (Join-Path $root 'scripts/lib/install-targets.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/semantic-retrieval.ps1')
|
. (Join-Path $root 'scripts/lib/qmd.ps1')
|
||||||
|
|
||||||
$Client = Read-InstallClient -Client $Client
|
$Client = Read-InstallClient -Client $Client
|
||||||
$homePath = [Environment]::GetFolderPath('UserProfile')
|
$homePath = [Environment]::GetFolderPath('UserProfile')
|
||||||
@@ -24,7 +24,7 @@ if (-not $DryRun -and -not $Force) {
|
|||||||
if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') {
|
if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') {
|
||||||
throw 'Refusing to uninstall without confirmation in a non-interactive session. Pass -Force to proceed.'
|
throw 'Refusing to uninstall without confirmation in a non-interactive session. Pass -Force to proceed.'
|
||||||
}
|
}
|
||||||
Write-Host "This removes the managed configuration, extensions, and semantic retrieval setup for $Client under $homePath."
|
Write-Host "This removes the managed configuration, extensions, and QMD local search setup for $Client under $homePath."
|
||||||
Write-Host 'Backups already on disk are kept; anything this setup never installed is left untouched.'
|
Write-Host 'Backups already on disk are kept; anything this setup never installed is left untouched.'
|
||||||
$confirm = Read-Host -Prompt 'Continue? [y/N]'
|
$confirm = Read-Host -Prompt 'Continue? [y/N]'
|
||||||
if ($null -eq $confirm) { $confirm = '' }
|
if ($null -eq $confirm) { $confirm = '' }
|
||||||
@@ -57,7 +57,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries @()
|
Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries @()
|
||||||
Sync-SemanticRetrieval -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $false -DryRun:$DryRun -Summary:$Summary
|
Sync-Qmd -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $false -DryRun:$DryRun -Summary:$Summary
|
||||||
|
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
$remaining = @($targets | Where-Object { Test-Path -LiteralPath (Get-ManagedManifestPath $_.Destination) })
|
$remaining = @($targets | Where-Object { Test-Path -LiteralPath (Get-ManagedManifestPath $_.Destination) })
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
|||||||
. "$root/scripts/lib/plugins.sh"
|
. "$root/scripts/lib/plugins.sh"
|
||||||
. "$root/scripts/lib/manifest.sh"
|
. "$root/scripts/lib/manifest.sh"
|
||||||
. "$root/scripts/lib/install-targets.sh"
|
. "$root/scripts/lib/install-targets.sh"
|
||||||
. "$root/scripts/lib/semantic-retrieval.sh"
|
. "$root/scripts/lib/qmd.sh"
|
||||||
|
|
||||||
client=$(read_install_client "$client")
|
client=$(read_install_client "$client")
|
||||||
home_path=${HOME:?HOME is required}
|
home_path=${HOME:?HOME is required}
|
||||||
@@ -40,7 +40,7 @@ if [ "$dry_run" = false ] && [ "$force" = false ]; then
|
|||||||
printf 'Refusing to uninstall without confirmation in a non-interactive session. Pass --force to proceed.\n' >&2
|
printf 'Refusing to uninstall without confirmation in a non-interactive session. Pass --force to proceed.\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
printf 'This removes the managed configuration, extensions, and semantic retrieval setup for %s under %s.\n' "$client" "$home_path"
|
printf 'This removes the managed configuration, extensions, and QMD local search setup for %s under %s.\n' "$client" "$home_path"
|
||||||
printf 'Backups already on disk are kept; anything this setup never installed is left untouched.\n'
|
printf 'Backups already on disk are kept; anything this setup never installed is left untouched.\n'
|
||||||
read -r -p "Continue? [y/N]: " confirm || confirm=""
|
read -r -p "Continue? [y/N]: " confirm || confirm=""
|
||||||
case "$confirm" in
|
case "$confirm" in
|
||||||
@@ -75,7 +75,7 @@ done
|
|||||||
# shellcheck disable=SC2034
|
# shellcheck disable=SC2034
|
||||||
selected_plugins=()
|
selected_plugins=()
|
||||||
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary"
|
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary"
|
||||||
sync_semantic_retrieval "$root" "$client" "$home_path" false "$dry_run" false "$summary"
|
sync_qmd "$root" "$client" "$home_path" false "$dry_run" false "$summary"
|
||||||
|
|
||||||
if [ "$dry_run" = false ]; then
|
if [ "$dry_run" = false ]; then
|
||||||
remaining=0
|
remaining=0
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
|||||||
. (Join-Path $root 'scripts/lib/install-targets.ps1')
|
. (Join-Path $root 'scripts/lib/install-targets.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/selection-state.ps1')
|
. (Join-Path $root 'scripts/lib/selection-state.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/install-options.ps1')
|
. (Join-Path $root 'scripts/lib/install-options.ps1')
|
||||||
. (Join-Path $root 'scripts/lib/semantic-retrieval.ps1')
|
. (Join-Path $root 'scripts/lib/qmd.ps1')
|
||||||
|
|
||||||
$homePath = [Environment]::GetFolderPath('UserProfile')
|
$homePath = [Environment]::GetFolderPath('UserProfile')
|
||||||
if ($Quick) {
|
if ($Quick) {
|
||||||
@@ -54,6 +54,7 @@ foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath
|
|||||||
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -SemanticRetrievalEnabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -SemanticRetrievalEnabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Sync-Qmd -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Update -Summary:$Summary
|
||||||
$previousPluginNonInteractive = $script:PluginNonInteractive
|
$previousPluginNonInteractive = $script:PluginNonInteractive
|
||||||
$previousCI = $env:CI
|
$previousCI = $env:CI
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +65,6 @@ try {
|
|||||||
$script:PluginNonInteractive = $previousPluginNonInteractive
|
$script:PluginNonInteractive = $previousPluginNonInteractive
|
||||||
$env:CI = $previousCI
|
$env:CI = $previousCI
|
||||||
}
|
}
|
||||||
Sync-SemanticRetrieval -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Update -Summary:$Summary
|
|
||||||
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -Flashbang $options.flashbang -StatusLine $options.statusline -SemanticRetrieval $options.semantic_retrieval }
|
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -Flashbang $options.flashbang -StatusLine $options.statusline -SemanticRetrieval $options.semantic_retrieval }
|
||||||
if ($Summary) { Write-Output "Update: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS update dry-run' } else { 'PASS update' })) }
|
if ($Summary) { Write-Output "Update: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS update dry-run' } else { 'PASS update' })) }
|
||||||
exit 0
|
exit 0
|
||||||
@@ -23,7 +23,7 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
|||||||
. "$root/scripts/lib/install-targets.sh"
|
. "$root/scripts/lib/install-targets.sh"
|
||||||
. "$root/scripts/lib/selection-state.sh"
|
. "$root/scripts/lib/selection-state.sh"
|
||||||
. "$root/scripts/lib/install-options.sh"
|
. "$root/scripts/lib/install-options.sh"
|
||||||
. "$root/scripts/lib/semantic-retrieval.sh"
|
. "$root/scripts/lib/qmd.sh"
|
||||||
|
|
||||||
home_path=${HOME:?HOME is required}
|
home_path=${HOME:?HOME is required}
|
||||||
selected_plugins=()
|
selected_plugins=()
|
||||||
@@ -66,12 +66,12 @@ while IFS='|' read -r source destination; do
|
|||||||
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" 5 "$statusline" "$semantic_retrieval"
|
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" 5 "$statusline" "$semantic_retrieval"
|
||||||
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
|
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
|
||||||
|
|
||||||
|
sync_qmd "$root" "$client" "$home_path" "$semantic_retrieval" "$dry_run" true "$summary"
|
||||||
if [ "$quick" = true ]; then
|
if [ "$quick" = true ]; then
|
||||||
CI=true sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" true selected_plugins "$summary" </dev/null
|
CI=true sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" true selected_plugins "$summary" </dev/null
|
||||||
else
|
else
|
||||||
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" true selected_plugins "$summary"
|
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" true selected_plugins "$summary"
|
||||||
fi
|
fi
|
||||||
sync_semantic_retrieval "$root" "$client" "$home_path" "$semantic_retrieval" "$dry_run" true "$summary"
|
|
||||||
if [ "$dry_run" = false ]; then save_update_selection; fi
|
if [ "$dry_run" = false ]; then save_update_selection; fi
|
||||||
|
|
||||||
if [ "$summary" = true ]; then
|
if [ "$summary" = true ]; then
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ function Read-SemanticRetrievalOption {
|
|||||||
param([bool]$Default,[string]$RepositoryRoot,[string]$HomePath)
|
param([bool]$Default,[string]$RepositoryRoot,[string]$HomePath)
|
||||||
$hint = if ($Default) { 'Y/n/a' } else { 'y/N/a' }
|
$hint = if ($Default) { 'Y/n/a' } else { 'y/N/a' }
|
||||||
while ($true) {
|
while ($true) {
|
||||||
Write-Host "Enable Qwen3 embedding and reranking semantic retrieval? [$hint] " -NoNewline
|
Write-Host "Enable local QMD search models (Qwen3 embedding, Qwen3 reranker, QMD query expansion)? [$hint] " -NoNewline
|
||||||
$answer = [Console]::ReadLine()
|
$answer = [Console]::ReadLine()
|
||||||
if ($null -eq $answer) { throw 'Input ended before semantic retrieval was confirmed.' }
|
if ($null -eq $answer) { throw 'Input ended before semantic retrieval was confirmed.' }
|
||||||
switch ($answer.Trim().ToLowerInvariant()) {
|
switch ($answer.Trim().ToLowerInvariant()) {
|
||||||
'' { return $Default }
|
'' { return $Default }
|
||||||
{ $_ -in @('y', 'yes') } { return $true }
|
{ $_ -in @('y', 'yes') } { return $true }
|
||||||
{ $_ -in @('n', 'no') } { return $false }
|
{ $_ -in @('n', 'no') } { return $false }
|
||||||
{ $_ -in @('a', 'auto') } { return Test-SemanticRetrievalDevice -RepositoryRoot $RepositoryRoot -HomePath $HomePath }
|
{ $_ -in @('a', 'auto') } { return Test-QmdDevice -RepositoryRoot $RepositoryRoot -HomePath $HomePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,27 @@ def is_flashbang(hook):
|
|||||||
for key in ('command', 'command_windows'))
|
for key in ('command', 'command_windows'))
|
||||||
|
|
||||||
|
|
||||||
def filter_options(path, flashbang_enabled=True, statusline_enabled=True):
|
def is_qmd_warm(hook):
|
||||||
|
return any(re.search(r'(?:[/\\\s"\x27]|^)(?:qmd-warm\.sh|Start-QmdWarm\.ps1)(?:[\s"\x27]|$)', 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)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_options(path, flashbang_enabled=True, statusline_enabled=True, semantic_retrieval_enabled=True):
|
||||||
content = path.read_text(encoding='utf-8-sig')
|
content = path.read_text(encoding='utf-8-sig')
|
||||||
if path.name == 'settings.json':
|
if path.name == 'settings.json':
|
||||||
settings = json.loads(content)
|
settings = json.loads(content)
|
||||||
@@ -26,6 +46,12 @@ def filter_options(path, flashbang_enabled=True, statusline_enabled=True):
|
|||||||
settings['hooks']['Stop'] = remaining
|
settings['hooks']['Stop'] = remaining
|
||||||
elif 'Stop' in settings.get('hooks', {}):
|
elif 'Stop' in settings.get('hooks', {}):
|
||||||
del settings['hooks']['Stop']
|
del settings['hooks']['Stop']
|
||||||
|
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)
|
||||||
if not statusline_enabled:
|
if not statusline_enabled:
|
||||||
settings.pop('statusLine', None)
|
settings.pop('statusLine', None)
|
||||||
return json.dumps(settings, indent=2, ensure_ascii=False) + '\n'
|
return json.dumps(settings, indent=2, ensure_ascii=False) + '\n'
|
||||||
@@ -44,6 +70,17 @@ def filter_options(path, flashbang_enabled=True, statusline_enabled=True):
|
|||||||
filtered.append(section)
|
filtered.append(section)
|
||||||
result = ''.join(filtered)
|
result = ''.join(filtered)
|
||||||
result = re.sub(r'(?m)^\[\[hooks\.Stop\]\][ \t]*\r?\n\s*(?=\[|\Z)(?!\[\[hooks\.Stop\.hooks\]\])', '', result)
|
result = re.sub(r'(?m)^\[\[hooks\.Stop\]\][ \t]*\r?\n\s*(?=\[|\Z)(?!\[\[hooks\.Stop\.hooks\]\])', '', result)
|
||||||
|
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)
|
||||||
if not statusline_enabled:
|
if not statusline_enabled:
|
||||||
sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', result)
|
sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', result)
|
||||||
for index, section in enumerate(sections):
|
for index, section in enumerate(sections):
|
||||||
@@ -69,7 +106,7 @@ def merge_json(current, managed):
|
|||||||
for key, value in managed.items():
|
for key, value in managed.items():
|
||||||
if key == 'hooks' and isinstance(value, dict) and isinstance(result.get(key), dict):
|
if key == 'hooks' and isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||||
hooks = dict(result[key])
|
hooks = dict(result[key])
|
||||||
for event in set(value) | {'Stop'}:
|
for event in set(value) | {'Stop', 'SessionStart'}:
|
||||||
groups = value.get(event, [])
|
groups = value.get(event, [])
|
||||||
old_groups = hooks.get(event, [])
|
old_groups = hooks.get(event, [])
|
||||||
if isinstance(groups, list) and isinstance(old_groups, list):
|
if isinstance(groups, list) and isinstance(old_groups, list):
|
||||||
@@ -79,7 +116,7 @@ def merge_json(current, managed):
|
|||||||
retained.append(group)
|
retained.append(group)
|
||||||
continue
|
continue
|
||||||
foreign = [hook for hook in group['hooks'] if not (isinstance(hook, dict) and re.search(
|
foreign = [hook for hook in group['hooks'] if not (isinstance(hook, dict) and re.search(
|
||||||
r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)',
|
r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)',
|
||||||
str(hook.get('command', '')) + str(hook.get('command_windows', '')), re.I))]
|
str(hook.get('command', '')) + str(hook.get('command_windows', '')), re.I))]
|
||||||
if foreign:
|
if foreign:
|
||||||
retained.append(dict(group, hooks=foreign))
|
retained.append(dict(group, hooks=foreign))
|
||||||
@@ -128,10 +165,21 @@ def merge_toml(current_text, managed_text, statusline_enabled=True):
|
|||||||
managed_headers = {header for header, _ in managed_sections}
|
managed_headers = {header for header, _ in managed_sections}
|
||||||
extras = []
|
extras = []
|
||||||
fields_by_header = {}
|
fields_by_header = {}
|
||||||
for header, block in current_sections:
|
owned_pattern = r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)'
|
||||||
|
for index, (header, block) in enumerate(current_sections):
|
||||||
plugin_table = re.match(r'^\[(?:plugins|marketplaces)(?:\.|\])', header)
|
plugin_table = re.match(r'^\[(?:plugins|marketplaces)(?:\.|\])', header)
|
||||||
array_table = header.startswith('[[')
|
array_table = header.startswith('[[')
|
||||||
owned_hook = any(re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)', line, re.I) for line in block)
|
owned_hook = any(re.search(owned_pattern, line, re.I) for line in block)
|
||||||
|
group = re.fullmatch(r'\[\[hooks\.(\w+)\]\]', header)
|
||||||
|
if group:
|
||||||
|
# A hook group header is owned when every hook table that follows it is owned (or none follows).
|
||||||
|
child = f'[[hooks.{group.group(1)}.hooks]]'
|
||||||
|
children = []
|
||||||
|
for name, lines in current_sections[index + 1:]:
|
||||||
|
if name != child:
|
||||||
|
break
|
||||||
|
children.append(any(re.search(owned_pattern, line, re.I) for line in lines))
|
||||||
|
owned_hook = all(children)
|
||||||
if plugin_table and header in managed_headers:
|
if plugin_table and header in managed_headers:
|
||||||
raise ValueError('Cannot merge conflicting managed plugin tables.')
|
raise ValueError('Cannot merge conflicting managed plugin tables.')
|
||||||
if (header not in managed_headers and not array_table) or (array_table and not owned_hook):
|
if (header not in managed_headers and not array_table) or (array_table and not owned_hook):
|
||||||
@@ -173,17 +221,20 @@ def merge_toml(current_text, managed_text, statusline_enabled=True):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('action', choices=('filter', 'merge-json', 'merge-toml'))
|
parser.add_argument('action', choices=('filter', 'filter-instructions', 'merge-json', 'merge-toml'))
|
||||||
parser.add_argument('--path', type=Path)
|
parser.add_argument('--path', type=Path)
|
||||||
parser.add_argument('--current', type=Path)
|
parser.add_argument('--current', type=Path)
|
||||||
parser.add_argument('--flashbang', choices=('true', 'false'))
|
parser.add_argument('--flashbang', choices=('true', 'false'))
|
||||||
parser.add_argument('--statusline', choices=('true', 'false'), default='true')
|
parser.add_argument('--statusline', choices=('true', 'false'), default='true')
|
||||||
|
parser.add_argument('--semantic-retrieval', choices=('true', 'false'), default='true')
|
||||||
parser.add_argument('--managed', type=Path)
|
parser.add_argument('--managed', type=Path)
|
||||||
parser.add_argument('--output', type=Path)
|
parser.add_argument('--output', type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
if args.action == 'filter':
|
if args.action == 'filter':
|
||||||
result = filter_options(args.path, args.flashbang == 'true', args.statusline == 'true')
|
result = filter_options(args.path, args.flashbang == 'true', args.statusline == 'true', args.semantic_retrieval == 'true')
|
||||||
|
elif args.action == 'filter-instructions':
|
||||||
|
result = filter_instructions(args.path.read_text(encoding='utf-8-sig'), args.semantic_retrieval == 'true')
|
||||||
else:
|
else:
|
||||||
managed = args.managed.read_text(encoding='utf-8-sig') if args.managed else sys.stdin.buffer.read().decode('utf-8-sig')
|
managed = args.managed.read_text(encoding='utf-8-sig') if args.managed else sys.stdin.buffer.read().decode('utf-8-sig')
|
||||||
current = args.current.read_text(encoding='utf-8-sig')
|
current = args.current.read_text(encoding='utf-8-sig')
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ read_semantic_retrieval_option() {
|
|||||||
local default=$1 hint=y/N/a answer
|
local default=$1 hint=y/N/a answer
|
||||||
[ "$default" = false ] || hint=Y/n/a
|
[ "$default" = false ] || hint=Y/n/a
|
||||||
while :; do
|
while :; do
|
||||||
printf 'Enable Qwen3 embedding and reranking semantic retrieval? [%s] ' "$hint" >&2
|
printf 'Enable local QMD search models (Qwen3 embedding, Qwen3 reranker, QMD query expansion)? [%s] ' "$hint" >&2
|
||||||
IFS= read -r answer || { printf 'Input ended before semantic retrieval was confirmed.\n' >&2; return 1; }
|
IFS= read -r answer || { printf 'Input ended before semantic retrieval was confirmed.\n' >&2; return 1; }
|
||||||
answer=${answer%$'\r'}
|
answer=${answer%$'\r'}
|
||||||
case "$answer" in
|
case "$answer" in
|
||||||
@@ -49,7 +49,7 @@ read_semantic_retrieval_option() {
|
|||||||
y|Y|yes|Yes|YES) printf 'true\n'; return ;;
|
y|Y|yes|Yes|YES) printf 'true\n'; return ;;
|
||||||
n|N|no|No|NO) printf 'false\n'; return ;;
|
n|N|no|No|NO) printf 'false\n'; return ;;
|
||||||
a|A|auto|Auto|AUTO)
|
a|A|auto|Auto|AUTO)
|
||||||
if test_semantic_retrieval_device "$root" "$home_path"; then printf 'true\n'; return; else status=$?; fi
|
if test_qmd_device "$root" "$home_path"; then printf 'true\n'; return; else status=$?; fi
|
||||||
[ "$status" -eq 1 ] && { printf 'false\n'; return; }
|
[ "$status" -eq 1 ] && { printf 'false\n'; return; }
|
||||||
return "$status"
|
return "$status"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -304,9 +304,6 @@ class Manager:
|
|||||||
if entry['codex_method'] == 'cli':
|
if entry['codex_method'] == 'cli':
|
||||||
self.ensure_cli(client, entry)
|
self.ensure_cli(client, entry)
|
||||||
return
|
return
|
||||||
if entry['codex_method'] == 'qmd':
|
|
||||||
self.ensure_qmd(client, entry)
|
|
||||||
return
|
|
||||||
skill = client == 'codex' and entry['codex_method'] != 'plugin'
|
skill = client == 'codex' and entry['codex_method'] != 'plugin'
|
||||||
kind = 'skill' if skill else 'plugin'
|
kind = 'skill' if skill else 'plugin'
|
||||||
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None)
|
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None)
|
||||||
@@ -417,28 +414,6 @@ class Manager:
|
|||||||
atomic_write(path, (json.dumps(content, indent=2) + '\n').encode('utf-8'))
|
atomic_write(path, (json.dumps(content, indent=2) + '\n').encode('utf-8'))
|
||||||
print('Repaired Caveman Windows hook commands; review the changed hooks in Codex /hooks.')
|
print('Repaired Caveman Windows hook commands; review the changed hooks in Codex /hooks.')
|
||||||
|
|
||||||
def ensure_qmd(self, client, entry):
|
|
||||||
package = entry['codex_source']
|
|
||||||
if package != '@tobilu/qmd':
|
|
||||||
raise ValueError('Invalid QMD package.')
|
|
||||||
record = next((r for r in self.state['resources'] if r['client'] == 'shared' and r['name'] == entry['name']), None)
|
|
||||||
installed = package in self.global_packages()
|
|
||||||
if record is not None:
|
|
||||||
record['clients'] = sorted(set(record.get('clients', ['claude', 'codex'])) | {client})
|
|
||||||
self.save()
|
|
||||||
if record is None and installed and not self.summary:
|
|
||||||
print(f'PASS Preserving pre-existing global package: {package}')
|
|
||||||
if (record is not None or not installed) and (not installed or (self.update and package not in self.updated_packages)):
|
|
||||||
self.command(['npm', 'update' if installed else 'install', '--global', package])
|
|
||||||
self.global_packages().add(package)
|
|
||||||
self.updated_packages.add(package)
|
|
||||||
if record is None:
|
|
||||||
self.state['resources'].append(dict(client='shared', name=entry['name'], kind='qmd', package=package, clients=[client]))
|
|
||||||
self.save()
|
|
||||||
if client == 'claude':
|
|
||||||
entry = dict(entry, codex_method='plugin')
|
|
||||||
self.ensure(client, entry)
|
|
||||||
|
|
||||||
def ensure_mcporter(self, entry, clients):
|
def ensure_mcporter(self, entry, clients):
|
||||||
server, url = entry['mcporter_name'], entry['mcporter_url']
|
server, url = entry['mcporter_name'], entry['mcporter_url']
|
||||||
if not re.fullmatch(r'[a-z0-9-]+', server) or not re.fullmatch(r'https://[^\s]+', url):
|
if not re.fullmatch(r'[a-z0-9-]+', server) or not re.fullmatch(r'https://[^\s]+', url):
|
||||||
|
|||||||
@@ -227,13 +227,17 @@ function Invoke-ManagedSync {
|
|||||||
if (-not $SemanticRetrievalEnabled -and $relative -match '(^|/)semantic-search/') { return }
|
if (-not $SemanticRetrievalEnabled -and $relative -match '(^|/)semantic-search/') { return }
|
||||||
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
|
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
|
||||||
$rawContent = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8
|
$rawContent = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8
|
||||||
$filterOptions = ((-not $FlashbangEnabled) -or (-not $StatusLineEnabled)) -and $relative -in @('settings.json', 'config.toml')
|
$filterOptions = ((-not $FlashbangEnabled) -or (-not $StatusLineEnabled) -or (-not $SemanticRetrievalEnabled)) -and $relative -in @('settings.json', 'config.toml')
|
||||||
if ($filterOptions) {
|
if ($filterOptions) {
|
||||||
$flashbangOption = if ($FlashbangEnabled) { 'true' } else { 'false' }
|
$flashbangOption = if ($FlashbangEnabled) { 'true' } else { 'false' }
|
||||||
$statusLineOption = if ($StatusLineEnabled) { 'true' } else { 'false' }
|
$statusLineOption = if ($StatusLineEnabled) { 'true' } else { 'false' }
|
||||||
$rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption) -ErrorMessage 'Could not configure install options.'
|
$rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption, '--semantic-retrieval', $(if ($SemanticRetrievalEnabled) { 'true' } else { 'false' })) -ErrorMessage 'Could not configure install options.'
|
||||||
}
|
}
|
||||||
$needsSubstitution = $filterOptions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__')
|
$filterInstructions = (-not $SemanticRetrievalEnabled) -and $relative -in @('CLAUDE.md', 'AGENTS.md', 'rules/general.md')
|
||||||
|
if ($filterInstructions) {
|
||||||
|
$rawContent = Invoke-InstallOptions -Arguments @('filter-instructions', '--path', $_.FullName, '--semantic-retrieval', 'false') -ErrorMessage 'Could not configure install options.'
|
||||||
|
}
|
||||||
|
$needsSubstitution = $filterOptions -or $filterInstructions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__')
|
||||||
if ($needsSubstitution) {
|
if ($needsSubstitution) {
|
||||||
$finalContent = $rawContent.Replace('__AI_CONFIG_ROOT__', $AiConfigRoot).Replace('__HOOK_COMMAND__', $ShellCommand).Replace('__POWERSHELL_HOOK_COMMAND__', $PowerShellCommand).Replace('__POWERSHELL_COMMAND__', $PowerShellCommand).Replace('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__', $ClaudeConcurrency).Replace('__HOOK_SCRIPT__', 'flashbang.ps1').Replace('__POWERSHELL_HOOK_SCRIPT__', 'flashbang.ps1')
|
$finalContent = $rawContent.Replace('__AI_CONFIG_ROOT__', $AiConfigRoot).Replace('__HOOK_COMMAND__', $ShellCommand).Replace('__POWERSHELL_HOOK_COMMAND__', $PowerShellCommand).Replace('__POWERSHELL_COMMAND__', $PowerShellCommand).Replace('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__', $ClaudeConcurrency).Replace('__HOOK_SCRIPT__', 'flashbang.ps1').Replace('__POWERSHELL_HOOK_SCRIPT__', 'flashbang.ps1')
|
||||||
$newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent)
|
$newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent)
|
||||||
|
|||||||
@@ -210,8 +210,13 @@ sys.stdout.buffer.write(("\n".join(lines) + "\n").encode("utf-8"))
|
|||||||
content=$(cat -- "$source_file" && printf '\034') || return
|
content=$(cat -- "$source_file" && printf '\034') || return
|
||||||
content=${content%$'\034'}
|
content=${content%$'\034'}
|
||||||
needs_sub=false
|
needs_sub=false
|
||||||
if { [ "$flashbang_enabled" = false ] || [ "$statusline_enabled" = false ]; } && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then
|
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" && printf '\034') || return
|
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 || "$relative" = rules/general.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'}
|
content=${content%$'\034'}
|
||||||
needs_sub=true
|
needs_sub=true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -125,9 +125,7 @@ function Select-ConfiguredPlugins {
|
|||||||
} else { $false }
|
} else { $false }
|
||||||
foreach ($referenceClient in $referenceClients) {
|
foreach ($referenceClient in $referenceClients) {
|
||||||
$selector = if ($referenceClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin }
|
$selector = if ($referenceClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin }
|
||||||
if ($referenceClient -eq 'Codex' -and $entry.codex_method -eq 'qmd') {
|
if ($referenceClient -eq 'Codex' -and $entry.codex_method -ne 'plugin') {
|
||||||
$found = $found -or ($null -ne (Get-Command qmd -ErrorAction SilentlyContinue))
|
|
||||||
} elseif ($referenceClient -eq 'Codex' -and $entry.codex_method -ne 'plugin') {
|
|
||||||
$found = $found -or (Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $entry.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $entry.codex_skill)))
|
$found = $found -or (Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $entry.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $entry.codex_skill)))
|
||||||
} else {
|
} else {
|
||||||
$idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' }
|
$idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' }
|
||||||
|
|||||||
@@ -161,9 +161,7 @@ select_configured_plugins() {
|
|||||||
fi
|
fi
|
||||||
for reference_client in "${reference_clients[@]}"; do
|
for reference_client in "${reference_clients[@]}"; do
|
||||||
if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi
|
if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi
|
||||||
if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" = qmd ]; then
|
if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
|
||||||
command -v qmd >/dev/null 2>&1 && found=true
|
|
||||||
elif [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
|
|
||||||
local skill_name
|
local skill_name
|
||||||
skill_name=$(plugin_field "$root" "$name" codex_skill)
|
skill_name=$(plugin_field "$root" "$name" codex_skill)
|
||||||
{ [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; } && found=true
|
{ [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; } && found=true
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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)
|
||||||
|
$arguments = @((Join-Path $PSScriptRoot 'qmd.py'), 'benchmark', '--root', $RepositoryRoot, '--home', $HomePath)
|
||||||
|
if ($Summary) { $arguments += '--summary' }
|
||||||
|
& python @arguments | Write-Host
|
||||||
|
if ($LASTEXITCODE -eq 0) { return $true }
|
||||||
|
if ($LASTEXITCODE -eq 2) { return $false }
|
||||||
|
throw 'QMD benchmark failed.'
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""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
|
||||||
|
import time
|
||||||
|
|
||||||
|
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, check=True):
|
||||||
|
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 check and result.returncode != 0:
|
||||||
|
raise ValueError(f'{" ".join(arguments[:3])} failed: {(result.stderr or result.stdout).strip()[-300:]}')
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def process_alive(pid):
|
||||||
|
if WINDOWS: # os.kill(pid, 0) would send CTRL_C_EVENT on Windows
|
||||||
|
import ctypes
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
||||||
|
if not handle:
|
||||||
|
return False
|
||||||
|
code = ctypes.c_ulong()
|
||||||
|
try:
|
||||||
|
return bool(kernel32.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(handle)
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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 = Path(os.environ.get('XDG_CACHE_HOME') or 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, 'previous_models': None, '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):
|
||||||
|
# npm list exits non-zero on tree problems (ELSPROBLEMS) but still prints valid JSON.
|
||||||
|
output = self.run(['npm', 'list', '--global', '--depth=0', '--json'], check=False)
|
||||||
|
try:
|
||||||
|
listing = json.loads(output)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise ValueError('npm list did not return JSON.')
|
||||||
|
if not isinstance(listing, dict) or not isinstance(listing.get('dependencies'), dict):
|
||||||
|
raise ValueError('npm list did not report the global packages.')
|
||||||
|
return PACKAGE in listing['dependencies']
|
||||||
|
|
||||||
|
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 stop_legacy_daemon(self, legacy):
|
||||||
|
"""Ask the old Python daemon to exit so its files can be deleted (Windows keeps open files locked)."""
|
||||||
|
python = legacy / '.venv' / ('Scripts/python.exe' if WINDOWS else 'bin/python')
|
||||||
|
server = legacy / 'server.py'
|
||||||
|
if python.is_file() and server.is_file():
|
||||||
|
try:
|
||||||
|
self.run([str(python), str(server), 'stop', '--data-dir', str(legacy / 'data'),
|
||||||
|
'--model-cache', str(legacy / 'model-cache')], timeout=60, check=False)
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def migrate(self, state=None):
|
||||||
|
state = self.load() if state is None else 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.')
|
||||||
|
if legacy.exists():
|
||||||
|
self.stop_legacy_daemon(legacy)
|
||||||
|
shutil.rmtree(legacy)
|
||||||
|
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:
|
||||||
|
state['owned_package'] = True
|
||||||
|
atomic_json(self.state_path, state) # record ownership before the ledger forgets it
|
||||||
|
ledger['resources'] = [r for r in ledger['resources'] if r.get('kind') != 'qmd']
|
||||||
|
atomic_json(ledger_path, ledger)
|
||||||
|
|
||||||
|
def install_scripts(self, state):
|
||||||
|
if self.scripts.is_symlink():
|
||||||
|
raise ValueError('QMD script directory must not be a symbolic link.')
|
||||||
|
self.scripts.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name in SCRIPTS:
|
||||||
|
target = self.scripts / name
|
||||||
|
if target.is_symlink():
|
||||||
|
raise ValueError(f'QMD script must not be a symbolic link: {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']:
|
||||||
|
output = self.node('qmd-config.mjs', 'set-models')
|
||||||
|
try:
|
||||||
|
previous = json.loads(output.strip().splitlines()[-1])['previous']
|
||||||
|
except (IndexError, KeyError, TypeError, json.JSONDecodeError):
|
||||||
|
raise ValueError('qmd-config set-models returned no previous models.')
|
||||||
|
state['previous_models'] = previous
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}')
|
||||||
|
gpu = found.get('gpu') or False
|
||||||
|
except Exception as error: # any probe failure means: use the CPU
|
||||||
|
self.say(f'QMD GPU detection failed, using the CPU: {str(error)[:200]}')
|
||||||
|
return False
|
||||||
|
names = ', '.join(found.get('devices') or []) or 'unnamed device'
|
||||||
|
self.say(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):
|
||||||
|
daemon = self.scripts / 'daemon.json'
|
||||||
|
try:
|
||||||
|
pid = json.loads(daemon.read_text(encoding='utf-8')).get('pid') if daemon.is_file() else None
|
||||||
|
except (OSError, ValueError, AttributeError):
|
||||||
|
pid = None
|
||||||
|
try:
|
||||||
|
if self.scripts.joinpath('qmd-search.mjs').exists():
|
||||||
|
self.node('qmd-search.mjs', '--stop', timeout=60)
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
if isinstance(pid, int) and pid > 0:
|
||||||
|
deadline = time.monotonic() + 10 # the daemon must release the model files and the package
|
||||||
|
while process_alive(pid) and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.2)
|
||||||
|
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, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
if state['models_block'] and self.scripts.joinpath('qmd-config.mjs').exists():
|
||||||
|
previous = state.get('previous_models')
|
||||||
|
try:
|
||||||
|
if previous is not None:
|
||||||
|
self.node('qmd-config.mjs', 'restore-models', json.dumps(previous))
|
||||||
|
else:
|
||||||
|
self.node('qmd-config.mjs', 'unset-models')
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError) as error:
|
||||||
|
print(f'QMD local search: could not restore the models block in index.yml: {error}', file=sys.stderr)
|
||||||
|
kept = []
|
||||||
|
for name in state['owned_models']:
|
||||||
|
path = self.models / name
|
||||||
|
if name == Path(name).name and path.is_file() and not path.is_symlink():
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError as error:
|
||||||
|
print(f'QMD local search: could not delete model {name}; the next removal retries it: {error}', file=sys.stderr)
|
||||||
|
kept.append(name)
|
||||||
|
if state['owned_package']:
|
||||||
|
self.run(['npm', 'uninstall', '--global', PACKAGE])
|
||||||
|
if self.scripts.exists() and not self.scripts.is_symlink():
|
||||||
|
shutil.rmtree(self.scripts)
|
||||||
|
if kept:
|
||||||
|
state.update(clients=[], owned_models=kept, owned_package=False, models_block=False, previous_models=None, files={})
|
||||||
|
atomic_json(self.state_path, state)
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
if state['owned_package']:
|
||||||
|
self.run(['npm', 'uninstall', '--global', PACKAGE])
|
||||||
|
self.state_path.unlink(missing_ok=True)
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
self.install(state)
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||||
|
print(f'QMD local search is not suitable for this computer: {error}')
|
||||||
|
try:
|
||||||
|
self.remove_all(state)
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as cleanup:
|
||||||
|
print(f'QMD cleanup was incomplete: {cleanup}')
|
||||||
|
return False
|
||||||
|
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))
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ValueError('benchmark returned a non-object result')
|
||||||
|
except (ValueError, subprocess.SubprocessError) 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)
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
sync_semantic_retrieval() {
|
sync_qmd() {
|
||||||
local root=$1 client=$2 home_path=$3 enabled=$4 dry_run=$5 update=$6 summary=${7:-false}
|
local root=$1 client=$2 home_path=$3 enabled=$4 dry_run=$5 update=$6 summary=${7:-false}
|
||||||
[ "$enabled" = true ] || [ -f "$home_path/.my-ai-configuration/semantic-retrieval.json" ] || return 0
|
local arguments=("$root/scripts/lib/qmd.py" sync --root "$root" --home "$home_path" --client "$client" --enabled "$enabled")
|
||||||
local arguments=("$root/scripts/lib/semantic-retrieval.py" sync --root "$root" --home "$home_path" --client "$client" --enabled "$enabled")
|
|
||||||
[ "$dry_run" != true ] || arguments+=(--dry-run)
|
[ "$dry_run" != true ] || arguments+=(--dry-run)
|
||||||
[ "$update" != true ] || arguments+=(--update)
|
[ "$update" != true ] || arguments+=(--update)
|
||||||
[ "$summary" != true ] || arguments+=(--summary)
|
[ "$summary" != true ] || arguments+=(--summary)
|
||||||
python3 "${arguments[@]}"
|
python3 "${arguments[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
test_semantic_retrieval_device() {
|
test_qmd_device() {
|
||||||
local root=$1 home_path=$2 summary=${3:-false}
|
local root=$1 home_path=$2 summary=${3:-false}
|
||||||
local arguments=("$root/scripts/lib/semantic-retrieval.py" benchmark --root "$root" --home "$home_path")
|
local arguments=("$root/scripts/lib/qmd.py" benchmark --root "$root" --home "$home_path")
|
||||||
[ "$summary" != true ] || arguments+=(--summary)
|
[ "$summary" != true ] || arguments+=(--summary)
|
||||||
local status
|
local status
|
||||||
if python3 "${arguments[@]}" >&2; then return 0; else status=$?; fi
|
if python3 "${arguments[@]}" >&2; then return 0; else status=$?; fi
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
function Sync-SemanticRetrieval {
|
|
||||||
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)
|
|
||||||
$statePath = Join-Path $HomePath '.my-ai-configuration/semantic-retrieval.json'
|
|
||||||
if (-not $Enabled -and -not (Test-Path -LiteralPath $statePath)) { return }
|
|
||||||
$arguments = @((Join-Path $PSScriptRoot 'semantic-retrieval.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 'Semantic retrieval reconciliation failed.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-SemanticRetrievalDevice {
|
|
||||||
param([Parameter(Mandatory=$true)][string]$RepositoryRoot,[Parameter(Mandatory=$true)][string]$HomePath,[switch]$Summary)
|
|
||||||
& python (Join-Path $PSScriptRoot 'semantic-retrieval.py') benchmark --root $RepositoryRoot --home $HomePath $(if ($Summary) { '--summary' }) | Write-Host
|
|
||||||
if ($LASTEXITCODE -eq 0) { return $true }
|
|
||||||
if ($LASTEXITCODE -eq 2) { return $false }
|
|
||||||
throw 'Semantic retrieval benchmark failed.'
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
|
|
||||||
FILES = ('server.py', 'benchmark.py', 'requirements.txt')
|
|
||||||
CUDA_INDEX = 'https://download.pytorch.org/whl/cu126'
|
|
||||||
MODEL_PRELOAD_TIMEOUT = 900
|
|
||||||
|
|
||||||
|
|
||||||
def digest(path):
|
|
||||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def python_path(target):
|
|
||||||
return target / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python')
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_runtime(target, dry_run):
|
|
||||||
if dry_run:
|
|
||||||
return python_path(target / '.venv')
|
|
||||||
venv = target / '.venv'
|
|
||||||
if not venv.exists():
|
|
||||||
subprocess.run([sys.executable, '-m', 'venv', str(venv)], check=True, timeout=180)
|
|
||||||
python = python_path(venv)
|
|
||||||
if not python.is_file():
|
|
||||||
raise ValueError('Semantic retrieval virtual environment is incomplete.')
|
|
||||||
arguments = [str(python), '-m', 'pip', 'install', '--disable-pip-version-check', '-r', str(target / 'requirements.txt')]
|
|
||||||
if shutil.which('nvidia-smi'):
|
|
||||||
try:
|
|
||||||
subprocess.run([*arguments, '--extra-index-url', CUDA_INDEX], check=True, timeout=900)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
print('Semantic retrieval: CUDA runtime installation failed; falling back to CPU.', file=sys.stderr)
|
|
||||||
subprocess.run(arguments, check=True, timeout=900)
|
|
||||||
else:
|
|
||||||
subprocess.run(arguments, check=True, timeout=900)
|
|
||||||
return python
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_models(python, target):
|
|
||||||
subprocess.run([str(python), str(target / 'server.py'), 'preload', '--model-cache', str(target / 'model-cache')],
|
|
||||||
check=True, timeout=MODEL_PRELOAD_TIMEOUT)
|
|
||||||
|
|
||||||
|
|
||||||
def load_state(path):
|
|
||||||
if not path.exists():
|
|
||||||
return {'version': 1, 'clients': [], 'files': {}}
|
|
||||||
state = json.loads(path.read_text(encoding='utf-8'))
|
|
||||||
if state.get('version') != 1 or not isinstance(state.get('clients'), list) or not isinstance(state.get('files'), dict):
|
|
||||||
raise ValueError('Invalid semantic retrieval ownership state.')
|
|
||||||
if any(client not in ('codex', 'claude') for client in state['clients']):
|
|
||||||
raise ValueError('Invalid semantic retrieval client state.')
|
|
||||||
if any(name not in FILES or not isinstance(value, str) or len(value) != 64 or any(character not in '0123456789abcdef' for character in value) for name, value in state['files'].items()):
|
|
||||||
raise ValueError('Invalid semantic retrieval file state.')
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def save_state(path, state, dry_run):
|
|
||||||
if not dry_run:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
temporary = None
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, delete=False) as stream:
|
|
||||||
temporary = Path(stream.name)
|
|
||||||
json.dump(state, stream, indent=2)
|
|
||||||
stream.write('\n')
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
os.replace(temporary, path)
|
|
||||||
finally:
|
|
||||||
if temporary is not None:
|
|
||||||
temporary.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def check_runtime(target, state):
|
|
||||||
if target.is_symlink():
|
|
||||||
raise ValueError('Semantic retrieval directory must not be a symbolic link.')
|
|
||||||
if not target.exists():
|
|
||||||
return
|
|
||||||
if not target.is_dir():
|
|
||||||
raise ValueError('Semantic retrieval target is not a directory.')
|
|
||||||
for name, expected in state['files'].items():
|
|
||||||
path = target / name
|
|
||||||
if path.is_symlink() or not path.is_file() or digest(path) != expected:
|
|
||||||
raise ValueError(f'Owned semantic retrieval file changed: {name}')
|
|
||||||
allowed = set(state['files']) | {'.venv', 'model-cache', '__pycache__', 'data'}
|
|
||||||
for path in target.iterdir():
|
|
||||||
if path.is_symlink() or path.name not in allowed:
|
|
||||||
raise ValueError(f'Foreign or linked semantic retrieval path: {path.name}')
|
|
||||||
if path.name == 'data' and (not path.is_dir() or any(
|
|
||||||
child.is_symlink() or not (
|
|
||||||
child.is_file() and (child.name in ('daemon.key', 'daemon.starting') or re.fullmatch(
|
|
||||||
r'[0-9a-f]{64}\.sqlite3(?:-(?:wal|shm|journal))?|daemon-[0-9a-f]{12}\.starting', child.name))
|
|
||||||
or child.is_socket() and re.fullmatch(r'daemon-[0-9a-f]{16}\.sock', child.name))
|
|
||||||
for child in path.iterdir())):
|
|
||||||
raise ValueError('Foreign or linked semantic retrieval path: data')
|
|
||||||
if path.name == '__pycache__' and (not path.is_dir() or any(
|
|
||||||
child.is_symlink() or not child.is_file() or not re.fullmatch(
|
|
||||||
r'(?:server|benchmark)\.[A-Za-z0-9_-]+(?:\.opt-\d+)?\.pyc', child.name)
|
|
||||||
for child in path.iterdir())):
|
|
||||||
raise ValueError('Foreign or linked semantic retrieval path: __pycache__')
|
|
||||||
|
|
||||||
|
|
||||||
def sync(root, home, clients, enabled, dry_run, update, summary=False):
|
|
||||||
base = home / '.my-ai-configuration'
|
|
||||||
target = base / 'semantic-retrieval'
|
|
||||||
state_path = base / 'semantic-retrieval.json'
|
|
||||||
state = load_state(state_path)
|
|
||||||
clients = list(dict.fromkeys(clients))
|
|
||||||
if base.exists() and base.is_symlink():
|
|
||||||
raise ValueError('Semantic retrieval state directory must not be a symbolic link.')
|
|
||||||
if not enabled:
|
|
||||||
owned = [client for client in state['clients'] if client in clients]
|
|
||||||
if dry_run:
|
|
||||||
if not summary:
|
|
||||||
print('DRYRUN disable semantic retrieval: ' + (', '.join(owned) if owned else 'nothing owned'))
|
|
||||||
return
|
|
||||||
state['clients'] = [client for client in state['clients'] if client not in clients]
|
|
||||||
if state['clients']:
|
|
||||||
save_state(state_path, state, False)
|
|
||||||
return
|
|
||||||
if not state['files']:
|
|
||||||
state_path.unlink(missing_ok=True)
|
|
||||||
return
|
|
||||||
check_runtime(target, state)
|
|
||||||
if target.exists() and target.is_dir():
|
|
||||||
shutil.rmtree(target)
|
|
||||||
state_path.unlink(missing_ok=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
check_runtime(target, state)
|
|
||||||
if target.exists() and not state['files']:
|
|
||||||
raise ValueError('Semantic retrieval directory exists without setup ownership; refusing to overwrite it.')
|
|
||||||
if dry_run:
|
|
||||||
if not summary:
|
|
||||||
print('DRYRUN enable semantic retrieval: Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B')
|
|
||||||
return
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
source = root / 'shared' / 'retrieval'
|
|
||||||
for name in FILES:
|
|
||||||
shutil.copy2(source / name, target / name)
|
|
||||||
state['files'][name] = digest(target / name)
|
|
||||||
save_state(state_path, state, False)
|
|
||||||
python = ensure_runtime(target, False)
|
|
||||||
ensure_models(python, target)
|
|
||||||
# Sandboxed clients may write only to data/; it cannot be created from inside the sandbox.
|
|
||||||
(target / 'data').mkdir(exist_ok=True)
|
|
||||||
state['clients'] = list(dict.fromkeys(state['clients']))
|
|
||||||
save_state(state_path, state, False)
|
|
||||||
for client in clients:
|
|
||||||
if client not in state['clients']:
|
|
||||||
state['clients'].append(client)
|
|
||||||
save_state(state_path, state, False)
|
|
||||||
save_state(state_path, state, False)
|
|
||||||
|
|
||||||
|
|
||||||
def benchmark(root, home, dry_run, summary=False):
|
|
||||||
base = home / '.my-ai-configuration'
|
|
||||||
target = base / 'semantic-retrieval'
|
|
||||||
state_path = base / 'semantic-retrieval.json'
|
|
||||||
if dry_run:
|
|
||||||
if not summary:
|
|
||||||
print('DRYRUN benchmark semantic retrieval: Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B')
|
|
||||||
return True
|
|
||||||
state = load_state(state_path)
|
|
||||||
source = root / 'shared' / 'retrieval'
|
|
||||||
if state['files']:
|
|
||||||
check_runtime(target, state)
|
|
||||||
elif target.is_symlink():
|
|
||||||
raise ValueError('Semantic retrieval directory must not be a symbolic link.')
|
|
||||||
if target.exists() and not state['files']:
|
|
||||||
if not all((target / name).is_file() and digest(target / name) == digest(source / name) for name in FILES):
|
|
||||||
raise ValueError('Semantic retrieval directory exists without setup ownership; refusing to overwrite it.')
|
|
||||||
state['files'] = {name: digest(target / name) for name in FILES}
|
|
||||||
check_runtime(target, state)
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
for name in FILES:
|
|
||||||
shutil.copy2(source / name, target / name)
|
|
||||||
state['files'][name] = digest(target / name)
|
|
||||||
python = ensure_runtime(target, False)
|
|
||||||
result = subprocess.run([str(python), str(target / 'benchmark.py'), '--model-cache', str(target / 'model-cache')], check=True,
|
|
||||||
stdout=subprocess.PIPE, text=True, timeout=300)
|
|
||||||
outcome = json.loads(result.stdout)
|
|
||||||
if not isinstance(outcome, dict) or not isinstance(outcome.get('recommended'), bool):
|
|
||||||
raise ValueError('Semantic retrieval benchmark returned an invalid result.')
|
|
||||||
if outcome['recommended'] or state['clients']:
|
|
||||||
save_state(state_path, state, False)
|
|
||||||
else:
|
|
||||||
check_runtime(target, state)
|
|
||||||
shutil.rmtree(target)
|
|
||||||
state_path.unlink(missing_ok=True)
|
|
||||||
if not summary:
|
|
||||||
print('Semantic retrieval warm-model check: ' + ('passed' if outcome['recommended'] else 'failed') + f" (embedding {outcome['embeddingSeconds']}s, reranking {outcome['rerankingSeconds']}s; excludes model startup and indexing)")
|
|
||||||
return outcome['recommended']
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
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()
|
|
||||||
if args.action == 'benchmark':
|
|
||||||
recommended = benchmark(args.root.resolve(), args.home.resolve(), args.dry_run, args.summary)
|
|
||||||
raise SystemExit(0 if recommended 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,)
|
|
||||||
sync(args.root.resolve(), args.home.resolve(), clients, args.enabled == 'true', args.dry_run, args.update, args.summary)
|
|
||||||
print('SEMANTIC RETRIEVAL: reconciliation complete' + (' (dry run)' if args.dry_run else ''))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
|
||||||
print(f'Semantic retrieval: {error}', file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
// 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;
|
||||||
@@ -17,6 +17,22 @@ try {
|
|||||||
if (Test-AllManifestsPresent @($installed, $missing)) { throw 'Update guard must fail when any selected destination is not installed.' }
|
if (Test-AllManifestsPresent @($installed, $missing)) { throw 'Update guard must fail when any selected destination is not installed.' }
|
||||||
if (-not (Test-AllManifestsPresent @($installed))) { throw 'Update guard must pass when every selected destination is installed.' }
|
if (-not (Test-AllManifestsPresent @($installed))) { throw 'Update guard must pass when every selected destination is installed.' }
|
||||||
|
|
||||||
|
# QMD prompt: Auto maps device suitability; No never calls the benchmark.
|
||||||
|
. (Join-Path $root 'scripts/lib/install-options.ps1')
|
||||||
|
$originalIn = [Console]::In
|
||||||
|
try {
|
||||||
|
function Test-QmdDevice { param($RepositoryRoot, $HomePath) return $false }
|
||||||
|
[Console]::SetIn([IO.StringReader]::new("a`n"))
|
||||||
|
if ((Read-SemanticRetrievalOption -Default $false -RepositoryRoot $root -HomePath $work) -ne $false) { throw 'Auto unsuitable must yield false' }
|
||||||
|
function Test-QmdDevice { param($RepositoryRoot, $HomePath) return $true }
|
||||||
|
[Console]::SetIn([IO.StringReader]::new("a`n"))
|
||||||
|
if ((Read-SemanticRetrievalOption -Default $false -RepositoryRoot $root -HomePath $work) -ne $true) { throw 'Auto suitable must yield true' }
|
||||||
|
function Test-QmdDevice { throw 'must not run' }
|
||||||
|
[Console]::SetIn([IO.StringReader]::new("n`n"))
|
||||||
|
if ((Read-SemanticRetrievalOption -Default $true -RepositoryRoot $root -HomePath $work) -ne $false) { throw 'No must yield false' }
|
||||||
|
} finally { [Console]::SetIn($originalIn) }
|
||||||
|
if ((Get-Content -Raw (Join-Path $root 'scripts/lib/install-options.ps1')) -notmatch 'Enable local QMD search models \(Qwen3 embedding, Qwen3 reranker, QMD query expansion\)\? \[') { throw 'prompt text changed' }
|
||||||
|
|
||||||
if ($Summary) { Write-Output 'Tests: PASS | install guards' } else { Write-Output 'PASS install guards: already-installed and not-installed detection' }
|
if ($Summary) { Write-Output 'Tests: PASS | install guards' } else { Write-Output 'PASS install guards: already-installed and not-installed detection' }
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|||||||
@@ -25,4 +25,18 @@ any_manifest_present "$installed" "$missing" || { printf 'Expected an installed
|
|||||||
! all_manifests_present "$installed" "$missing" || { printf 'Update guard must fail when any selected destination is not installed.\n' >&2; exit 1; }
|
! all_manifests_present "$installed" "$missing" || { printf 'Update guard must fail when any selected destination is not installed.\n' >&2; exit 1; }
|
||||||
all_manifests_present "$installed" || { printf 'Update guard must pass when every selected destination is installed.\n' >&2; exit 1; }
|
all_manifests_present "$installed" || { printf 'Update guard must pass when every selected destination is installed.\n' >&2; exit 1; }
|
||||||
|
|
||||||
|
# QMD prompt: Auto maps benchmark exit codes; No never calls the benchmark.
|
||||||
|
(
|
||||||
|
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; }
|
||||||
|
rm -rf -- "$home_path"
|
||||||
|
)
|
||||||
|
|
||||||
if [ "$summary" = true ]; then printf 'Tests: PASS | install guards\n'; else printf 'PASS install guards: already-installed and not-installed detection\n'; fi
|
if [ "$summary" = true ]; then printf 'Tests: PASS | install guards\n'; else printf 'PASS install guards: already-installed and not-installed detection\n'; fi
|
||||||
@@ -73,6 +73,69 @@ class InstallOptionsTests(unittest.TestCase):
|
|||||||
subprocess.run([sys.executable, script, 'merge-toml', '--current', str(current), '--managed', str(managed_path), '--output', str(output)], check=True)
|
subprocess.run([sys.executable, script, 'merge-toml', '--current', str(current), '--managed', str(managed_path), '--output', str(output)], check=True)
|
||||||
self.assertIn('# Größe', output.read_text(encoding='utf-8'))
|
self.assertIn('# Größe', output.read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
def test_semantic_retrieval_disabled_removes_qmd_session_hook(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
settings = Path(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(OPTIONS.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):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Path(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 = OPTIONS.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(OPTIONS.filter_instructions(text, False), 'Line one.\nLine three.\n')
|
||||||
|
self.assertEqual(OPTIONS.filter_instructions(text, True), text)
|
||||||
|
|
||||||
|
def test_qmd_warm_match_is_anchored(self):
|
||||||
|
self.assertTrue(OPTIONS.is_qmd_warm({'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'}))
|
||||||
|
self.assertTrue(OPTIONS.is_qmd_warm({'command_windows': 'pwsh "C:\hooks\Start-QmdWarm.ps1"'}))
|
||||||
|
self.assertFalse(OPTIONS.is_qmd_warm({'command': 'bash "/c/hooks/scripts/my-qmd-warm.sh"'}))
|
||||||
|
|
||||||
|
def test_merge_json_keeps_user_session_hook_and_drops_qmd_when_disabled(self):
|
||||||
|
user = {'type': 'command', 'command': 'bash "/home/me/my-start.sh"'}
|
||||||
|
pointer = {'type': 'command', 'command': 'bash "/c/hooks/scripts/show-session-state-pointer.sh"'}
|
||||||
|
qmd = {'type': 'command', 'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'}
|
||||||
|
current = {'hooks': {'SessionStart': [{'hooks': [user]}]}}
|
||||||
|
managed = {'hooks': {'SessionStart': [{'hooks': [pointer]}, {'hooks': [qmd]}]}}
|
||||||
|
once = OPTIONS.merge_json(current, managed)
|
||||||
|
twice = OPTIONS.merge_json(once, managed)
|
||||||
|
self.assertEqual(twice['hooks']['SessionStart'], [{'hooks': [pointer]}, {'hooks': [qmd]}, {'hooks': [user]}])
|
||||||
|
disabled = OPTIONS.merge_json(twice, {'hooks': {'SessionStart': [{'hooks': [pointer]}]}})
|
||||||
|
commands = [hook['command'] for group in disabled['hooks']['SessionStart'] for hook in group['hooks']]
|
||||||
|
self.assertEqual(commands, [pointer['command'], user['command']])
|
||||||
|
removed = OPTIONS.merge_json(twice, {'hooks': {}})
|
||||||
|
self.assertEqual(removed['hooks']['SessionStart'], [{'hooks': [user]}])
|
||||||
|
|
||||||
|
def test_merge_toml_hook_groups_do_not_grow_and_user_hook_survives(self):
|
||||||
|
managed = ('[features]\nhooks = true\n\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\n'
|
||||||
|
'command = \'bash "/c/hooks/scripts/qmd-warm.sh"\'\n\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\n'
|
||||||
|
'command = \'bash "/c/hooks/flashbang.sh"\'\n')
|
||||||
|
user = '\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\ncommand = \'bash "/home/me/my-start.sh"\'\n'
|
||||||
|
text = managed + user
|
||||||
|
for _ in range(3):
|
||||||
|
text = OPTIONS.merge_toml(text, managed)
|
||||||
|
hooks = tomllib.loads(text)['hooks']
|
||||||
|
self.assertEqual(len(hooks['Stop']), 1)
|
||||||
|
self.assertEqual(len(hooks['SessionStart']), 2)
|
||||||
|
commands = [hook['command'] for group in hooks['SessionStart'] for hook in group['hooks']]
|
||||||
|
self.assertEqual(sorted(commands), ['bash "/c/hooks/scripts/qmd-warm.sh"', 'bash "/home/me/my-start.sh"'])
|
||||||
|
disabled = OPTIONS.merge_toml(text, '[features]\nhooks = true\n')
|
||||||
|
hooks = tomllib.loads(disabled)['hooks']
|
||||||
|
self.assertNotIn('Stop', hooks)
|
||||||
|
self.assertEqual([hook['command'] for group in hooks['SessionStart'] for hook in group['hooks']], ['bash "/home/me/my-start.sh"'])
|
||||||
|
|
||||||
def test_filter_preserves_other_claude_hooks_and_status(self):
|
def test_filter_preserves_other_claude_hooks_and_status(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
path = Path(directory) / 'settings.json'
|
path = Path(directory) / 'settings.json'
|
||||||
|
|||||||
@@ -260,27 +260,15 @@ class ManagedExtensionTests(unittest.TestCase):
|
|||||||
self.manager(dry_run=True, summary=True).sync([plugin(), skill()], ['codex'], {'Example', 'Humanizer'})
|
self.manager(dry_run=True, summary=True).sync([plugin(), skill()], ['codex'], {'Example', 'Humanizer'})
|
||||||
self.assertEqual('EXTENSIONS: reconciliation complete (dry run)\n', output.getvalue())
|
self.assertEqual('EXTENSIONS: reconciliation complete (dry run)\n', output.getvalue())
|
||||||
|
|
||||||
def test_qmd_installs_once_as_a_cli_without_registering_mcp(self):
|
def test_manifest_has_no_qmd_extension(self):
|
||||||
entry = dict(name='QMD', codex_method='qmd', codex_source='@tobilu/qmd', codex_skill='qmd', codex_plugin='-', codex_marketplace='-', claude_plugin='qmd@qmd', claude_marketplace='tobi/qmd')
|
rows = (root / 'adapters/plugins.tsv').read_text(encoding='utf-8').splitlines()
|
||||||
self.manager().sync([entry], ['codex'], {'QMD'})
|
self.assertFalse(any(row.split(' ')[0] == 'QMD' for row in rows[1:]))
|
||||||
self.assertEqual([['npm', 'install', '--global', '@tobilu/qmd']], self.commands)
|
|
||||||
self.manager().sync([], ['codex'], set())
|
|
||||||
self.assertEqual(['npm', 'uninstall', '--global', '@tobilu/qmd'], self.commands[-1])
|
|
||||||
|
|
||||||
def test_qmd_installs_once_for_both_clients(self):
|
def test_legacy_qmd_record_still_validates(self):
|
||||||
entry = dict(name='QMD', codex_method='qmd', codex_source='@tobilu/qmd', codex_skill='qmd', codex_plugin='-', codex_marketplace='-', claude_plugin='qmd@qmd', claude_marketplace='tobi/qmd')
|
(self.home / '.my-ai-configuration').mkdir()
|
||||||
self.manager().sync([entry], ['claude', 'codex'], {'QMD'})
|
(self.home / '.my-ai-configuration/extensions.json').write_text(json.dumps({'version': 1, 'resources': [
|
||||||
self.assertEqual(1, self.commands.count(['npm', 'install', '--global', '@tobilu/qmd']))
|
{'client': 'shared', 'name': 'QMD', 'kind': 'qmd', 'package': '@tobilu/qmd', 'clients': ['codex']}]}))
|
||||||
self.assertIn(['claude', 'plugin', 'install', 'qmd@qmd', '--scope', 'user'], self.commands)
|
self.manager() # must not raise
|
||||||
self.assertNotIn(['codex', 'mcp', 'add', 'qmd', '--', 'qmd', 'mcp'], self.commands)
|
|
||||||
|
|
||||||
def test_owned_qmd_uses_npm_update_instead_of_reinstalling(self):
|
|
||||||
entry = dict(name='QMD', codex_method='qmd', codex_source='@tobilu/qmd', codex_skill='qmd', codex_plugin='-', codex_marketplace='-', claude_plugin='qmd@qmd', claude_marketplace='tobi/qmd')
|
|
||||||
self.manager().sync([entry], ['codex'], {'QMD'})
|
|
||||||
self.commands.clear()
|
|
||||||
with patch.object(extensions.Manager, 'global_packages', return_value={'@tobilu/qmd'}):
|
|
||||||
self.manager(update=True).sync([entry], ['codex'], {'QMD'})
|
|
||||||
self.assertEqual([['npm', 'update', '--global', '@tobilu/qmd']], self.commands)
|
|
||||||
|
|
||||||
def test_managed_mcporter_updates_when_requested(self):
|
def test_managed_mcporter_updates_when_requested(self):
|
||||||
self.manager().sync([mcporter()], ['codex'], {'MCPorter'})
|
self.manager().sync([mcporter()], ['codex'], {'MCPorter'})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { passes, fixtureDocuments, describeDevices, embedThroughput } 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));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('embed throughput rejects errors and empty runs', () => {
|
||||||
|
assert.deepEqual(embedThroughput({ chunksEmbedded: 200, durationMs: 4000, errors: 0 }), { chunksPerSecond: 50, ok: true });
|
||||||
|
assert.equal(embedThroughput({ chunksEmbedded: 200, durationMs: 4000, errors: 1 }).ok, false);
|
||||||
|
assert.equal(embedThroughput({ chunksEmbedded: 0, durationMs: 4000, errors: 0 }).ok, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// scripts/tests/test-qmd-daemon.mjs
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { acquireStartLock, createServer, gitFingerprint, ignoreGlobs, keepModelsLoaded } from '../../shared/qmd/qmd-daemon.mjs';
|
||||||
|
import { collectionName } from '../../shared/qmd/qmd-lib.mjs';
|
||||||
|
|
||||||
|
const defaultHits = () => [{ displayPath: `${collectionName('/r')}/src/a.ts`, body: 'x\nretry here', bestChunkPos: 2, bestChunk: 'retry here', score: 0.91 }];
|
||||||
|
|
||||||
|
function fakeStore(updateGate, hits = defaultHits) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
listCollections: async () => [{ name: 'notes', includeByDefault: true }, ...calls.filter((c) => c[0] === 'add').map((c) => ({ name: c[1] }))],
|
||||||
|
getDefaultCollectionNames: async () => ['notes', 'repo-aaaaaaaaaaaa', 'repo-notes'],
|
||||||
|
addCollection: async (name, opts) => calls.push(['add', name, opts]),
|
||||||
|
update: async (opts) => { calls.push(['update', opts]); await updateGate; return { indexed: 1 }; },
|
||||||
|
embed: async (opts) => { calls.push(['embed', opts]); return { chunksEmbedded: 1 }; },
|
||||||
|
search: async (opts) => { calls.push(['search', opts]); return hits(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(t, overrides = {}, { updateGate, hits } = {}) {
|
||||||
|
const store = fakeStore(updateGate, hits);
|
||||||
|
let clock = 0;
|
||||||
|
const idle = [];
|
||||||
|
const daemon = createServer({
|
||||||
|
store, token: 't0k', idleMs: 1000, now: () => clock, onIdle: () => idle.push(clock),
|
||||||
|
extractSnippet: () => ({ line: 2, snippet: 'retry here' }),
|
||||||
|
git: () => 'fingerprint-1', excludeCollection: async () => {}, recordCollection: () => {},
|
||||||
|
ignored: () => [], persistIgnore: async () => {}, ...overrides,
|
||||||
|
});
|
||||||
|
await new Promise((done) => daemon.server.listen(0, '127.0.0.1', done));
|
||||||
|
t.after(() => { daemon.server.closeAllConnections(); daemon.server.close(); });
|
||||||
|
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: { connection: 'close', 'x-qmd-token': token, 'content-type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body) });
|
||||||
|
const raw = (path, body) => fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: { connection: 'close', 'x-qmd-token': 't0k' }, body });
|
||||||
|
return { daemon, store, call, raw, setClock: (v) => { clock = v; }, idle };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||||
|
const gateOf = () => { let release; const gate = new Promise((done) => { release = done; }); return { gate, release }; };
|
||||||
|
|
||||||
|
test('rejects requests without the token', async (t) => {
|
||||||
|
const s = await start(t);
|
||||||
|
assert.equal((await s.call('/health', undefined, 'wrong')).status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health identifies the service', async (t) => {
|
||||||
|
const s = await start(t);
|
||||||
|
assert.equal((await (await s.call('/health')).json()).service, 'ai-config-qmd');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search adds the repo collection once, refreshes on change, and filters other repos', async (t) => {
|
||||||
|
const s = await start(t);
|
||||||
|
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.ok(search.collections.includes('repo-notes'), 'only hash-named repo collections are filtered');
|
||||||
|
assert.equal(search.collections.filter((n) => /^repo-[0-9a-f]{12}$/.test(n)).length, 1);
|
||||||
|
assert.equal(s.store.calls.filter((c) => c[0] === 'add').length, 1);
|
||||||
|
assert.equal(s.store.calls.find((c) => c[0] === 'add')[2].path, resolve('/r'));
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a missing query', async (t) => {
|
||||||
|
const s = await start(t);
|
||||||
|
assert.equal((await s.call('/search', { repo: '/r' })).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle check calls onIdle only after the idle window', async (t) => {
|
||||||
|
const s = await start(t);
|
||||||
|
s.setClock(500); s.daemon.checkIdle(); assert.deepEqual(s.idle, []);
|
||||||
|
s.setClock(1500); s.daemon.checkIdle(); assert.deepEqual(s.idle, [1500]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a changed fingerprint triggers update and embed again', async (t) => {
|
||||||
|
let fingerprint = 'one';
|
||||||
|
const s = await start(t, { git: () => fingerprint });
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q' });
|
||||||
|
fingerprint = 'two';
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q' });
|
||||||
|
assert.equal(s.store.calls.filter((c) => c[0] === 'update').length, 2);
|
||||||
|
assert.equal(s.store.calls.filter((c) => c[0] === 'embed').length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fingerprint changes when an already dirty file is edited again', (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'qmd-fp-'));
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
const git = (...args) => execFileSync('git', ['-C', dir, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { stdio: 'ignore' });
|
||||||
|
git('init', '-q');
|
||||||
|
writeFileSync(join(dir, 'a.ts'), 'one');
|
||||||
|
git('add', '.');
|
||||||
|
git('commit', '-qm', 'init');
|
||||||
|
writeFileSync(join(dir, 'a.ts'), 'two');
|
||||||
|
const first = gitFingerprint(dir);
|
||||||
|
writeFileSync(join(dir, 'a.ts'), 'three!');
|
||||||
|
utimesSync(join(dir, 'a.ts'), new Date(), new Date(Date.now() + 5000));
|
||||||
|
assert.notEqual(gitFingerprint(dir), first);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failing exclude does not stop the search and is retried', async (t) => {
|
||||||
|
let attempts = 0;
|
||||||
|
const recorded = [];
|
||||||
|
const s = await start(t, {
|
||||||
|
excludeCollection: async () => { attempts++; if (attempts === 1) throw new Error('boom'); },
|
||||||
|
recordCollection: (name) => recorded.push(name),
|
||||||
|
});
|
||||||
|
const first = await s.call('/search', { repo: '/r', query: 'q' });
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
assert.equal((await first.json()).results.length, 1);
|
||||||
|
assert.deepEqual(recorded, []);
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q' });
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.equal(recorded.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent searches share one refresh', async (t) => {
|
||||||
|
const { gate, release } = gateOf();
|
||||||
|
const s = await start(t, {}, { updateGate: gate });
|
||||||
|
const both = Promise.all([s.call('/search', { repo: '/r', query: 'a' }), s.call('/search', { repo: '/r', query: 'b' })]);
|
||||||
|
await wait(100);
|
||||||
|
release();
|
||||||
|
await both;
|
||||||
|
assert.equal(s.store.calls.filter((c) => c[0] === 'update').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle check waits for in-flight requests', async (t) => {
|
||||||
|
const { gate, release } = gateOf();
|
||||||
|
const s = await start(t, {}, { updateGate: gate });
|
||||||
|
const pending = s.call('/search', { repo: '/r', query: 'a' });
|
||||||
|
await wait(100);
|
||||||
|
s.setClock(5000); s.daemon.checkIdle(); assert.deepEqual(s.idle, []);
|
||||||
|
release();
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed or non-object bodies give 400 and limit is clamped', async (t) => {
|
||||||
|
const s = await start(t);
|
||||||
|
assert.equal((await s.raw('/search', '{nope')).status, 400);
|
||||||
|
assert.equal((await s.raw('/search', '[1]')).status, 400);
|
||||||
|
assert.equal((await s.raw('/search', 'null')).status, 400);
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q', limit: 9999 });
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q', limit: 'x' });
|
||||||
|
const limits = s.store.calls.filter((c) => c[0] === 'search').map((c) => c[1].limit);
|
||||||
|
assert.deepEqual(limits, [20, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-repo hits become qmd:// URIs', async (t) => {
|
||||||
|
const s = await start(t, {}, { hits: () => [{ displayPath: 'notes/todo.md', body: 'x', bestChunkPos: 0, bestChunk: 'x', score: 0.5 }] });
|
||||||
|
const body = await (await s.call('/search', { repo: '/r', query: 'q' })).json();
|
||||||
|
assert.equal(body.results[0].path, 'qmd://notes/todo.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh, stop, 404 and 403 on POST routes', async (t) => {
|
||||||
|
const s = await start(t);
|
||||||
|
assert.deepEqual(await (await s.call('/refresh', { repo: '/r' })).json(), { collection: collectionName('/r'), updated: true });
|
||||||
|
assert.equal((await s.call('/nothing', { repo: '/r' })).status, 404);
|
||||||
|
assert.equal((await s.call('/search', { repo: '/r', query: 'q' }, 'wrong')).status, 403);
|
||||||
|
assert.deepEqual(await (await s.call('/stop', {})).json(), { stopping: true });
|
||||||
|
assert.equal(s.idle.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keepModelsLoaded disables QMD unloading models after 5 idle minutes', () => {
|
||||||
|
const llm = { inactivityTimeoutMs: 5 * 60 * 1000, disposeModelsOnInactivity: true };
|
||||||
|
keepModelsLoaded({ internal: { llm } });
|
||||||
|
assert.deepEqual(llm, { inactivityTimeoutMs: 0, disposeModelsOnInactivity: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignoreGlobs turns git ignored paths into escaped QMD ignore globs', () => {
|
||||||
|
const escape = (path) => path.replace(/[()[\]]/g, '\\$&');
|
||||||
|
assert.deepEqual(ignoreGlobs('dist/\0a b.ts\0out (1)/\0x[1].md\0', escape), ['dist/**', 'a b.ts', 'out \\(1\\)/**', 'x\\[1\\].md']);
|
||||||
|
assert.deepEqual(ignoreGlobs('', escape), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('git ignored paths reach addCollection and are persisted only when they change', async (t) => {
|
||||||
|
let ignore = ['dist/**'];
|
||||||
|
let fingerprint = 'one';
|
||||||
|
const persisted = [];
|
||||||
|
const s = await start(t, { ignored: () => ignore, git: () => fingerprint, persistIgnore: async (name, list) => persisted.push([name, list]) });
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q' });
|
||||||
|
assert.deepEqual(s.store.calls.find((c) => c[0] === 'add')[2].ignore, ['dist/**']);
|
||||||
|
assert.deepEqual(persisted, [[collectionName('/r'), ['dist/**']]]);
|
||||||
|
const order = s.store.calls.map((c) => c[0]);
|
||||||
|
assert.ok(order.indexOf('add') < order.indexOf('update'));
|
||||||
|
fingerprint = 'two';
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q' });
|
||||||
|
assert.equal(persisted.length, 1, 'an unchanged list is not written again');
|
||||||
|
ignore = ['dist/**', 'tmp/**'];
|
||||||
|
fingerprint = 'three';
|
||||||
|
await s.call('/search', { repo: '/r', query: 'q' });
|
||||||
|
assert.deepEqual(persisted[1], [collectionName('/r'), ['dist/**', 'tmp/**']]);
|
||||||
|
assert.equal(s.store.calls.filter((c) => c[0] === 'update').length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the start lock admits one launcher and is taken over after 2 minutes', (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'qmd-lock-'));
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
const lock = join(dir, 'daemon.lock');
|
||||||
|
const release = acquireStartLock(lock);
|
||||||
|
assert.equal(typeof release, 'function');
|
||||||
|
assert.equal(acquireStartLock(lock), null, 'a concurrent launch loses');
|
||||||
|
release();
|
||||||
|
assert.equal(existsSync(lock), false);
|
||||||
|
assert.equal(typeof acquireStartLock(lock), 'function');
|
||||||
|
assert.equal(acquireStartLock(lock, () => Date.now() + 60_000), null, 'a fresh lock holds');
|
||||||
|
assert.equal(typeof acquireStartLock(lock, () => Date.now() + 121_000), 'function', 'a stale lock is taken over');
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
import { runQmdCli } from '../../shared/qmd/qmd-lib.mjs';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
runQmdCli(['pull'], { env, timeout: 1_700_000, stdio: 'inherit' }); // download models first so the search only measures indexing and query
|
||||||
|
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.replaceAll('\\', '/'), 'src/upload.ts');
|
||||||
|
execFileSync('node', [search, '--stop'], { env });
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } 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, writeTextAtomic, readJson, runQmdCli, MODELS } from '../../shared/qmd/qmd-lib.mjs';
|
||||||
|
import { setModels, unsetModels, previousModels, restoreModels, setCollectionIgnore } 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runQmdCli runs the CLI entry of QMD_PACKAGE_DIR without a shell', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'qmd-pkg-'));
|
||||||
|
mkdirSync(join(dir, 'dist', 'cli'), { recursive: true });
|
||||||
|
writeFileSync(join(dir, 'dist', 'cli', 'qmd.js'), 'console.log(JSON.stringify(process.argv.slice(2)));\n');
|
||||||
|
const previous = process.env.QMD_PACKAGE_DIR;
|
||||||
|
process.env.QMD_PACKAGE_DIR = dir;
|
||||||
|
try {
|
||||||
|
assert.deepEqual(JSON.parse(runQmdCli(['search', 'a "b" & c'])), ['search', 'a "b" & c']);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.QMD_PACKAGE_DIR; else process.env.QMD_PACKAGE_DIR = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('previousModels captures a user models block, null when absent', () => {
|
||||||
|
assert.deepEqual(previousModels('models:\n embed: mine\ncollections: {}\n', YAML), { embed: 'mine' });
|
||||||
|
assert.equal(previousModels('models: custom\n', YAML), 'custom');
|
||||||
|
assert.deepEqual(previousModels('models: [a, b]\n', YAML), ['a', 'b']);
|
||||||
|
assert.equal(previousModels('collections: {}\n', YAML), null);
|
||||||
|
assert.equal(previousModels('', YAML), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoreModels writes the previous block back and keeps comments and other keys', () => {
|
||||||
|
const original = '# keep\ncollections:\n a: {}\nmodels:\n embed: mine\n';
|
||||||
|
const replaced = setModels(original, YAML);
|
||||||
|
const restored = restoreModels(replaced, YAML, { embed: 'mine' });
|
||||||
|
assert.match(restored, /# keep/);
|
||||||
|
assert.match(restored, /embed: mine/);
|
||||||
|
assert.match(restored, /collections:/);
|
||||||
|
assert.doesNotMatch(restored, /Qwen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeTextAtomic replaces the file through a temp file in the same directory', () => {
|
||||||
|
const dir = join(mkdtempSync(join(tmpdir(), 'qmd-lib-')), 'cfg');
|
||||||
|
const file = join(dir, 'index.yml');
|
||||||
|
writeTextAtomic(file, 'a: 1\n');
|
||||||
|
writeTextAtomic(file, 'b: 2\n');
|
||||||
|
assert.equal(readFileSync(file, 'utf8'), 'b: 2\n');
|
||||||
|
assert.deepEqual(readdirSync(dir), ['index.yml']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setCollectionIgnore writes the ignore list of one collection and keeps the rest', () => {
|
||||||
|
const original = '# mine\ncollections:\n notes:\n path: /n # keep\n repo-aaaaaaaaaaaa:\n path: /r\n includeByDefault: false\n';
|
||||||
|
const withIgnore = setCollectionIgnore(original, YAML, 'repo-aaaaaaaaaaaa', ['dist/**', 'a.ts']);
|
||||||
|
assert.match(withIgnore, /# mine/);
|
||||||
|
assert.match(withIgnore, /path: \/n # keep/);
|
||||||
|
assert.deepEqual(YAML.default.parse(withIgnore).collections['repo-aaaaaaaaaaaa'], { path: '/r', includeByDefault: false, ignore: ['dist/**', 'a.ts'] });
|
||||||
|
const cleared = setCollectionIgnore(withIgnore, YAML, 'repo-aaaaaaaaaaaa', []);
|
||||||
|
assert.deepEqual(YAML.default.parse(cleared).collections['repo-aaaaaaaaaaaa'], { path: '/r', includeByDefault: false });
|
||||||
|
assert.equal(setCollectionIgnore(original, YAML, 'repo-bbbbbbbbbbbb', ['x']), null, 'an unknown collection is not created');
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { callDaemon, runSearch, startDaemon, toCliResults } from '../../shared/qmd/qmd-search.mjs';
|
||||||
|
import { collectionName } from '../../shared/qmd/qmd-lib.mjs';
|
||||||
|
|
||||||
|
const hit = [{ path: 'src/a.ts', line: 3, score: 0.9, snippet: 's' }];
|
||||||
|
|
||||||
|
test('uses the daemon when it answers', async () => {
|
||||||
|
const results = await runSearch({ root: '/My Repo/ĂĽ', query: 'q', topK: 5,
|
||||||
|
daemon: async (path, body) => { assert.equal(path, '/search'); assert.equal(body.repo, '/My Repo/ĂĽ'); return { results: hit }; },
|
||||||
|
startDaemon: async () => assert.fail('must not start'), cli: async () => assert.fail('must not fall back') });
|
||||||
|
assert.deepEqual(results, hit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts the daemon once when the first call fails, then retries', async () => {
|
||||||
|
let calls = 0; let started = 0;
|
||||||
|
const results = await runSearch({ root: '/r', query: 'q', topK: 5,
|
||||||
|
daemon: async () => { calls += 1; if (calls === 1) throw Object.assign(new Error('ECONNREFUSED'), { notRunning: true }); 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 Object.assign(new Error('stale'), { notRunning: true }); }, startDaemon: async () => { throw new Error('spawn failed'); },
|
||||||
|
cli: async (args) => { assert.ok(args.includes('--json')); return [{ file: `qmd://${collectionName('/r')}/src/b.ts`, line: 7, score: 0.5, snippet: 'b' }]; } });
|
||||||
|
assert.deepEqual(results, [{ path: 'src/b.ts', line: 7, score: 0.5, snippet: 'b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI results keep foreign collections as qmd URIs', () => {
|
||||||
|
assert.deepEqual(toCliResults([{ file: 'qmd://notes/x.md', line: 1, score: 0.4, snippet: 'n' }], 'repo-x'),
|
||||||
|
[{ path: 'qmd://notes/x.md', line: 1, score: 0.4, snippet: 'n' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cliHit = (root) => async () => [{ file: `qmd://${collectionName(root)}/src/b.ts`, line: 7, score: 0.5, snippet: 'b' }];
|
||||||
|
const cliExpected = [{ path: 'src/b.ts', line: 7, score: 0.5, snippet: 'b' }];
|
||||||
|
|
||||||
|
test('a live daemon that answers with an error does not trigger a second daemon', async () => {
|
||||||
|
const results = await runSearch({ root: '/r', query: 'q', topK: 2,
|
||||||
|
daemon: async () => { throw new Error('daemon answered 500'); },
|
||||||
|
startDaemon: async () => assert.fail('must not start'), cli: cliHit('/r') });
|
||||||
|
assert.deepEqual(results, cliExpected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a daemon reply without results falls back to the CLI', async () => {
|
||||||
|
const results = await runSearch({ root: '/r', query: 'q', topK: 2,
|
||||||
|
daemon: async () => ({}), startDaemon: async () => assert.fail('must not start'), cli: cliHit('/r') });
|
||||||
|
assert.deepEqual(results, cliExpected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a daemon failure after a successful start falls back to the CLI', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const results = await runSearch({ root: '/r', query: 'q', topK: 2,
|
||||||
|
daemon: async () => { calls += 1; throw calls === 1 ? Object.assign(new Error('down'), { notRunning: true }) : new Error('daemon answered 500'); },
|
||||||
|
startDaemon: async () => {}, cli: cliHit('/r') });
|
||||||
|
assert.deepEqual(results, cliExpected);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startDaemon rejects quickly when the child exits immediately', async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
await assert.rejects(startDaemon({ spawnChild: () => {
|
||||||
|
const child = new EventEmitter(); child.unref = () => {}; child.pid = 1;
|
||||||
|
setImmediate(() => child.emit('exit', 1));
|
||||||
|
return child;
|
||||||
|
} }), /exited with code 1/);
|
||||||
|
assert.ok(Date.now() - started < 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startDaemon rejects when the child cannot be spawned', async () => {
|
||||||
|
await assert.rejects(startDaemon({ spawnChild: () => {
|
||||||
|
const child = new EventEmitter(); child.unref = () => {}; child.pid = 1;
|
||||||
|
setImmediate(() => child.emit('error', new Error('spawn failed')));
|
||||||
|
return child;
|
||||||
|
} }), /spawn failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function tempDaemonFile(t, value) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'qmd-search-'));
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
const file = join(dir, 'daemon.json');
|
||||||
|
if (value) writeFileSync(file, JSON.stringify(value));
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('startDaemon resolves when its child loses the start race to a running daemon', async (t) => {
|
||||||
|
const file = tempDaemonFile(t, { port: 4000, token: 'other', pid: 999 });
|
||||||
|
await startDaemon({ file, spawnChild: () => {
|
||||||
|
const child = new EventEmitter(); child.unref = () => {}; child.pid = 1;
|
||||||
|
setImmediate(() => child.emit('exit', 0));
|
||||||
|
return child;
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 403 means a stale token: daemon.json is deleted and one new daemon is started', async (t) => {
|
||||||
|
const file = tempDaemonFile(t, { port: 4000, token: 'stale', pid: 999 });
|
||||||
|
const fetchImpl = async (url, options) => (options.headers['x-qmd-token'] === 'fresh'
|
||||||
|
? { ok: true, status: 200, json: async () => ({ results: hit }) } : { ok: false, status: 403 });
|
||||||
|
let started = 0;
|
||||||
|
const results = await runSearch({ root: '/r', query: 'q', topK: 5,
|
||||||
|
daemon: (path, body) => callDaemon(path, body, { file, fetchImpl }),
|
||||||
|
startDaemon: async () => { started += 1; assert.equal(existsSync(file), false); writeFileSync(file, JSON.stringify({ port: 4001, token: 'fresh', pid: 998 })); },
|
||||||
|
cli: async () => assert.fail('must not fall back') });
|
||||||
|
assert.deepEqual(results, hit);
|
||||||
|
assert.equal(started, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
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, previous=None, fail_pull=False, list_output=None,
|
||||||
|
node='v22.4.0', fail=(), on_call=None):
|
||||||
|
self.calls, self.installed, self.gpu = [], installed, gpu
|
||||||
|
self.previous, self.fail_pull, self.list_output = previous, fail_pull, list_output
|
||||||
|
self.node, self.fail, self.on_call = node, fail, on_call
|
||||||
|
self.results = {'gpu': gpu_result, 'cpu': cpu_result}
|
||||||
|
|
||||||
|
def __call__(self, arguments, env=None, timeout=None, check=True):
|
||||||
|
self.calls.append(list(arguments))
|
||||||
|
joined = ' '.join(arguments)
|
||||||
|
if self.on_call:
|
||||||
|
self.on_call(arguments)
|
||||||
|
if any(part in joined for part in self.fail):
|
||||||
|
raise ValueError(f'{joined} failed')
|
||||||
|
if arguments[:2] == ['node', '--version']:
|
||||||
|
return self.node
|
||||||
|
if arguments[-1:] == ['set-models']:
|
||||||
|
return json.dumps({'previous': self.previous}) + chr(10)
|
||||||
|
if self.fail_pull and arguments[:2] == ['qmd', 'pull']:
|
||||||
|
raise ValueError('pull failed')
|
||||||
|
if 'npm list' in joined and self.list_output is not None:
|
||||||
|
return self.list_output
|
||||||
|
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)
|
||||||
|
environment = mock.patch.dict(os.environ)
|
||||||
|
environment.start()
|
||||||
|
self.addCleanup(environment.stop)
|
||||||
|
os.environ.pop('XDG_CACHE_HOME', None)
|
||||||
|
(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_previous_models_are_restored_on_disable(self):
|
||||||
|
runner = FakeRunner(previous={'embed': 'mine'})
|
||||||
|
setup = self.setup(runner)
|
||||||
|
setup.enable(['claude'])
|
||||||
|
self.assertEqual(self.state()['previous_models'], {'embed': 'mine'})
|
||||||
|
setup.disable(['claude'])
|
||||||
|
self.assertIn(['node', str(self.home / '.my-ai-configuration/qmd/qmd-config.mjs'), 'restore-models', '{"embed": "mine"}'], runner.calls)
|
||||||
|
self.assertFalse(any(c[-1:] == ['unset-models'] for c in runner.calls))
|
||||||
|
|
||||||
|
def test_models_block_removed_when_none_existed(self):
|
||||||
|
runner = FakeRunner()
|
||||||
|
setup = self.setup(runner)
|
||||||
|
setup.enable(['claude'])
|
||||||
|
self.assertIsNone(self.state()['previous_models'])
|
||||||
|
setup.disable(['claude'])
|
||||||
|
self.assertTrue(any(c[-1:] == ['unset-models'] for c in runner.calls))
|
||||||
|
self.assertFalse(any('restore-models' in c for c in runner.calls))
|
||||||
|
|
||||||
|
def ledger(self, base):
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
(base / 'extensions.json').write_text(json.dumps({'version': 1, 'resources': [
|
||||||
|
{'client': 'shared', 'name': 'QMD', 'kind': 'qmd', 'package': '@tobilu/qmd', 'clients': ['codex']}]}))
|
||||||
|
|
||||||
|
def test_disable_without_state_uninstalls_adopted_package(self):
|
||||||
|
base = self.home / '.my-ai-configuration'
|
||||||
|
self.ledger(base)
|
||||||
|
runner = FakeRunner(installed=True)
|
||||||
|
self.setup(runner).disable(['claude'])
|
||||||
|
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||||||
|
self.assertFalse((base / 'qmd.json').exists())
|
||||||
|
|
||||||
|
def test_ownership_is_persisted_before_ledger_rewrite(self):
|
||||||
|
base = self.home / '.my-ai-configuration'
|
||||||
|
self.ledger(base)
|
||||||
|
real = qmd.atomic_json
|
||||||
|
|
||||||
|
def failing(path, value):
|
||||||
|
if path.name == 'extensions.json':
|
||||||
|
raise OSError('disk full')
|
||||||
|
real(path, value)
|
||||||
|
with mock.patch.object(qmd, 'atomic_json', failing):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.setup(FakeRunner(installed=True)).enable(['claude'])
|
||||||
|
self.assertTrue(self.state()['owned_package'])
|
||||||
|
self.assertEqual(len(json.loads((base / 'extensions.json').read_text())['resources']), 1)
|
||||||
|
|
||||||
|
def test_symlinked_script_directory_is_refused(self):
|
||||||
|
base = self.home / '.my-ai-configuration'
|
||||||
|
base.mkdir(parents=True)
|
||||||
|
target = self.home / 'elsewhere'
|
||||||
|
target.mkdir()
|
||||||
|
try:
|
||||||
|
(base / 'qmd').symlink_to(target, target_is_directory=True)
|
||||||
|
except OSError:
|
||||||
|
self.skipTest('symlinks unavailable')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.setup(FakeRunner()).enable(['claude'])
|
||||||
|
self.assertEqual(list(target.iterdir()), [])
|
||||||
|
|
||||||
|
def test_non_dict_benchmark_result_counts_as_failure(self):
|
||||||
|
runner = FakeRunner(gpu=False, cpu_result=['unexpected'])
|
||||||
|
self.assertFalse(self.setup(runner).benchmark())
|
||||||
|
|
||||||
|
def test_package_installed_parses_listing(self):
|
||||||
|
self.assertTrue(self.setup(FakeRunner(installed=True)).package_installed())
|
||||||
|
self.assertFalse(self.setup(FakeRunner()).package_installed())
|
||||||
|
|
||||||
|
def test_package_installed_rejects_unreliable_listings(self):
|
||||||
|
for output in ('', ' ', 'not json', '{"error": {"code": "ELSPROBLEMS"}}', '{}', '[]'):
|
||||||
|
with self.assertRaises(ValueError, msg=repr(output)):
|
||||||
|
self.setup(FakeRunner(list_output=output)).package_installed()
|
||||||
|
self.assertTrue(self.setup(FakeRunner(list_output='{"dependencies": {"@tobilu/qmd": {}}, "error": {}}')).package_installed())
|
||||||
|
self.assertFalse(self.setup(FakeRunner(list_output='{"dependencies": {}}')).package_installed())
|
||||||
|
|
||||||
|
def test_non_mapping_previous_models_are_restored_verbatim(self):
|
||||||
|
runner = FakeRunner(previous='custom.gguf')
|
||||||
|
setup = self.setup(runner)
|
||||||
|
setup.enable(['claude'])
|
||||||
|
self.assertEqual(self.state()['previous_models'], 'custom.gguf')
|
||||||
|
setup.disable(['claude'])
|
||||||
|
self.assertTrue(any(c[-2:] == ['restore-models', '"custom.gguf"'] for c in runner.calls))
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
def scripts_dir(self):
|
||||||
|
return self.home / '.my-ai-configuration/qmd'
|
||||||
|
|
||||||
|
def test_benchmark_with_too_old_node_is_unsuitable(self):
|
||||||
|
runner = FakeRunner(node='v20.11.1', cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertFalse(self.setup(runner).benchmark())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
|
||||||
|
|
||||||
|
def test_benchmark_npm_install_failure_is_unsuitable(self):
|
||||||
|
runner = FakeRunner(fail=('npm install',), cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertFalse(self.setup(runner).benchmark())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
self.assertFalse(any('--device' in c for c in runner.calls))
|
||||||
|
|
||||||
|
def test_benchmark_download_failure_cleans_up_what_it_created(self):
|
||||||
|
runner = FakeRunner(fail_pull=True, cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertFalse(self.setup(runner).benchmark())
|
||||||
|
self.assertTrue(any(c[-1:] == ['unset-models'] for c in runner.calls))
|
||||||
|
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||||||
|
self.assertFalse(self.scripts_dir().exists())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
|
||||||
|
def test_benchmark_detect_crash_runs_cpu_only(self):
|
||||||
|
runner = FakeRunner(fail=('--detect',), gpu_result={'device': 'gpu', 'passed': True}, cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertTrue(self.setup(runner).benchmark())
|
||||||
|
self.assertEqual(self.state()['device'], 'cpu')
|
||||||
|
self.assertFalse(any('--device' in c and 'gpu' in c for c in runner.calls))
|
||||||
|
|
||||||
|
def test_enable_with_detect_crash_uses_cpu(self):
|
||||||
|
self.setup(FakeRunner(fail=('--detect',))).enable(['claude'])
|
||||||
|
self.assertEqual(self.state()['device'], 'cpu')
|
||||||
|
|
||||||
|
def legacy_install(self):
|
||||||
|
legacy = self.home / '.my-ai-configuration/semantic-retrieval'
|
||||||
|
python = legacy / '.venv' / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python')
|
||||||
|
python.parent.mkdir(parents=True)
|
||||||
|
python.write_text('')
|
||||||
|
(legacy / 'server.py').write_text('')
|
||||||
|
return legacy, python
|
||||||
|
|
||||||
|
def test_migration_stops_the_legacy_daemon_before_removal(self):
|
||||||
|
legacy, python = self.legacy_install()
|
||||||
|
seen = []
|
||||||
|
runner = FakeRunner(installed=True, on_call=lambda a: seen.append(legacy.exists()) if 'server.py' in ' '.join(a) else None)
|
||||||
|
self.setup(runner).enable(['claude'])
|
||||||
|
self.assertIn([str(python), str(legacy / 'server.py'), 'stop', '--data-dir', str(legacy / 'data'),
|
||||||
|
'--model-cache', str(legacy / 'model-cache')], runner.calls)
|
||||||
|
self.assertEqual(seen, [True], 'stop runs while the legacy directory still exists')
|
||||||
|
self.assertFalse(legacy.exists())
|
||||||
|
|
||||||
|
def test_failing_legacy_stop_does_not_block_migration(self):
|
||||||
|
legacy, _ = self.legacy_install()
|
||||||
|
self.setup(FakeRunner(installed=True, fail=('server.py',))).enable(['claude'])
|
||||||
|
self.assertFalse(legacy.exists())
|
||||||
|
|
||||||
|
def test_removal_waits_for_the_daemon_to_exit_before_deleting_models(self):
|
||||||
|
runner = FakeRunner()
|
||||||
|
setup = self.setup(runner)
|
||||||
|
setup.enable(['claude'])
|
||||||
|
(self.scripts_dir() / 'daemon.json').write_text(json.dumps({'port': 1, 'token': 't', 'pid': 4242}))
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
def alive(pid):
|
||||||
|
checks.append((pid, any('--stop' in c for c in runner.calls), any('npm uninstall' in ' '.join(c) for c in runner.calls)))
|
||||||
|
return len(checks) < 3
|
||||||
|
with mock.patch.object(qmd, 'process_alive', alive):
|
||||||
|
setup.disable(['claude'])
|
||||||
|
self.assertEqual(checks, [(4242, True, False)] * 3, 'the PID is polled after --stop and before the uninstall')
|
||||||
|
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||||||
|
|
||||||
|
def test_process_alive(self):
|
||||||
|
self.assertTrue(qmd.process_alive(os.getpid()))
|
||||||
|
child = subprocess.Popen([sys.executable, '-c', 'pass'])
|
||||||
|
child.wait()
|
||||||
|
self.assertFalse(qmd.process_alive(child.pid))
|
||||||
|
|
||||||
|
def test_undeletable_model_keeps_state_for_a_retry(self):
|
||||||
|
runner = FakeRunner()
|
||||||
|
setup = self.setup(runner)
|
||||||
|
setup.enable(['claude'])
|
||||||
|
state = self.state()
|
||||||
|
models = self.home / '.cache/qmd/models'
|
||||||
|
(models / 'locked.gguf').write_text('x')
|
||||||
|
(models / 'free.gguf').write_text('x')
|
||||||
|
state['owned_models'] = ['free.gguf', 'locked.gguf']
|
||||||
|
(self.home / '.my-ai-configuration/qmd.json').write_text(json.dumps(state))
|
||||||
|
real = Path.unlink
|
||||||
|
|
||||||
|
def unlink(path, missing_ok=False):
|
||||||
|
if path.name == 'locked.gguf':
|
||||||
|
raise PermissionError('in use')
|
||||||
|
real(path, missing_ok=missing_ok)
|
||||||
|
with mock.patch.object(qmd.Path, 'unlink', unlink):
|
||||||
|
setup.disable(['claude'])
|
||||||
|
state = self.state()
|
||||||
|
self.assertEqual(state['owned_models'], ['locked.gguf'])
|
||||||
|
self.assertFalse(state['owned_package'])
|
||||||
|
self.assertFalse(state['models_block'])
|
||||||
|
self.assertFalse((models / 'free.gguf').exists())
|
||||||
|
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||||||
|
self.assertFalse(self.scripts_dir().exists())
|
||||||
|
runner.calls.clear()
|
||||||
|
setup.disable(['claude'])
|
||||||
|
self.assertFalse((models / 'locked.gguf').exists())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
self.assertFalse(any('npm uninstall' in ' '.join(c) for c in runner.calls), 'a retry never uninstalls a later foreign package')
|
||||||
|
|
||||||
|
def test_failing_models_block_restore_does_not_stop_removal(self):
|
||||||
|
runner = FakeRunner(fail=('unset-models',))
|
||||||
|
setup = self.setup(runner)
|
||||||
|
setup.enable(['claude'])
|
||||||
|
setup.disable(['claude'])
|
||||||
|
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||||||
|
self.assertFalse(self.scripts_dir().exists())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
|
||||||
|
def test_models_dir_honors_xdg_cache_home(self):
|
||||||
|
os.environ['XDG_CACHE_HOME'] = str(self.home / 'xdg')
|
||||||
|
self.assertEqual(self.setup(FakeRunner()).models, self.home / 'xdg' / 'qmd' / 'models')
|
||||||
|
os.environ['XDG_CACHE_HOME'] = ''
|
||||||
|
self.assertEqual(self.setup(FakeRunner()).models, self.home / '.cache' / 'qmd' / 'models')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty or non-numeric lock counts as stale', async () => {
|
||||||
|
for (const content of ['', 'garbage']) {
|
||||||
|
const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock');
|
||||||
|
writeFileSync(lock, content);
|
||||||
|
let runs = 0;
|
||||||
|
await withLock(lock, async () => { runs += 1; });
|
||||||
|
assert.equal(runs, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run never removes a lock that another run took over', async () => {
|
||||||
|
const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock');
|
||||||
|
await withLock(lock, async () => { writeFileSync(lock, 'other-run'); });
|
||||||
|
assert.equal(existsSync(lock), true);
|
||||||
|
});
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
import importlib.util
|
|
||||||
from array import array
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
from types import SimpleNamespace
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
import urllib.request
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
SPEC = importlib.util.spec_from_file_location('retrieval_server', ROOT / 'shared/retrieval/server.py')
|
|
||||||
MODULE = importlib.util.module_from_spec(SPEC)
|
|
||||||
SPEC.loader.exec_module(MODULE)
|
|
||||||
|
|
||||||
|
|
||||||
def tokenize(text, **kwargs):
|
|
||||||
offsets = [match.span() for match in re.finditer(r'\S+', text)]
|
|
||||||
return {'offset_mapping': offsets, 'input_ids': list(range(len(offsets)))}
|
|
||||||
|
|
||||||
|
|
||||||
def embedding():
|
|
||||||
return array('f', [1.0] + [0.0] * (MODULE.EMBEDDING_DIMENSIONS - 1))
|
|
||||||
|
|
||||||
|
|
||||||
class VectorBatch:
|
|
||||||
def __init__(self, blob):
|
|
||||||
self.values = array('f', blob)
|
|
||||||
|
|
||||||
def reshape(self, *shape):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __matmul__(self, vector):
|
|
||||||
width = MODULE.EMBEDDING_DIMENSIONS
|
|
||||||
return [sum(a * b for a, b in zip(self.values[offset:offset + width], vector))
|
|
||||||
for offset in range(0, len(self.values), width)]
|
|
||||||
|
|
||||||
|
|
||||||
class ApproximateIndex:
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.vectors = {}
|
|
||||||
|
|
||||||
def add(self, keys, vectors):
|
|
||||||
width = MODULE.EMBEDDING_DIMENSIONS
|
|
||||||
for position, key in enumerate(keys):
|
|
||||||
start = position * width
|
|
||||||
self.vectors[key] = self.vectors.get(key, []) + list(vectors.values[start:start + width])
|
|
||||||
|
|
||||||
def search(self, vector, count):
|
|
||||||
matches = sorted(((key, sum(a * b for a, b in zip(values, vector)))
|
|
||||||
for key, values in self.vectors.items()), key=lambda item: (-item[1], item[0]))[:count]
|
|
||||||
return [SimpleNamespace(key=key, distance=1.0 - score) for key, score in matches]
|
|
||||||
|
|
||||||
|
|
||||||
def fake_numpy():
|
|
||||||
return SimpleNamespace(frombuffer=lambda blob, **kwargs: VectorBatch(blob), float32=None)
|
|
||||||
|
|
||||||
|
|
||||||
def fake_usearch():
|
|
||||||
module = SimpleNamespace(Index=ApproximateIndex)
|
|
||||||
return SimpleNamespace(index=module), module
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_repository(root):
|
|
||||||
subprocess.run(['git', '-C', str(root), 'init', '--quiet'], check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def huggingface_available():
|
|
||||||
try:
|
|
||||||
request = urllib.request.Request('https://huggingface.co', method='HEAD')
|
|
||||||
with urllib.request.urlopen(request, timeout=5):
|
|
||||||
pass
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def huggingface_network_unavailable(error):
|
|
||||||
while error:
|
|
||||||
if isinstance(error, socket.gaierror):
|
|
||||||
return True
|
|
||||||
error = error.__cause__ or error.__context__
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class RetrievalServerTests(unittest.TestCase):
|
|
||||||
def test_huggingface_availability_handles_connection_failure(self):
|
|
||||||
with patch('urllib.request.urlopen', side_effect=OSError):
|
|
||||||
self.assertFalse(huggingface_available())
|
|
||||||
|
|
||||||
def test_huggingface_network_failure_is_detected_from_cause_chain(self):
|
|
||||||
error = OSError('model download failed')
|
|
||||||
error.__cause__ = socket.gaierror('name resolution failed')
|
|
||||||
self.assertTrue(huggingface_network_unavailable(error))
|
|
||||||
self.assertFalse(huggingface_network_unavailable(OSError('model is missing')))
|
|
||||||
|
|
||||||
def test_ci_disables_xet_for_model_downloads(self):
|
|
||||||
content = (ROOT / '.gitea/workflows/ci.yml').read_text(encoding='utf-8')
|
|
||||||
self.assertIn("HF_HUB_DISABLE_XET: '1'", content)
|
|
||||||
|
|
||||||
def test_client_smoke_builds_packages(self):
|
|
||||||
content = (ROOT / '.gitea/workflows/ci.yml').read_text(encoding='utf-8')
|
|
||||||
self.assertIn('name: Build packages\n run: bash scripts/commands/build.sh --summary', content)
|
|
||||||
|
|
||||||
def test_runtime_python_uses_adjacent_virtualenv(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
script = Path(temporary) / 'semantic-retrieval' / 'server.py'
|
|
||||||
runtime = script.parent / '.venv' / 'Scripts' / 'python.exe'
|
|
||||||
runtime.parent.mkdir(parents=True)
|
|
||||||
runtime.touch()
|
|
||||||
|
|
||||||
self.assertEqual(MODULE.runtime_python(script, 'C:/Python/python.exe', 'nt'), runtime.resolve())
|
|
||||||
|
|
||||||
def test_windows_runtime_handoff_keeps_multi_word_arguments(self):
|
|
||||||
calls = []
|
|
||||||
with patch.object(MODULE, 'runtime_python', return_value=Path('C:/runtime/python.exe')), patch.object(MODULE.sys, 'argv', ['server.py', 'search', '--query', 'two words']), patch.object(MODULE.subprocess, 'run', side_effect=lambda command: calls.append(command) or SimpleNamespace(returncode=3)), self.assertRaises(SystemExit) as stopped:
|
|
||||||
MODULE.use_runtime_python('nt')
|
|
||||||
self.assertEqual(stopped.exception.code, 3)
|
|
||||||
self.assertEqual(calls[0][2:], ['search', '--query', 'two words'])
|
|
||||||
|
|
||||||
def test_daemon_identity_changes_with_code_version(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
data_dir = Path(temporary)
|
|
||||||
with patch.object(MODULE, 'code_version', return_value='aaaaaaaaaaaa'):
|
|
||||||
old_address, old_lock = MODULE.daemon_address(data_dir), MODULE.daemon_lock_path(data_dir)
|
|
||||||
with patch.object(MODULE, 'code_version', return_value='bbbbbbbbbbbb'):
|
|
||||||
self.assertNotEqual(MODULE.daemon_address(data_dir), old_address)
|
|
||||||
self.assertNotEqual(MODULE.daemon_lock_path(data_dir), old_lock)
|
|
||||||
|
|
||||||
def test_run_direct_runs_in_process_when_pipes_are_forbidden(self):
|
|
||||||
def action(action, root, data_dir, model_cache, query, top_k, output):
|
|
||||||
output.put((True, [query, top_k]))
|
|
||||||
|
|
||||||
with patch.object(MODULE, 'pipes_available', return_value=False), patch.object(MODULE, 'run_action', action):
|
|
||||||
self.assertEqual(MODULE.run_direct('search', Path('.'), Path('.'), Path('.'), 'two words', 3), ['two words', 3])
|
|
||||||
|
|
||||||
def test_model_kwargs_use_bfloat16_only_on_supporting_gpus(self):
|
|
||||||
bfloat16 = object()
|
|
||||||
for available, supported, expected in [(True, True, {'dtype': bfloat16}), (True, False, {}), (False, True, {})]:
|
|
||||||
cuda = SimpleNamespace(is_available=lambda available=available: available, is_bf16_supported=lambda supported=supported: supported)
|
|
||||||
with patch.dict('sys.modules', torch=SimpleNamespace(cuda=cuda, bfloat16=bfloat16)):
|
|
||||||
self.assertEqual(MODULE.model_kwargs(), expected)
|
|
||||||
|
|
||||||
def test_query_prompt_matches_qwen_format(self):
|
|
||||||
self.assertTrue(MODULE.QUERY_PROMPT.startswith('Instruct: '))
|
|
||||||
self.assertTrue(MODULE.QUERY_PROMPT.endswith('\nQuery:'))
|
|
||||||
|
|
||||||
def test_preferred_device_uses_cuda_with_cpu_fallback(self):
|
|
||||||
with patch.dict('sys.modules', torch=SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True))):
|
|
||||||
self.assertEqual(MODULE.preferred_device(), 'cuda')
|
|
||||||
with patch.dict('sys.modules', torch=SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False))):
|
|
||||||
self.assertEqual(MODULE.preferred_device(), 'cpu')
|
|
||||||
|
|
||||||
def test_preload_models_warms_embedding_and_reranking_from_model_cache(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
cache = Path(temporary) / 'model-cache'
|
|
||||||
calls = []
|
|
||||||
encoder = SimpleNamespace(encode=lambda values, **kwargs: calls.append(('encode', values, kwargs)) or [embedding()])
|
|
||||||
reranker = SimpleNamespace(predict=lambda pairs, **kwargs: calls.append(('predict', pairs, kwargs)) or [0.0])
|
|
||||||
|
|
||||||
with patch.object(MODULE, 'shared_encoder', return_value=encoder), \
|
|
||||||
patch.object(MODULE, 'shared_reranker', return_value=reranker), \
|
|
||||||
patch.dict(os.environ, {'HF_HOME': 'other-cache'}):
|
|
||||||
getattr(MODULE, 'preload_models', lambda _: None)(cache)
|
|
||||||
self.assertEqual(str(cache.resolve()), os.environ['HF_HOME'])
|
|
||||||
|
|
||||||
self.assertTrue(cache.is_dir())
|
|
||||||
self.assertEqual([
|
|
||||||
('encode', ['Verify semantic retrieval models.'], {'prompt_name': 'query'}),
|
|
||||||
('predict', [('Verify semantic retrieval models.', 'Verify semantic retrieval models.')], {'show_progress_bar': False}),
|
|
||||||
], calls)
|
|
||||||
|
|
||||||
@unittest.skipUnless(os.environ.get('QWEN_TEST_MODEL_CACHE'), 'Set QWEN_TEST_MODEL_CACHE to run the real-model check.')
|
|
||||||
def test_real_models(self):
|
|
||||||
if not huggingface_available():
|
|
||||||
self.skipTest('huggingface.co DNS is unavailable')
|
|
||||||
os.environ['HF_HOME'] = os.environ['QWEN_TEST_MODEL_CACHE']
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
initialize_repository(root)
|
|
||||||
(root / 'authentication.md').write_text('Password reset tokens are created by create_reset_token. The reset_password endpoint sends the user a password reset email.', encoding='utf-8')
|
|
||||||
(root / 'weather.md').write_text('The weather service fetches temperature and rainfall forecasts for a city.', encoding='utf-8')
|
|
||||||
index = MODULE.Index(root, root / 'data')
|
|
||||||
self.addCleanup(index.db.close)
|
|
||||||
try:
|
|
||||||
tokenizer = index.encoder().tokenizer
|
|
||||||
for source in ['alpha beta gamma ' * 450, 'Grüße 世界 🙂 ' * 300]:
|
|
||||||
windows = list(MODULE.chunks(source, 'sample.txt', tokenizer))
|
|
||||||
self.assertGreater(len(windows), 1)
|
|
||||||
self.assertTrue(all(len(tokenizer(value, add_special_tokens=False)['input_ids']) <= 400
|
|
||||||
for _, value in windows))
|
|
||||||
self.assertTrue(all('\ufffd' not in value for _, value in windows))
|
|
||||||
for query, expected in [('Where are password reset tokens created?', 'authentication.md'),
|
|
||||||
('Which service provides rainfall forecasts?', 'weather.md')]:
|
|
||||||
started = time.monotonic()
|
|
||||||
results = index.search(query, 2)
|
|
||||||
self.assertEqual(results[0]['path'], expected)
|
|
||||||
self.assertEqual(len(results), 2)
|
|
||||||
self.assertGreater(results[0]['score'], results[1]['score'])
|
|
||||||
print(f'Qwen real search: {expected}, {time.monotonic() - started:.2f}s, embedding device {index.encoder().device}, reranker device {index.reranker().device}', flush=True)
|
|
||||||
except OSError as error:
|
|
||||||
if huggingface_network_unavailable(error):
|
|
||||||
self.skipTest('Hugging Face network is unavailable')
|
|
||||||
raise
|
|
||||||
|
|
||||||
def test_isolation_freshness_and_incremental_encoding(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
first, second = root / 'first', root / 'second'
|
|
||||||
first.mkdir()
|
|
||||||
second.mkdir()
|
|
||||||
initialize_repository(first)
|
|
||||||
initialize_repository(second)
|
|
||||||
file = first / 'a.md'
|
|
||||||
file.write_text('alpha', encoding='utf-8')
|
|
||||||
(second / 'a.md').write_text('other', encoding='utf-8')
|
|
||||||
indexes = [MODULE.Index(repository, root / 'data') for repository in (first, second)]
|
|
||||||
encoded = []
|
|
||||||
|
|
||||||
def encode(values, **kwargs):
|
|
||||||
self.assertEqual(kwargs.get('prompt_name'), 'query' if values == ['query'] else None)
|
|
||||||
encoded.extend(values)
|
|
||||||
return [embedding() for _ in values]
|
|
||||||
|
|
||||||
for index in indexes:
|
|
||||||
index.encoder = lambda: SimpleNamespace(encode=encode, tokenizer=tokenize)
|
|
||||||
index.rerank = lambda query, rows, limit: rows[:limit]
|
|
||||||
numpy = fake_numpy()
|
|
||||||
usearch, usearch_index = fake_usearch()
|
|
||||||
try:
|
|
||||||
with patch.dict('sys.modules', numpy=numpy, usearch=usearch, **{'usearch.index': usearch_index}):
|
|
||||||
self.assertEqual(indexes[0].search('query', 5)[0]['text'], 'alpha')
|
|
||||||
self.assertEqual(indexes[1].search('query', 5)[0]['text'], 'other')
|
|
||||||
encoded.clear()
|
|
||||||
with patch.object(Path, 'read_bytes', side_effect=AssertionError('unchanged files must not be read')):
|
|
||||||
self.assertEqual(indexes[0].search('query', 5)[0]['text'], 'alpha')
|
|
||||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
||||||
self.assertEqual(executor.submit(indexes[0].search, 'query', 5).result()[0]['text'], 'alpha')
|
|
||||||
self.assertEqual(encoded, [])
|
|
||||||
file.write_text('bravo', encoding='utf-8')
|
|
||||||
self.assertEqual(indexes[0].search('query', 5)[0]['text'], 'bravo')
|
|
||||||
file.rename(first / 'renamed.md')
|
|
||||||
self.assertEqual(indexes[0].search('query', 5)[0]['path'], 'renamed.md')
|
|
||||||
(first / 'renamed.md').unlink()
|
|
||||||
encoded.clear()
|
|
||||||
self.assertEqual(indexes[0].search('query', 5), [])
|
|
||||||
self.assertEqual(indexes[0].search('query', 5), [])
|
|
||||||
self.assertEqual(encoded, [])
|
|
||||||
self.assertEqual(indexes[1].search('query', 5)[0]['text'], 'other')
|
|
||||||
finally:
|
|
||||||
for index in indexes:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
def test_failed_refresh_preserves_previous_index(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
initialize_repository(root)
|
|
||||||
(root / 'a.md').write_text('before', encoding='utf-8')
|
|
||||||
index = MODULE.Index(root, root / 'data')
|
|
||||||
index.encoder = lambda: SimpleNamespace(encode=lambda values, **kwargs: [embedding() for _ in values], tokenizer=tokenize)
|
|
||||||
try:
|
|
||||||
index.rebuild()
|
|
||||||
(root / 'a.md').write_text('after', encoding='utf-8')
|
|
||||||
(root / 'b.md').write_text('fail', encoding='utf-8')
|
|
||||||
|
|
||||||
def encode(values, **kwargs):
|
|
||||||
if any(value.endswith('\n\nfail') for value in values):
|
|
||||||
raise RuntimeError('encoder failed')
|
|
||||||
return [embedding() for _ in values]
|
|
||||||
|
|
||||||
index.encoder = lambda: SimpleNamespace(encode=encode, tokenizer=tokenize)
|
|
||||||
with self.assertRaisesRegex(RuntimeError, 'encoder failed'):
|
|
||||||
index.rebuild()
|
|
||||||
self.assertEqual(index.db.execute('select path, text from chunks').fetchall(), [('a.md', 'before')])
|
|
||||||
self.assertEqual(index.db.execute('select document from lexical').fetchall(),
|
|
||||||
[('Path: a.md\nSymbol: <module>\n\nbefore',)])
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
def test_refresh_batches_embeddings_for_changed_files(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
initialize_repository(root)
|
|
||||||
(root / 'first.md').write_text('first', encoding='utf-8')
|
|
||||||
(root / 'second.md').write_text('second', encoding='utf-8')
|
|
||||||
index = MODULE.Index(root, root / 'data')
|
|
||||||
encoded = []
|
|
||||||
|
|
||||||
def encode(values, **kwargs):
|
|
||||||
encoded.append(list(values))
|
|
||||||
return [embedding() for _ in values]
|
|
||||||
|
|
||||||
index.encoder = lambda: SimpleNamespace(encode=encode, tokenizer=tokenize)
|
|
||||||
try:
|
|
||||||
index.rebuild()
|
|
||||||
self.assertEqual(len(encoded), 1)
|
|
||||||
self.assertEqual(len(encoded[0]), 2)
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
def test_scan_prunes_ignored_directories_and_warns_at_limit(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
initialize_repository(root)
|
|
||||||
index = MODULE.Index(root, root / 'data')
|
|
||||||
(root / 'visible.md').write_text('visible', encoding='utf-8')
|
|
||||||
(root / '.gitignore').write_text('secrets.json\n**/private/\n', encoding='utf-8')
|
|
||||||
(root / 'secrets.json').write_text('{"token":"hidden"}', encoding='utf-8')
|
|
||||||
private = root / 'nested' / 'private'
|
|
||||||
private.mkdir(parents=True)
|
|
||||||
(private / 'hidden.md').write_text('hidden', encoding='utf-8')
|
|
||||||
for name in ('node_modules', '.git', 'generated', 'data'):
|
|
||||||
directory = root / name
|
|
||||||
directory.mkdir(exist_ok=True)
|
|
||||||
(directory / 'hidden.md').write_text('hidden', encoding='utf-8')
|
|
||||||
try:
|
|
||||||
self.assertEqual(index.files(), [(root / 'visible.md').resolve()])
|
|
||||||
filenames = ['visible.md'] * 2001
|
|
||||||
with patch.object(MODULE.subprocess, 'run', return_value=SimpleNamespace(stdout=b'visible.md\0' * 2001)), \
|
|
||||||
patch.object(MODULE.sys, 'stderr') as stderr:
|
|
||||||
self.assertEqual(len(index.files()), 2000)
|
|
||||||
self.assertIn('stopped scanning after 2000', stderr.write.call_args_list[0].args[0])
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
def test_status_reports_index_freshness_without_creating_an_index(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root, data = Path(temporary), Path(temporary) / 'data'
|
|
||||||
initialize_repository(root)
|
|
||||||
source = root / 'module.py'
|
|
||||||
source.write_text('value = 1\n', encoding='utf-8')
|
|
||||||
status = MODULE.index_status(root, data)
|
|
||||||
self.assertEqual((status['indexable_files'], status['indexed_files'], status['stale_files']), (1, 0, 1))
|
|
||||||
self.assertFalse(status['models_loaded'])
|
|
||||||
self.assertFalse(MODULE.index_database(root, data).exists())
|
|
||||||
index = MODULE.Index(root, data)
|
|
||||||
try:
|
|
||||||
metadata = source.stat()
|
|
||||||
with index.db:
|
|
||||||
index.db.execute('insert into files values (?, ?, ?, ?, ?)',
|
|
||||||
('module.py', 'digest', metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns))
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
self.assertEqual(MODULE.index_status(root, data)['stale_files'], 0)
|
|
||||||
source.write_text('value = 22\n', encoding='utf-8')
|
|
||||||
self.assertEqual(MODULE.index_status(root, data)['stale_files'], 1)
|
|
||||||
|
|
||||||
def test_max_files_is_configurable_via_environment(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
initialize_repository(root)
|
|
||||||
index = MODULE.Index(root, root / 'data')
|
|
||||||
try:
|
|
||||||
with patch.dict(os.environ, {MODULE.MAX_FILES_ENV: '3'}):
|
|
||||||
self.assertEqual(MODULE.read_max_files(), 3)
|
|
||||||
with patch.dict(os.environ, {MODULE.MAX_FILES_ENV: 'not-a-number'}):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
MODULE.read_max_files()
|
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
|
||||||
self.assertEqual(MODULE.read_max_files(), MODULE.DEFAULT_MAX_FILES)
|
|
||||||
(root / 'visible.md').write_text('visible', encoding='utf-8')
|
|
||||||
index.max_files = 3
|
|
||||||
for number in range(5):
|
|
||||||
(root / f'{number}.md').write_text('visible', encoding='utf-8')
|
|
||||||
with patch.object(MODULE.sys, 'stderr'):
|
|
||||||
self.assertEqual(len(index.files()), 3)
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
def test_daemon_address_is_deterministic_and_platform_specific(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
data_dir = Path(temporary)
|
|
||||||
first = MODULE.daemon_address(data_dir)
|
|
||||||
second = MODULE.daemon_address(data_dir)
|
|
||||||
self.assertEqual(first, second)
|
|
||||||
if os.name == 'nt':
|
|
||||||
self.assertTrue(first.startswith(r'\\.\pipe\ai-config-retrieval-'))
|
|
||||||
else:
|
|
||||||
self.assertTrue(first.startswith(str(data_dir)))
|
|
||||||
|
|
||||||
def test_shared_encoder_and_reranker_are_cached_module_singletons(self):
|
|
||||||
with patch.object(MODULE, '_shared_encoder', None), patch.object(MODULE, '_shared_reranker', None), \
|
|
||||||
patch.object(MODULE, 'preferred_device', lambda: 'cpu'), patch.object(MODULE, 'model_kwargs', dict):
|
|
||||||
sentinel = SimpleNamespace()
|
|
||||||
with patch.dict('sys.modules', sentence_transformers=SimpleNamespace(
|
|
||||||
SentenceTransformer=lambda *args, **kwargs: sentinel,
|
|
||||||
CrossEncoder=lambda *args, **kwargs: sentinel)):
|
|
||||||
self.assertIs(MODULE.shared_encoder(), sentinel)
|
|
||||||
self.assertIs(MODULE.shared_encoder(), MODULE.shared_encoder())
|
|
||||||
self.assertIs(MODULE.shared_reranker(), sentinel)
|
|
||||||
self.assertIs(MODULE.shared_reranker(), MODULE.shared_reranker())
|
|
||||||
|
|
||||||
def test_rerank_uses_cross_encoder_scores(self):
|
|
||||||
index = MODULE.Index.__new__(MODULE.Index)
|
|
||||||
|
|
||||||
class Reranker:
|
|
||||||
def predict(self, pairs, **kwargs):
|
|
||||||
self.pairs = pairs
|
|
||||||
return [0.1, 0.8, 0.5]
|
|
||||||
|
|
||||||
reranker = Reranker()
|
|
||||||
index.reranker = lambda: reranker
|
|
||||||
rows = [
|
|
||||||
{'path': 'a.md', 'symbol': 'A', 'score': 0.9, 'text': 'first'},
|
|
||||||
{'path': 'b.md', 'symbol': 'B', 'score': 0.8, 'text': 'second'},
|
|
||||||
{'path': 'c.md', 'symbol': 'C', 'score': 0.7, 'text': 'third'},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = index.rerank('query', rows, 2)
|
|
||||||
|
|
||||||
self.assertEqual([item['path'] for item in result], ['b.md', 'c.md'])
|
|
||||||
self.assertEqual(reranker.pairs, [('query', 'Path: a.md\nSymbol: A\n\nfirst'),
|
|
||||||
('query', 'Path: b.md\nSymbol: B\n\nsecond'),
|
|
||||||
('query', 'Path: c.md\nSymbol: C\n\nthird')])
|
|
||||||
|
|
||||||
def test_token_windows_have_exact_overlap_and_no_redundant_tail(self):
|
|
||||||
for size, lengths in [(0, []), (400, [400]), (401, [400, 65]), (736, [400, 400]), (800, [400, 400, 128])]:
|
|
||||||
words = [f'token{number}' for number in range(size)]
|
|
||||||
result = list(MODULE.chunks(' '.join(words), 'plain.txt', tokenize))
|
|
||||||
windows = [content.split() for _, content in result]
|
|
||||||
self.assertEqual([len(window) for window in windows], lengths)
|
|
||||||
for first, second in zip(windows, windows[1:]):
|
|
||||||
self.assertEqual(first[-64:], second[:64])
|
|
||||||
if windows:
|
|
||||||
reconstructed = windows[0] + [word for window in windows[1:] for word in window[64:]]
|
|
||||||
self.assertEqual(reconstructed, words)
|
|
||||||
|
|
||||||
def test_python_symbols_include_decorators_and_restore_parent(self):
|
|
||||||
source = 'class Service:\n @cached\n def load(self):\n return 1\n\nvalue = 2\n\nasync def refresh():\n return value\n'
|
|
||||||
result = list(MODULE.sections(source, 'service.py'))
|
|
||||||
self.assertEqual([symbol for symbol, _ in result], ['Service', 'Service.load', '<module>', 'refresh'])
|
|
||||||
self.assertIn('@cached', result[1][1])
|
|
||||||
self.assertEqual(''.join(content for _, content in result), source)
|
|
||||||
broken = 'def incomplete(\n' + 'word ' * 450
|
|
||||||
self.assertEqual(len(list(MODULE.chunks(broken, 'broken.py', tokenize))), 2)
|
|
||||||
|
|
||||||
def test_markdown_sections_ignore_fenced_headings(self):
|
|
||||||
source = '# Setup\nIntro\n## Install\n```sh\n# not a heading\n```\nSteps\n## Run\nStart\n'
|
|
||||||
result = list(MODULE.chunks(source, 'guide.md', tokenize))
|
|
||||||
self.assertEqual([symbol for symbol, _ in result], ['Setup', 'Setup / Install', 'Setup / Run'])
|
|
||||||
self.assertIn('# not a heading', result[1][1])
|
|
||||||
|
|
||||||
def test_common_declarations_and_config_sections(self):
|
|
||||||
for path, source, symbols in [
|
|
||||||
('service.ts', 'export function load() {\n return 1;\n}\nexport const save = () => 2;\n', ['load', 'save']),
|
|
||||||
('service.cs', 'public class Service\n{\n public void Load()\n {\n }\n}\n', ['Service', 'Load']),
|
|
||||||
('setup.ps1', 'function Get-Result {\n return 1\n}\n', ['Get-Result']),
|
|
||||||
('build.sh', 'quote() {\n echo "$1"\n}\nfor shell in a b; do\n quote "$shell"\ndone\n', ['quote', '<module>']),
|
|
||||||
('schema.sql', 'CREATE TABLE users (id int);\nCREATE OR ALTER PROCEDURE dbo.GetUser AS SELECT 1;\n', ['users', 'dbo.GetUser']),
|
|
||||||
('Main.kt', 'fun main() {\n println(1)\n}\n', ['main']),
|
|
||||||
('config.toml', '[server]\nport = 1\n[client]\nport = 2\n', ['server', 'client']),
|
|
||||||
]:
|
|
||||||
self.assertEqual([symbol for symbol, _ in MODULE.sections(source, path)], symbols)
|
|
||||||
|
|
||||||
def test_rrf_rewards_agreement_and_breaks_ties_deterministically(self):
|
|
||||||
self.assertEqual(MODULE.reciprocal_rank_fusion([1, 2, 3], [4, 2, 3]), [2, 3, 1, 4])
|
|
||||||
self.assertEqual(MODULE.reciprocal_rank_fusion([], [4, 2]), [4, 2])
|
|
||||||
|
|
||||||
def test_dense_candidates_converts_vector_keys_for_sqlite(self):
|
|
||||||
class VectorKey:
|
|
||||||
def __int__(self):
|
|
||||||
return 9
|
|
||||||
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
index = MODULE.Index(root, root / 'data')
|
|
||||||
index.vector_index = SimpleNamespace(search=lambda vector, count: [SimpleNamespace(key=VectorKey())])
|
|
||||||
usearch, usearch_index = fake_usearch()
|
|
||||||
try:
|
|
||||||
with patch.dict('sys.modules', numpy=fake_numpy(), usearch=usearch,
|
|
||||||
**{'usearch.index': usearch_index}):
|
|
||||||
candidates = index.dense_candidates(embedding())
|
|
||||||
self.assertEqual(candidates, [9])
|
|
||||||
self.assertIs(type(candidates[0]), int)
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
def test_hybrid_candidates_and_document_metadata(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
initialize_repository(root)
|
|
||||||
for number in range(110):
|
|
||||||
(root / f'{number:03}.md').write_text(f'# Symbol{number}\ncontent {number}', encoding='utf-8', newline='\n')
|
|
||||||
index = MODULE.Index(root, root / 'data')
|
|
||||||
documents = []
|
|
||||||
|
|
||||||
def encode(values, **kwargs):
|
|
||||||
if kwargs.get('prompt_name') != 'query':
|
|
||||||
documents.extend(values)
|
|
||||||
return [embedding() for _ in values]
|
|
||||||
|
|
||||||
pairs = []
|
|
||||||
index.encoder = lambda: SimpleNamespace(encode=encode, tokenizer=tokenize)
|
|
||||||
index.reranker = lambda: SimpleNamespace(predict=lambda values, **kwargs:
|
|
||||||
pairs.extend(values) or [float('109.md' in document) for _, document in values])
|
|
||||||
numpy = fake_numpy()
|
|
||||||
original_fusion = MODULE.reciprocal_rank_fusion
|
|
||||||
rankings = []
|
|
||||||
|
|
||||||
def fuse(*values):
|
|
||||||
rankings.extend(values)
|
|
||||||
return original_fusion(*values)
|
|
||||||
|
|
||||||
try:
|
|
||||||
usearch, usearch_index = fake_usearch()
|
|
||||||
with patch.dict('sys.modules', numpy=numpy, usearch=usearch, **{'usearch.index': usearch_index}), \
|
|
||||||
patch.object(MODULE, 'reciprocal_rank_fusion', fuse):
|
|
||||||
results = index.search('109.md Symbol109 content', 20)
|
|
||||||
self.assertEqual([len(ranking) for ranking in rankings], [50, 50])
|
|
||||||
self.assertEqual(len(pairs), 50)
|
|
||||||
self.assertEqual(len(results), 5)
|
|
||||||
self.assertEqual(results[0]['path'], '109.md')
|
|
||||||
self.assertEqual(results[0]['symbol'], 'Symbol109')
|
|
||||||
self.assertIn('Path: 109.md\nSymbol: Symbol109\n\n# Symbol109\ncontent 109', documents)
|
|
||||||
self.assertTrue(all(document in documents for _, document in pairs))
|
|
||||||
with patch.dict('sys.modules', numpy=numpy, usearch=usearch, **{'usearch.index': usearch_index}):
|
|
||||||
self.assertEqual(len(index.search('" OR * () : -')), 5)
|
|
||||||
self.assertEqual(len(index.search('***')), 5)
|
|
||||||
(root / '109.md').unlink()
|
|
||||||
index.search('Symbol109')
|
|
||||||
self.assertEqual(index.db.execute('select count(*) from lexical where lexical match ?', ('"Symbol109"',)).fetchone()[0], 0)
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
def test_old_cache_is_preserved_but_not_reused(self):
|
|
||||||
with TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
data = root / 'data'
|
|
||||||
data.mkdir()
|
|
||||||
identity = os.path.normcase(str(root.resolve())) + '\n' + MODULE.MODEL
|
|
||||||
old = data / (MODULE.hashlib.sha256(identity.encode()).hexdigest() + '.sqlite3')
|
|
||||||
old.write_bytes(b'old cache')
|
|
||||||
index = MODULE.Index(root, data)
|
|
||||||
try:
|
|
||||||
self.assertNotEqual(Path(index.db.execute('pragma database_list').fetchone()[2]), old)
|
|
||||||
self.assertEqual(old.read_bytes(), b'old cache')
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
from contextlib import redirect_stdout
|
|
||||||
from pathlib import Path
|
|
||||||
import py_compile
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
SPEC = importlib.util.spec_from_file_location('semantic_retrieval', ROOT / 'scripts/lib/semantic-retrieval.py')
|
|
||||||
MODULE = importlib.util.module_from_spec(SPEC)
|
|
||||||
SPEC.loader.exec_module(MODULE)
|
|
||||||
|
|
||||||
|
|
||||||
class SemanticRetrievalTests(unittest.TestCase):
|
|
||||||
def test_sync_does_not_register_mcp(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
home = Path(directory) / 'home'
|
|
||||||
output = io.StringIO()
|
|
||||||
with redirect_stdout(output):
|
|
||||||
MODULE.sync(ROOT, home, ['codex', 'claude'], True, True, False)
|
|
||||||
self.assertNotIn('mcp', output.getvalue().lower())
|
|
||||||
|
|
||||||
def test_runtime_prefers_cuda_packages_for_nvidia_gpu(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
target = Path(directory)
|
|
||||||
python = MODULE.python_path(target / '.venv')
|
|
||||||
python.parent.mkdir(parents=True)
|
|
||||||
python.touch()
|
|
||||||
with patch.object(MODULE.shutil, 'which', return_value='nvidia-smi'), patch.object(MODULE.subprocess, 'run') as run:
|
|
||||||
MODULE.ensure_runtime(target, False)
|
|
||||||
|
|
||||||
self.assertEqual(run.call_args.args[0][-2:], ['--extra-index-url', MODULE.CUDA_INDEX])
|
|
||||||
|
|
||||||
def test_runtime_falls_back_to_cpu_packages_when_cuda_install_fails(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
target = Path(directory)
|
|
||||||
python = MODULE.python_path(target / '.venv')
|
|
||||||
python.parent.mkdir(parents=True)
|
|
||||||
python.touch()
|
|
||||||
failure = MODULE.subprocess.CalledProcessError(1, 'pip')
|
|
||||||
with patch.object(MODULE.shutil, 'which', return_value='nvidia-smi'), patch.object(MODULE.subprocess, 'run', side_effect=[failure, None]) as run:
|
|
||||||
MODULE.ensure_runtime(target, False)
|
|
||||||
|
|
||||||
self.assertEqual(run.call_count, 2)
|
|
||||||
self.assertNotIn('--extra-index-url', run.call_args.args[0])
|
|
||||||
|
|
||||||
def test_sync_preloads_models_after_installing_runtime(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
home = Path(directory) / 'home'
|
|
||||||
target = home / '.my-ai-configuration/semantic-retrieval'
|
|
||||||
python = MODULE.python_path(target / '.venv')
|
|
||||||
with patch.object(MODULE, 'ensure_runtime', return_value=python), \
|
|
||||||
patch.object(MODULE.subprocess, 'run') as run:
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], True, False, False)
|
|
||||||
|
|
||||||
self.assertEqual([[
|
|
||||||
str(python), str(target / 'server.py'), 'preload', '--model-cache', str(target / 'model-cache')
|
|
||||||
]], [call.args[0] for call in run.call_args_list])
|
|
||||||
|
|
||||||
def test_summary_dry_run_omits_details_without_writes(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
home = Path(directory) / 'home'
|
|
||||||
for enabled in (True, False):
|
|
||||||
output = io.StringIO()
|
|
||||||
with redirect_stdout(output):
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], enabled, True, False, summary=True)
|
|
||||||
self.assertEqual(output.getvalue(), '')
|
|
||||||
self.assertFalse(home.exists())
|
|
||||||
|
|
||||||
def test_disable_removes_owned_runtime_without_cli_registration(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
home = Path(directory) / 'home'
|
|
||||||
target = home / '.my-ai-configuration/semantic-retrieval'
|
|
||||||
target.mkdir(parents=True)
|
|
||||||
for name in MODULE.FILES:
|
|
||||||
(target / name).write_text(name)
|
|
||||||
state_path = target.parent / 'semantic-retrieval.json'
|
|
||||||
state_path.write_text(json.dumps({'version': 1, 'clients': ['codex'], 'files': {
|
|
||||||
name: hashlib.sha256((target / name).read_bytes()).hexdigest() for name in MODULE.FILES}}))
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], False, False, False)
|
|
||||||
self.assertFalse(target.exists())
|
|
||||||
self.assertFalse(state_path.exists())
|
|
||||||
|
|
||||||
def test_update_accepts_python_cache_but_rejects_foreign_cache_content(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
home = Path(directory) / 'home'
|
|
||||||
target = home / '.my-ai-configuration/semantic-retrieval'
|
|
||||||
target.mkdir(parents=True)
|
|
||||||
files = {}
|
|
||||||
for name in MODULE.FILES:
|
|
||||||
content = (ROOT / 'shared/retrieval' / name).read_bytes()
|
|
||||||
(target / name).write_bytes(content)
|
|
||||||
files[name] = hashlib.sha256(content).hexdigest()
|
|
||||||
MODULE.save_state(target.parent / 'semantic-retrieval.json', {'version': 1, 'clients': ['codex'], 'files': files}, False)
|
|
||||||
py_compile.compile(str(target / 'server.py'), doraise=True)
|
|
||||||
cache = target / '__pycache__'
|
|
||||||
with patch.object(MODULE, 'ensure_runtime', return_value=Path('python')), patch.object(MODULE, 'ensure_models'):
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], True, False, True)
|
|
||||||
(cache / 'foreign.txt').write_text('keep')
|
|
||||||
with self.assertRaisesRegex(ValueError, '__pycache__'):
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], True, False, True)
|
|
||||||
|
|
||||||
def test_install_search_update_and_remove_owned_data(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
home = Path(directory) / 'home'
|
|
||||||
repository = Path(directory) / 'repository'
|
|
||||||
repository.mkdir()
|
|
||||||
subprocess.run(['git', '-C', str(repository), 'init', '--quiet'], check=True)
|
|
||||||
target = home / '.my-ai-configuration/semantic-retrieval'
|
|
||||||
state_path = target.parent / 'semantic-retrieval.json'
|
|
||||||
with patch.object(MODULE, 'ensure_runtime', return_value=Path('python')), \
|
|
||||||
patch.object(MODULE, 'ensure_models'):
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], True, False, False)
|
|
||||||
installed = importlib.util.spec_from_file_location('installed_retrieval', target / 'server.py')
|
|
||||||
server = importlib.util.module_from_spec(installed)
|
|
||||||
installed.loader.exec_module(server)
|
|
||||||
index = server.Index(repository, target / 'data')
|
|
||||||
try:
|
|
||||||
self.assertEqual(index.search('query'), [])
|
|
||||||
finally:
|
|
||||||
index.db.close()
|
|
||||||
self.assertEqual(len(list((target / 'data').glob('*.sqlite3'))), 1)
|
|
||||||
server.daemon_authkey(target / 'data')
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], True, False, True)
|
|
||||||
self.assertTrue((target / 'data').is_dir())
|
|
||||||
foreign = target / 'data' / 'foreign.txt'
|
|
||||||
foreign.write_text('keep')
|
|
||||||
with self.assertRaisesRegex(ValueError, 'data'):
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], False, False, False)
|
|
||||||
self.assertTrue(foreign.exists())
|
|
||||||
foreign.unlink()
|
|
||||||
MODULE.sync(ROOT, home, ['codex'], False, False, False)
|
|
||||||
self.assertFalse(target.exists())
|
|
||||||
self.assertFalse(state_path.exists())
|
|
||||||
|
|
||||||
def test_negative_benchmark_keeps_active_runtime(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
home = Path(directory) / 'home'
|
|
||||||
target = home / '.my-ai-configuration/semantic-retrieval'
|
|
||||||
target.mkdir(parents=True)
|
|
||||||
state_path = target.parent / 'semantic-retrieval.json'
|
|
||||||
files = {}
|
|
||||||
for name in MODULE.FILES:
|
|
||||||
content = (ROOT / 'shared/retrieval' / name).read_bytes()
|
|
||||||
(target / name).write_bytes(content)
|
|
||||||
files[name] = hashlib.sha256(content).hexdigest()
|
|
||||||
MODULE.save_state(state_path, {'version': 1, 'clients': ['codex'], 'files': files}, False)
|
|
||||||
result = type('Result', (), {'stdout': '{"recommended": false, "embeddingSeconds": 1, "rerankingSeconds": 1}'})()
|
|
||||||
with patch.object(MODULE, 'ensure_runtime', return_value=Path('python')), \
|
|
||||||
patch.object(MODULE.subprocess, 'run', return_value=result):
|
|
||||||
self.assertFalse(MODULE.benchmark(ROOT, home, False, True))
|
|
||||||
self.assertTrue(target.is_dir())
|
|
||||||
self.assertEqual(MODULE.load_state(state_path)['clients'], ['codex'])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/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 </dev/null &
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// 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\n` + (`This 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 embedThroughput({ chunksEmbedded, durationMs, errors }) {
|
||||||
|
return { chunksPerSecond: chunksEmbedded / Math.max(durationMs / 1000, 0.001), ok: !errors && chunksEmbedded > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
try {
|
||||||
|
const gpu = llama.gpu;
|
||||||
|
const devices = gpu ? await llama.getGpuDeviceNames() : [];
|
||||||
|
return { gpu, devices, threads: availableParallelism() };
|
||||||
|
} finally {
|
||||||
|
await llama.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.embed({ collection: 'bench' }); // warm-up: loads (and on a first run downloads) the model
|
||||||
|
const embedded = await store.embed({ collection: 'bench', force: true });
|
||||||
|
const { chunksPerSecond, ok } = embedThroughput(embedded);
|
||||||
|
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)), embedErrors: embedded.errors };
|
||||||
|
return { ...result, passed: ok && 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); });
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// shared/qmd/qmd-config.mjs — edits only the models: block and setup-owned collection ignore lists in QMD's index.yml.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { MODELS, qmdConfigPath, qmdPackageDir, writeTextAtomic } 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 previousModels(text, YAML) {
|
||||||
|
const document = YAML.parseDocument(text || '');
|
||||||
|
if (document.errors.length) throw new Error(`index.yml cannot be parsed: ${document.errors[0].message}`);
|
||||||
|
return document.contents === null ? null : document.toJS().models ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreModels(text, YAML, mapping) {
|
||||||
|
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(mapping));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the new text, or null when index.yml has no such collection (QMD's write-through always creates it).
|
||||||
|
export function setCollectionIgnore(text, YAML, name, ignore) {
|
||||||
|
const document = YAML.parseDocument(text || '');
|
||||||
|
if (document.errors.length) throw new Error(`index.yml cannot be parsed: ${document.errors[0].message}`);
|
||||||
|
if (!document.hasIn(['collections', name])) return null;
|
||||||
|
if (ignore.length) document.setIn(['collections', name, 'ignore'], document.createNode(ignore));
|
||||||
|
else document.deleteIn(['collections', name, 'ignore']);
|
||||||
|
return document.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(action, argument) {
|
||||||
|
if (!['set-models', 'unset-models', 'restore-models'].includes(action)) throw new Error('usage: qmd-config.mjs set-models|unset-models|restore-models <json>');
|
||||||
|
const YAML = await import(pathToFileURL(join(qmdPackageDir(), 'node_modules', 'yaml', 'dist', 'index.js')).href);
|
||||||
|
const path = qmdConfigPath();
|
||||||
|
const current = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
||||||
|
let next;
|
||||||
|
if (action === 'set-models') {
|
||||||
|
const previous = previousModels(current, YAML);
|
||||||
|
// A block equal to ours is a leftover of an interrupted run, not the user's own.
|
||||||
|
console.log(JSON.stringify({ previous: JSON.stringify(previous) === JSON.stringify(MODELS) ? null : previous }));
|
||||||
|
next = setModels(current, YAML);
|
||||||
|
} else if (action === 'restore-models') {
|
||||||
|
const mapping = JSON.parse(argument ?? '');
|
||||||
|
if (mapping === undefined) throw new Error('restore-models needs a JSON value');
|
||||||
|
next = restoreModels(current, YAML, mapping);
|
||||||
|
} else next = unsetModels(current, YAML);
|
||||||
|
writeTextAtomic(path, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||||
|
main(process.argv[2], process.argv[3]).catch((error) => { console.error(`qmd-config: ${error.message}`); process.exit(1); });
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// shared/qmd/qmd-daemon.mjs — keeps QMD's models loaded for fast searches; exits when idle.
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||||
|
import { createServer as createHttpServer } from 'node:http';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { setCollectionIgnore } from './qmd-config.mjs';
|
||||||
|
import { MASK, collectionName, idleMinutes, importQmd, qmdConfigPath, qmdDbPath, readJson, runQmdCli, stateDir, writePrivateJson, writeTextAtomic } from './qmd-lib.mjs';
|
||||||
|
|
||||||
|
const MAX_BODY = 64 * 1024;
|
||||||
|
|
||||||
|
// Dirty files keep the same porcelain line when edited again, so mtime and size of each listed path are part of the fingerprint.
|
||||||
|
export function gitFingerprint(repo) {
|
||||||
|
try {
|
||||||
|
const status = execFileSync('git', ['-C', repo, 'status', '--porcelain=v1', '-uall', '-z'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true });
|
||||||
|
const stats = status.split('\0').filter((entry) => entry.length > 3).map((entry) => {
|
||||||
|
try {
|
||||||
|
const stat = statSync(join(repo, entry.slice(3)));
|
||||||
|
return `${entry}|${stat.mtimeMs}:${stat.size}`;
|
||||||
|
} catch {
|
||||||
|
return `${entry}|deleted`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return `${stats.join('\n')}\n` + execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true });
|
||||||
|
} catch {
|
||||||
|
return String(Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// git ls-files -z --directory output: ignored directories end with '/' and become 'dir/**'; escape quotes glob syntax in names.
|
||||||
|
export function ignoreGlobs(output, escape) {
|
||||||
|
return output.split('\0').filter(Boolean).map((path) => (path.endsWith('/') ? `${escape(path.slice(0, -1))}/**` : escape(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gitIgnored(repo, escape) {
|
||||||
|
try {
|
||||||
|
return ignoreGlobs(execFileSync('git', ['-C', repo, 'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'],
|
||||||
|
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }), escape);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclusive start lock: returns a release function, or null while another launch holds a lock younger than 2 minutes.
|
||||||
|
// ponytail: two launchers that find the same stale lock can both take it over; the daemon.json health check covers the rest.
|
||||||
|
export function acquireStartLock(path, now = Date.now) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
closeSync(openSync(path, 'wx'));
|
||||||
|
let held = true;
|
||||||
|
return () => {
|
||||||
|
if (held) rmSync(path, { force: true });
|
||||||
|
held = false;
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'EEXIST') throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (now() - statSync(path).mtimeMs < 120_000) return null;
|
||||||
|
rmSync(path, { force: true });
|
||||||
|
} catch { /* the holder released it meanwhile: try again */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// QMD 2.8.3 createStore unloads models after 5 idle minutes (store.internal.llm); the daemon's own idle exit replaces that.
|
||||||
|
export function keepModelsLoaded(store) {
|
||||||
|
const llm = store.internal?.llm;
|
||||||
|
if (!llm) return;
|
||||||
|
llm.inactivityTimeoutMs = 0;
|
||||||
|
llm.disposeModelsOnInactivity = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPO_COLLECTION = /^repo-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
function tokenMatches(given, expected) {
|
||||||
|
const a = Buffer.from(String(given ?? ''));
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer({ store, token, idleMs, now = Date.now, onIdle, extractSnippet, git = gitFingerprint,
|
||||||
|
excludeCollection, recordCollection, ignored, persistIgnore }) {
|
||||||
|
let lastRequest = now();
|
||||||
|
let active = 0;
|
||||||
|
const known = new Set();
|
||||||
|
const fingerprints = new Map();
|
||||||
|
const ignoreKeys = new Map();
|
||||||
|
|
||||||
|
const inFlight = new Map();
|
||||||
|
|
||||||
|
function refresh(repo) {
|
||||||
|
const name = collectionName(repo);
|
||||||
|
if (!inFlight.has(name)) inFlight.set(name, doRefresh(repo, name).finally(() => inFlight.delete(name)));
|
||||||
|
return inFlight.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRefresh(repo, name) {
|
||||||
|
const fingerprint = git(repo);
|
||||||
|
let ignore;
|
||||||
|
if (!known.has(name)) {
|
||||||
|
const existing = (await store.listCollections()).some((c) => c.name === name);
|
||||||
|
if (!existing) {
|
||||||
|
ignore = ignored(repo);
|
||||||
|
await store.addCollection(name, { path: resolve(repo), pattern: MASK, ignore });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await excludeCollection(name);
|
||||||
|
recordCollection(name);
|
||||||
|
known.add(name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`qmd-daemon: could not exclude ${name} from default search: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fingerprints.get(name) === fingerprint) return { collection: name, updated: false };
|
||||||
|
ignore ??= ignored(repo);
|
||||||
|
const ignoreKey = JSON.stringify(ignore);
|
||||||
|
if (ignoreKeys.get(name) !== ignoreKey) {
|
||||||
|
try {
|
||||||
|
await persistIgnore(name, ignore);
|
||||||
|
ignoreKeys.set(name, ignoreKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`qmd-daemon: could not save the git ignore list of ${name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) => !REPO_COLLECTION.test(n));
|
||||||
|
const results = await store.search({ query, collections: [collection, ...defaults], limit: Number.isInteger(limit) ? Math.min(20, Math.max(1, limit)) : 5, chunkStrategy: 'auto' });
|
||||||
|
return results.map((r) => {
|
||||||
|
const { line, snippet } = extractSnippet(r.body, query, 300, r.bestChunkPos, r.bestChunk.length);
|
||||||
|
// displayPath is <collection>/<path>: repo hits become repo-relative, others stay addressable as qmd:// URIs.
|
||||||
|
const path = r.displayPath.startsWith(`${collection}/`) ? r.displayPath.slice(collection.length + 1) : `qmd://${r.displayPath}`;
|
||||||
|
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 (!tokenMatches(request.headers['x-qmd-token'], token)) return reply(403, { error: 'forbidden' });
|
||||||
|
active++;
|
||||||
|
lastRequest = now();
|
||||||
|
try {
|
||||||
|
if (request.method === 'GET' && request.url === '/health') return reply(200, { status: 'ok', service: 'ai-config-qmd', pid: process.pid });
|
||||||
|
request.setEncoding('utf8');
|
||||||
|
let raw = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
raw += chunk;
|
||||||
|
if (raw.length > MAX_BODY) return reply(413, { error: 'body too large' });
|
||||||
|
}
|
||||||
|
let body;
|
||||||
|
try { body = raw ? JSON.parse(raw) : {}; } catch { return reply(400, { error: 'invalid JSON' }); }
|
||||||
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { error: 'body must be an object' });
|
||||||
|
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 {
|
||||||
|
active--;
|
||||||
|
lastRequest = now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkIdle = () => { if (active === 0 && now() - lastRequest >= idleMs) onIdle(); };
|
||||||
|
return { server, refresh, search, checkIdle };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dir = stateDir();
|
||||||
|
const daemonFile = join(dir, 'daemon.json');
|
||||||
|
// The lock comes before the health check, so a launch that waited for another one sees its daemon.json.
|
||||||
|
const releaseLock = acquireStartLock(join(dir, 'daemon.lock'));
|
||||||
|
if (!releaseLock) return; // another launch is starting the daemon
|
||||||
|
process.on('exit', releaseLock);
|
||||||
|
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 releaseLock();
|
||||||
|
} catch { /* stale file: start a new daemon */ }
|
||||||
|
}
|
||||||
|
const { createStore, extractSnippet } = await importQmd();
|
||||||
|
const store = await createStore({ dbPath: qmdDbPath(), configPath: qmdConfigPath() });
|
||||||
|
keepModelsLoaded(store);
|
||||||
|
// QMD keeps index.yml authoritative: createStore and the CLI re-sync it into SQLite, and its addCollection
|
||||||
|
// write-through drops `ignore`, so the list is written into index.yml and re-synced the way QMD does it.
|
||||||
|
const fastGlob = (await importQmd(join('node_modules', 'fast-glob', 'out', 'index.js'))).default;
|
||||||
|
const YAML = await importQmd(join('node_modules', 'yaml', 'dist', 'index.js'));
|
||||||
|
const { loadConfig } = await importQmd(join('dist', 'collections.js'));
|
||||||
|
const { syncConfigToDb } = await importQmd(join('dist', 'store.js'));
|
||||||
|
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 });
|
||||||
|
daemon.server.close();
|
||||||
|
daemon.server.closeIdleConnections();
|
||||||
|
await store.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
const daemon = createServer({
|
||||||
|
store, token, idleMs: idleMinutes() * 60_000, onIdle: stop, extractSnippet,
|
||||||
|
ignored: (repo) => gitIgnored(repo, fastGlob.escapePath),
|
||||||
|
persistIgnore: async (name, ignore) => {
|
||||||
|
const path = qmdConfigPath();
|
||||||
|
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
||||||
|
const next = setCollectionIgnore(text, YAML, name, ignore);
|
||||||
|
if (next === null) return; // never re-sync without the collection: the sync would delete it from SQLite
|
||||||
|
if (next !== text) writeTextAtomic(path, next);
|
||||||
|
syncConfigToDb(store.internal.db, loadConfig());
|
||||||
|
},
|
||||||
|
excludeCollection: async (name) => { runQmdCli(['collection', 'exclude', name], { windowsHide: true, 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 });
|
||||||
|
releaseLock();
|
||||||
|
});
|
||||||
|
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); });
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readers never see a half-written file: write a temp file next to the target, then rename it.
|
||||||
|
export function writeTextAtomic(path, text, options = {}) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const temporary = `${path}.${process.pid}.tmp`;
|
||||||
|
writeFileSync(temporary, text, options);
|
||||||
|
renameSync(temporary, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state directory lives in the user profile; mode 0600 restricts the token on POSIX.
|
||||||
|
export function writePrivateJson(path, value) {
|
||||||
|
writeTextAtomic(path, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedPackageDir;
|
||||||
|
|
||||||
|
export function qmdPackageDir() {
|
||||||
|
if (process.env.QMD_PACKAGE_DIR) return process.env.QMD_PACKAGE_DIR;
|
||||||
|
if (!cachedPackageDir) {
|
||||||
|
const root = execFileSync('npm', ['root', '--global'], { encoding: 'utf8', shell: process.platform === 'win32', windowsHide: true }).trim();
|
||||||
|
cachedPackageDir = join(root, '@tobilu', 'qmd');
|
||||||
|
}
|
||||||
|
return cachedPackageDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importQmd(subpath = join('dist', 'index.js')) {
|
||||||
|
return import(pathToFileURL(join(qmdPackageDir(), subpath)).href);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the QMD CLI through node without a shell, so free-text arguments are never re-parsed.
|
||||||
|
export function runQmdCli(args, options = {}) {
|
||||||
|
return execFileSync(process.execPath, [join(qmdPackageDir(), 'dist', 'cli', 'qmd.js'), ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...options });
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// 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 { rmSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { parseArgs } from 'node:util';
|
||||||
|
import { collectionName, deviceEnv, readJson, runQmdCli, setupStatePath, stateDir } from './qmd-lib.mjs';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const daemonFile = () => join(stateDir(), 'daemon.json');
|
||||||
|
|
||||||
|
export function toCliResults(items, collection) {
|
||||||
|
return items.map((item) => {
|
||||||
|
const prefix = `qmd://${collection}/`;
|
||||||
|
const path = String(item.file || '').startsWith(prefix) ? item.file.slice(prefix.length) : item.file;
|
||||||
|
return { path, line: item.line ?? null, score: item.score, snippet: item.snippet ?? '' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notRunning = (message, cause) => Object.assign(new Error(message, { cause }), { notRunning: true });
|
||||||
|
|
||||||
|
export async function callDaemon(path, body, { file = daemonFile(), fetchImpl = fetch } = {}) {
|
||||||
|
const info = readJson(file, null);
|
||||||
|
if (!info?.port || !info?.token) throw notRunning('daemon not running');
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(`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) });
|
||||||
|
} catch (error) {
|
||||||
|
// fetch rejects with a TypeError when the connection is refused or reset; a timeout is a different error.
|
||||||
|
if (error instanceof TypeError) throw notRunning('daemon not reachable', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
// Whatever listens there does not know this token, so daemon.json is stale.
|
||||||
|
rmSync(file, { force: true });
|
||||||
|
throw notRunning('daemon rejected the token');
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(`daemon answered ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDaemon({ spawnChild = spawn, file = daemonFile() } = {}) {
|
||||||
|
const device = readJson(setupStatePath(), {}).device || 'cpu';
|
||||||
|
const child = spawnChild(process.execPath, [join(here, 'qmd-daemon.mjs')], { detached: true, stdio: 'ignore', windowsHide: true, env: deviceEnv(device) });
|
||||||
|
let failure = null;
|
||||||
|
let lostRace = false; // exit code 0: another launch holds the start lock or a daemon already runs
|
||||||
|
child.once('error', (error) => { failure = error; });
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
if (code === 0) lostRace = true;
|
||||||
|
else failure ||= new Error(`daemon exited with code ${code}`);
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
const deadline = Date.now() + 120_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((done) => setTimeout(done, 500));
|
||||||
|
if (failure) throw failure;
|
||||||
|
const info = readJson(file, null);
|
||||||
|
if (info?.port && info?.token && (lostRace || info.pid === child.pid)) return;
|
||||||
|
}
|
||||||
|
throw new Error('daemon did not start within 120 s');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(args) {
|
||||||
|
const device = readJson(setupStatePath(), {}).device || 'cpu';
|
||||||
|
return JSON.parse(runQmdCli(args, { env: deviceEnv(device), windowsHide: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSearch({ root, query, topK, daemon = callDaemon, startDaemon: start = startDaemon, cli = runCli }) {
|
||||||
|
const body = { repo: root, query, limit: topK };
|
||||||
|
const ask = async () => {
|
||||||
|
const reply = await daemon('/search', body);
|
||||||
|
if (!Array.isArray(reply?.results)) throw new Error('daemon reply has no results');
|
||||||
|
return reply.results;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
return await ask();
|
||||||
|
} catch (error) {
|
||||||
|
if (!error.notRunning) throw error;
|
||||||
|
await start();
|
||||||
|
return await ask();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const collection = collectionName(root);
|
||||||
|
return toCliResults(await cli(['query', query, '--json', '-n', String(topK), '-c', collection, '--chunk-strategy', 'auto']), collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { values } = parseArgs({ options: { query: { type: 'string' }, 'top-k': { type: 'string', default: '5' }, root: { type: 'string' }, stop: { type: 'boolean' } } });
|
||||||
|
if (values.stop) {
|
||||||
|
try { await callDaemon('/stop', {}); } catch { /* nothing running */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!values.query?.trim()) throw new Error('--query is required');
|
||||||
|
const root = resolve(values.root || execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim());
|
||||||
|
const topK = Math.min(Math.max(Number(values['top-k']) || 5, 1), 20);
|
||||||
|
process.stdout.write(JSON.stringify(await runSearch({ root, query: values.query, topK }), null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||||
|
main().catch((error) => { console.error(`semantic-search: ${error.message}`); process.exit(1); });
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// 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) {
|
||||||
|
const stamp = String(now());
|
||||||
|
try {
|
||||||
|
writeFileSync(lockPath, stamp, { flag: 'wx' });
|
||||||
|
} catch {
|
||||||
|
const age = now() - Number(readFileSync(lockPath, 'utf8'));
|
||||||
|
// An empty or non-numeric lock gives a NaN age and counts as stale.
|
||||||
|
if (Number.isFinite(age) && age <= STALE_MS) return false;
|
||||||
|
writeFileSync(lockPath, stamp);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
// Remove the lock only while it is still this run's own.
|
||||||
|
try { if (readFileSync(lockPath, 'utf8') === stamp) rmSync(lockPath, { force: true }); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
try { await startDaemon(); } catch { /* another daemon may have won the start race */ }
|
||||||
|
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}\n`); } catch { /* ignore */ }
|
||||||
|
}).finally(() => process.exit(0));
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import statistics
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def elapsed(call):
|
|
||||||
started = time.monotonic()
|
|
||||||
call()
|
|
||||||
return time.monotonic() - started
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument('--model-cache', required=True, type=Path)
|
|
||||||
parser.add_argument('--max-seconds', type=float, default=float(os.environ.get('QWEN_BENCHMARK_MAX_SECONDS', '8')))
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.max_seconds <= 0:
|
|
||||||
raise ValueError('--max-seconds must be greater than zero.')
|
|
||||||
os.environ['HF_HOME'] = str(args.model_cache)
|
|
||||||
from server import shared_encoder, shared_reranker
|
|
||||||
|
|
||||||
encoder = shared_encoder()
|
|
||||||
reranker = shared_reranker()
|
|
||||||
query = 'Find the implementation that updates the selected client configuration.'
|
|
||||||
documents = ['Path: scripts/commands/install.py\n\n' + query] * 50
|
|
||||||
print('Benchmark: warming up embedding model...', file=sys.stderr, flush=True)
|
|
||||||
encoder.encode([query], prompt_name='query')
|
|
||||||
print('Benchmark: probing reranker speed...', file=sys.stderr, flush=True)
|
|
||||||
probe = elapsed(lambda: reranker.predict([(query, documents[0])], show_progress_bar=False))
|
|
||||||
if probe * len(documents) > args.max_seconds:
|
|
||||||
print(json.dumps({
|
|
||||||
'embeddingSeconds': None,
|
|
||||||
'rerankingSeconds': round(probe, 3),
|
|
||||||
'maxSeconds': args.max_seconds,
|
|
||||||
'recommended': False,
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
print('Benchmark: measuring full retrieval batch...', file=sys.stderr, flush=True)
|
|
||||||
reranker.predict([(query, document) for document in documents], show_progress_bar=False)
|
|
||||||
embedding = statistics.median(elapsed(lambda: encoder.encode([query], prompt_name='query')) for _ in range(3))
|
|
||||||
reranking = statistics.median(elapsed(lambda: reranker.predict([(query, document) for document in documents], show_progress_bar=False)) for _ in range(3))
|
|
||||||
result = {
|
|
||||||
'embeddingSeconds': round(embedding, 3),
|
|
||||||
'rerankingSeconds': round(reranking, 3),
|
|
||||||
'maxSeconds': args.max_seconds,
|
|
||||||
'recommended': embedding <= args.max_seconds and reranking <= args.max_seconds,
|
|
||||||
}
|
|
||||||
print(json.dumps(result))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
sentence-transformers==5.7.0
|
|
||||||
usearch==2.26.2
|
|
||||||
@@ -1,695 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Local CLI semantic search backed by Qwen3 embedding and reranking."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import ast
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import multiprocessing
|
|
||||||
from multiprocessing import AuthenticationError
|
|
||||||
from multiprocessing.connection import Client, Listener
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
MODEL = "Qwen/Qwen3-Embedding-0.6B"
|
|
||||||
MODEL_REVISION = "97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3"
|
|
||||||
RERANKER_MODEL = "Qwen/Qwen3-Reranker-0.6B"
|
|
||||||
RERANKER_MODEL_REVISION = "e61197ed45024b0ed8a2d74b80b4d909f1255473"
|
|
||||||
EMBEDDING_DIMENSIONS = 1024
|
|
||||||
CHUNK_TOKENS = 400
|
|
||||||
OVERLAP_TOKENS = 64
|
|
||||||
DENSE_CANDIDATES = 50
|
|
||||||
LEXICAL_CANDIDATES = 50
|
|
||||||
RERANK_CANDIDATES = 50
|
|
||||||
FINAL_RESULTS = 5
|
|
||||||
CLI_TIMEOUT_SECONDS = 300
|
|
||||||
RRF_K = 60
|
|
||||||
INDEX_VERSION = 3
|
|
||||||
RETRIEVAL_INSTRUCTION = "Given a codebase question, retrieve relevant code and documentation that answer the question."
|
|
||||||
# Qwen3-Embedding query format from the model card: no space after "Query:".
|
|
||||||
QUERY_PROMPT = f"Instruct: {RETRIEVAL_INSTRUCTION}\nQuery:"
|
|
||||||
TEXT_EXTENSIONS = {".c", ".cjs", ".cpp", ".cs", ".csproj", ".css", ".go", ".gradle", ".html", ".java", ".js", ".json", ".jsx", ".kt", ".kts",
|
|
||||||
".md", ".mjs", ".props", ".ps1", ".psm1", ".py", ".razor", ".rs", ".scss", ".sh", ".sql", ".targets", ".toml", ".ts",
|
|
||||||
".tsx", ".txt", ".xaml", ".xml", ".yaml", ".yml"}
|
|
||||||
DEFAULT_MAX_FILES = 2000
|
|
||||||
MAX_FILES_ENV = "SEMANTIC_RETRIEVAL_MAX_FILES"
|
|
||||||
|
|
||||||
# A daemon keeps the embedding/reranker models resident in memory across CLI
|
|
||||||
# invocations, since each `server.py search` call is otherwise a fresh process
|
|
||||||
# that would reload both models from disk before it can answer a single query.
|
|
||||||
DAEMON_IDLE_SECONDS = 1800
|
|
||||||
DAEMON_READY_TIMEOUT_SECONDS = 120
|
|
||||||
DAEMON_MESSAGE_LIMIT = 16 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_python(script=__file__, executable=sys.executable, system=os.name):
|
|
||||||
relative = Path('.venv') / ('Scripts/python.exe' if system == 'nt' else 'bin/python')
|
|
||||||
candidate = (Path(script).resolve().parent / relative)
|
|
||||||
if candidate.is_file() and candidate != Path(executable).resolve():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def use_runtime_python(system=os.name):
|
|
||||||
runtime = runtime_python()
|
|
||||||
if runtime is not None:
|
|
||||||
command = [str(runtime), str(Path(__file__).resolve()), *sys.argv[1:]]
|
|
||||||
if system == 'nt':
|
|
||||||
# os.execv does not quote arguments on Windows, so a multi-word --query would be split.
|
|
||||||
sys.exit(subprocess.run(command).returncode)
|
|
||||||
os.execv(command[0], command)
|
|
||||||
|
|
||||||
|
|
||||||
def sections(text: str, path: str):
|
|
||||||
lines = text.splitlines(keepends=True)
|
|
||||||
boundaries = {0: "<module>", len(lines): "<module>"}
|
|
||||||
suffix = Path(path).suffix.lower()
|
|
||||||
if suffix == ".py":
|
|
||||||
try:
|
|
||||||
tree = ast.parse(text)
|
|
||||||
except (SyntaxError, ValueError):
|
|
||||||
tree = None
|
|
||||||
|
|
||||||
def visit(node, parent):
|
|
||||||
symbol = parent
|
|
||||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
||||||
symbol = f"{parent}.{node.name}" if parent != "<module>" else node.name
|
|
||||||
start = min([node.lineno, *(item.lineno for item in node.decorator_list)]) - 1
|
|
||||||
boundaries[start] = symbol
|
|
||||||
boundaries.setdefault(node.end_lineno, parent)
|
|
||||||
for child in ast.iter_child_nodes(node):
|
|
||||||
visit(child, symbol)
|
|
||||||
|
|
||||||
if tree is not None:
|
|
||||||
visit(tree, "<module>")
|
|
||||||
else:
|
|
||||||
fence = None
|
|
||||||
headings = []
|
|
||||||
for number, line in enumerate(lines):
|
|
||||||
if suffix == ".md":
|
|
||||||
marker = re.match(r"^\s{0,3}(`{3,}|~{3,})", line)
|
|
||||||
if marker:
|
|
||||||
value = marker[1]
|
|
||||||
if fence is None:
|
|
||||||
fence = value
|
|
||||||
elif value[0] == fence[0] and len(value) >= len(fence):
|
|
||||||
fence = None
|
|
||||||
continue
|
|
||||||
heading = re.match(r"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$", line)
|
|
||||||
if heading and fence is None:
|
|
||||||
level = len(heading[1])
|
|
||||||
headings = [(depth, title) for depth, title in headings if depth < level]
|
|
||||||
headings.append((level, heading[2]))
|
|
||||||
boundaries[number] = " / ".join(title for _, title in headings)
|
|
||||||
elif suffix == ".toml":
|
|
||||||
heading = re.match(r"^\s*\[\[?([^\]]+)\]\]?\s*(?:#.*)?$", line)
|
|
||||||
if heading:
|
|
||||||
boundaries[number] = heading[1]
|
|
||||||
elif suffix == ".sql":
|
|
||||||
heading = re.match(r"^\s*create\s+(?:or\s+(?:alter|replace)\s+)?(?:procedure|proc|function|view|table|trigger|index)\s+([\w.\[\]\"]+)",
|
|
||||||
line, re.IGNORECASE)
|
|
||||||
if heading:
|
|
||||||
boundaries[number] = heading[1]
|
|
||||||
elif suffix in {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".cs", ".java", ".kt", ".kts", ".go", ".rs", ".c", ".cpp", ".ps1", ".psm1", ".sh"}:
|
|
||||||
# A closing brace in column 0 ends a top-level declaration; code after it belongs to the module again.
|
|
||||||
if line.startswith("}"):
|
|
||||||
boundaries.setdefault(number + 1, "<module>")
|
|
||||||
symbol = re.match(
|
|
||||||
r"^\s*(?:(?:export|default|public|private|protected|internal|static|abstract|async|sealed|partial|pub)\s+)*"
|
|
||||||
r"(?:(?:class|interface|struct|enum|record|function|fun|fn|def)\s+([\w$-]+)"
|
|
||||||
r"|func\s+(?:\([^)]*\)\s*)?([\w]+)"
|
|
||||||
r"|(?:const|let|var)\s+([\w$]+)\s*=.*=>"
|
|
||||||
r"|([\w$-]+)\s*\([^;]*\)\s*\{"
|
|
||||||
r"|[\w<>\[\],?]+\s+([\w]+)\s*\([^;]*\)\s*(?:\{|=>|$))", line)
|
|
||||||
if symbol:
|
|
||||||
name = next(value for value in symbol.groups() if value)
|
|
||||||
if name not in {"if", "for", "foreach", "while", "switch", "catch", "using", "lock"}:
|
|
||||||
boundaries[number] = name
|
|
||||||
positions = sorted(boundaries)
|
|
||||||
for start, end in zip(positions, positions[1:]):
|
|
||||||
content = "".join(lines[start:end])
|
|
||||||
if content.strip():
|
|
||||||
yield boundaries[start], content
|
|
||||||
|
|
||||||
|
|
||||||
def chunks(text: str, path: str, tokenizer):
|
|
||||||
for symbol, content in sections(text, path):
|
|
||||||
offsets = tokenizer(content, add_special_tokens=False, return_offsets_mapping=True)["offset_mapping"]
|
|
||||||
start = 0
|
|
||||||
while start < len(offsets):
|
|
||||||
end = min(start + CHUNK_TOKENS, len(offsets))
|
|
||||||
first = 0 if start == 0 else offsets[start][0]
|
|
||||||
while end > start:
|
|
||||||
last = len(content) if end == len(offsets) else offsets[end][0]
|
|
||||||
value = content[first:last].strip()
|
|
||||||
if len(tokenizer(value, add_special_tokens=False)["input_ids"]) <= CHUNK_TOKENS:
|
|
||||||
break
|
|
||||||
end -= 1
|
|
||||||
if value:
|
|
||||||
yield symbol, value
|
|
||||||
if end == len(offsets):
|
|
||||||
break
|
|
||||||
start = max(start + 1, end - OVERLAP_TOKENS)
|
|
||||||
|
|
||||||
|
|
||||||
def document_text(path: str, symbol: str, content: str):
|
|
||||||
return f"Path: {path}\nSymbol: {symbol}\n\n{content}"
|
|
||||||
|
|
||||||
|
|
||||||
def run_action(action: str, root: Path, data_dir: Path, model_cache: Path, query: str | None, top_k: int, output):
|
|
||||||
model_cache.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.environ.setdefault("HF_HOME", str(model_cache.resolve()))
|
|
||||||
index = Index(root, data_dir)
|
|
||||||
result = index.search(query, top_k) if action == "search" else {"chunks": index.rebuild()}
|
|
||||||
output.put((True, result))
|
|
||||||
|
|
||||||
|
|
||||||
def preferred_device():
|
|
||||||
import torch
|
|
||||||
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
||||||
|
|
||||||
|
|
||||||
def model_kwargs():
|
|
||||||
"""Use bfloat16 on GPUs that support it; CPUs keep float32, where bfloat16 is often slower."""
|
|
||||||
import torch
|
|
||||||
return {"dtype": torch.bfloat16} if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else {}
|
|
||||||
|
|
||||||
|
|
||||||
def read_max_files():
|
|
||||||
value = os.environ.get(MAX_FILES_ENV)
|
|
||||||
if value is None:
|
|
||||||
return DEFAULT_MAX_FILES
|
|
||||||
try:
|
|
||||||
parsed = int(value)
|
|
||||||
except ValueError:
|
|
||||||
parsed = 0
|
|
||||||
if parsed <= 0:
|
|
||||||
raise ValueError(f"{MAX_FILES_ENV} must be a positive integer, got {value!r}")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
_shared_model_lock = threading.Lock()
|
|
||||||
_shared_encoder = None
|
|
||||||
_shared_reranker = None
|
|
||||||
|
|
||||||
|
|
||||||
def shared_encoder():
|
|
||||||
global _shared_encoder
|
|
||||||
with _shared_model_lock:
|
|
||||||
if _shared_encoder is None:
|
|
||||||
from sentence_transformers import SentenceTransformer
|
|
||||||
_shared_encoder = SentenceTransformer(MODEL, revision=MODEL_REVISION, truncate_dim=EMBEDDING_DIMENSIONS,
|
|
||||||
prompts={"query": QUERY_PROMPT}, device=preferred_device(),
|
|
||||||
model_kwargs=model_kwargs())
|
|
||||||
return _shared_encoder
|
|
||||||
|
|
||||||
|
|
||||||
def shared_reranker():
|
|
||||||
global _shared_reranker
|
|
||||||
with _shared_model_lock:
|
|
||||||
if _shared_reranker is None:
|
|
||||||
from sentence_transformers import CrossEncoder
|
|
||||||
_shared_reranker = CrossEncoder(RERANKER_MODEL, revision=RERANKER_MODEL_REVISION, prompts={"query": RETRIEVAL_INSTRUCTION},
|
|
||||||
default_prompt_name="query", device=preferred_device(),
|
|
||||||
model_kwargs=model_kwargs())
|
|
||||||
return _shared_reranker
|
|
||||||
|
|
||||||
|
|
||||||
def preload_models(model_cache: Path):
|
|
||||||
model_cache.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.environ["HF_HOME"] = str(model_cache.resolve())
|
|
||||||
query = "Verify semantic retrieval models."
|
|
||||||
embeddings = shared_encoder().encode([query], prompt_name="query")
|
|
||||||
if len(embeddings) != 1 or len(embeddings[0]) != EMBEDDING_DIMENSIONS:
|
|
||||||
raise ValueError("Embedding model returned unexpected dimensions.")
|
|
||||||
scores = shared_reranker().predict([(query, query)], show_progress_bar=False)
|
|
||||||
if len(scores) != 1:
|
|
||||||
raise ValueError("Reranking model returned unexpected scores.")
|
|
||||||
|
|
||||||
|
|
||||||
def reciprocal_rank_fusion(*rankings):
|
|
||||||
scores = {}
|
|
||||||
for ranking in rankings:
|
|
||||||
for rank, identifier in enumerate(ranking, 1):
|
|
||||||
scores[identifier] = scores.get(identifier, 0.0) + 1.0 / (RRF_K + rank)
|
|
||||||
return sorted(scores, key=lambda identifier: (-scores[identifier], identifier))
|
|
||||||
|
|
||||||
|
|
||||||
def code_version() -> str:
|
|
||||||
"""Hash of this file, so a daemon started by older code is never reused after an update."""
|
|
||||||
return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]
|
|
||||||
|
|
||||||
|
|
||||||
def daemon_lock_path(data_dir: Path) -> Path:
|
|
||||||
return data_dir / f"daemon-{code_version()}.starting"
|
|
||||||
|
|
||||||
|
|
||||||
def daemon_address(data_dir: Path) -> str:
|
|
||||||
identity = os.path.normcase(str(data_dir.resolve())) + "|" + code_version()
|
|
||||||
digest = hashlib.sha256(identity.encode()).hexdigest()[:16]
|
|
||||||
if os.name == "nt":
|
|
||||||
return r"\\.\pipe\ai-config-retrieval-" + digest
|
|
||||||
return str(data_dir / f"daemon-{digest}.sock")
|
|
||||||
|
|
||||||
|
|
||||||
def daemon_authkey(data_dir: Path) -> bytes:
|
|
||||||
if data_dir.is_symlink():
|
|
||||||
raise OSError("Semantic retrieval data directory must not be a link.")
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
path = data_dir / "daemon.key"
|
|
||||||
try:
|
|
||||||
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
||||||
except FileExistsError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
with os.fdopen(descriptor, "wb") as stream:
|
|
||||||
stream.write(os.urandom(32))
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
metadata = path.lstat()
|
|
||||||
if not stat.S_ISREG(metadata.st_mode) or (os.name != "nt" and metadata.st_mode & 0o077):
|
|
||||||
raise OSError("Semantic retrieval daemon credential is not private.")
|
|
||||||
key = path.read_bytes()
|
|
||||||
if len(key) != 32:
|
|
||||||
raise OSError("Semantic retrieval daemon credential is incomplete.")
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def request_daemon(address: str, payload: dict, data_dir: Path):
|
|
||||||
with Client(address, authkey=daemon_authkey(data_dir)) as connection:
|
|
||||||
connection.send_bytes(json.dumps(payload).encode("utf-8"))
|
|
||||||
if not connection.poll(CLI_TIMEOUT_SECONDS):
|
|
||||||
raise TimeoutError(f"Semantic retrieval daemon exceeded the {CLI_TIMEOUT_SECONDS} second timeout.")
|
|
||||||
success, result = json.loads(connection.recv_bytes(DAEMON_MESSAGE_LIMIT))
|
|
||||||
if not success:
|
|
||||||
raise RuntimeError(str(result))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def spawn_daemon(data_dir: Path, model_cache: Path) -> bool:
|
|
||||||
lock_path = daemon_lock_path(data_dir)
|
|
||||||
try:
|
|
||||||
os.close(os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
|
|
||||||
except FileExistsError:
|
|
||||||
try:
|
|
||||||
if time.time() - lock_path.stat().st_mtime > DAEMON_READY_TIMEOUT_SECONDS:
|
|
||||||
lock_path.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
|
||||||
[sys.executable, str(Path(__file__).resolve()), "daemon", "--data-dir", str(data_dir), "--model-cache", str(model_cache)],
|
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
start_new_session=(os.name != "nt"),
|
|
||||||
creationflags=(subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP) if os.name == "nt" else 0)
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
lock_path.unlink(missing_ok=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def try_daemon(root: Path, data_dir: Path, model_cache: Path, action: str, query: str | None, top_k: int):
|
|
||||||
address = daemon_address(data_dir)
|
|
||||||
payload = {"action": action, "root": str(root), "query": query, "top_k": top_k}
|
|
||||||
try:
|
|
||||||
return request_daemon(address, payload, data_dir)
|
|
||||||
except AuthenticationError:
|
|
||||||
return None
|
|
||||||
except (OSError, EOFError):
|
|
||||||
pass
|
|
||||||
if not spawn_daemon(data_dir, model_cache):
|
|
||||||
return None
|
|
||||||
deadline = time.monotonic() + DAEMON_READY_TIMEOUT_SECONDS
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
return request_daemon(address, payload, data_dir)
|
|
||||||
except (OSError, EOFError, AuthenticationError):
|
|
||||||
time.sleep(0.5)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def run_daemon(data_dir: Path, model_cache: Path):
|
|
||||||
address = daemon_address(data_dir)
|
|
||||||
if os.name != "nt":
|
|
||||||
Path(address).unlink(missing_ok=True)
|
|
||||||
model_cache.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.environ.setdefault("HF_HOME", str(model_cache.resolve()))
|
|
||||||
indexes: dict[str, Index] = {}
|
|
||||||
activity_lock = threading.Lock()
|
|
||||||
last_activity = time.monotonic()
|
|
||||||
|
|
||||||
def watchdog():
|
|
||||||
while True:
|
|
||||||
time.sleep(5)
|
|
||||||
with activity_lock:
|
|
||||||
idle_for = time.monotonic() - last_activity
|
|
||||||
if idle_for > DAEMON_IDLE_SECONDS:
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
threading.Thread(target=watchdog, daemon=True).start()
|
|
||||||
listener = Listener(address, family="AF_PIPE" if os.name == "nt" else "AF_UNIX", authkey=daemon_authkey(data_dir))
|
|
||||||
daemon_lock_path(data_dir).unlink(missing_ok=True)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
connection = listener.accept()
|
|
||||||
try:
|
|
||||||
response = (True, None)
|
|
||||||
try:
|
|
||||||
request = json.loads(connection.recv_bytes(DAEMON_MESSAGE_LIMIT))
|
|
||||||
if isinstance(request, dict) and request.get("action") == "stop":
|
|
||||||
connection.send_bytes(json.dumps((True, None)).encode("utf-8"))
|
|
||||||
os._exit(0)
|
|
||||||
if not isinstance(request, dict) or request.get("action") not in ("search", "rebuild") or not isinstance(request.get("root"), str):
|
|
||||||
raise ValueError("Invalid semantic retrieval daemon request.")
|
|
||||||
index = indexes.get(request["root"])
|
|
||||||
if index is None:
|
|
||||||
if len(indexes) >= 8:
|
|
||||||
indexes.pop(next(iter(indexes))).db.close()
|
|
||||||
index = Index(Path(request["root"]), data_dir)
|
|
||||||
indexes[request["root"]] = index
|
|
||||||
if request["action"] == "search":
|
|
||||||
response = (True, index.search(request["query"], request["top_k"]))
|
|
||||||
else:
|
|
||||||
response = (True, {"chunks": index.rebuild()})
|
|
||||||
except EOFError:
|
|
||||||
continue
|
|
||||||
except Exception as error:
|
|
||||||
response = (False, str(error))
|
|
||||||
connection.send_bytes(json.dumps(response).encode("utf-8"))
|
|
||||||
except (OSError, EOFError):
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
with activity_lock:
|
|
||||||
last_activity = time.monotonic()
|
|
||||||
finally:
|
|
||||||
listener.close()
|
|
||||||
|
|
||||||
|
|
||||||
def index_database(root: Path, data: Path) -> Path:
|
|
||||||
identity = json.dumps([os.path.normcase(str(root.resolve())), MODEL, MODEL_REVISION,
|
|
||||||
EMBEDDING_DIMENSIONS, RETRIEVAL_INSTRUCTION, INDEX_VERSION,
|
|
||||||
CHUNK_TOKENS, OVERLAP_TOKENS])
|
|
||||||
return data.resolve() / (hashlib.sha256(identity.encode()).hexdigest() + ".sqlite3")
|
|
||||||
|
|
||||||
|
|
||||||
def daemon_running(data_dir: Path) -> bool:
|
|
||||||
if not pipes_available():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
Client(daemon_address(data_dir), authkey=daemon_authkey(data_dir)).close()
|
|
||||||
return True
|
|
||||||
except (OSError, EOFError, AuthenticationError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def index_status(root: Path, data_dir: Path) -> dict:
|
|
||||||
"""Report what the next search costs without loading models or starting the daemon."""
|
|
||||||
database = index_database(root, data_dir)
|
|
||||||
previous = {}
|
|
||||||
if database.is_file():
|
|
||||||
connection = sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)
|
|
||||||
try:
|
|
||||||
previous = {path: (size, mtime, ctime) for path, size, mtime, ctime
|
|
||||||
in connection.execute("select path, size, mtime, ctime from files")}
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
files = indexable_files(root.resolve(), data_dir.resolve(), read_max_files())
|
|
||||||
stale = 0
|
|
||||||
for path in files:
|
|
||||||
metadata = path.stat()
|
|
||||||
stale += previous.get(path.relative_to(root.resolve()).as_posix()) != (metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns)
|
|
||||||
return {"indexable_files": len(files), "indexed_files": len(previous), "stale_files": stale,
|
|
||||||
"models_loaded": daemon_running(data_dir), "in_process": not pipes_available()}
|
|
||||||
|
|
||||||
|
|
||||||
def indexable_files(root: Path, data: Path, max_files: int):
|
|
||||||
ignored = {".git", ".venv", "generated", "node_modules", ".my-ai-configuration", "bin", "obj"}
|
|
||||||
output = subprocess.run(["git", "-C", str(root), "ls-files", "--cached", "--others",
|
|
||||||
"--exclude-standard", "-z", "--", "."],
|
|
||||||
check=True, capture_output=True, timeout=30).stdout
|
|
||||||
candidates = [root / name for name in
|
|
||||||
output.decode("utf-8", errors="surrogateescape").split("\0") if name
|
|
||||||
and not any(part in ignored for part in Path(name).parts)]
|
|
||||||
result = []
|
|
||||||
for candidate in sorted(candidates):
|
|
||||||
if candidate.is_symlink():
|
|
||||||
continue
|
|
||||||
path = candidate.resolve()
|
|
||||||
if (not path.is_relative_to(root) or path.is_relative_to(data)
|
|
||||||
or path.suffix.lower() not in TEXT_EXTENSIONS or not path.is_file() or path.stat().st_size > 524288):
|
|
||||||
continue
|
|
||||||
result.append(path)
|
|
||||||
if len(result) == max_files:
|
|
||||||
print(f"Semantic retrieval: stopped scanning after {max_files} indexable files; "
|
|
||||||
f"some files under {root} were not indexed. Set {MAX_FILES_ENV} to raise this limit.",
|
|
||||||
file=sys.stderr)
|
|
||||||
return result
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class Index:
|
|
||||||
def __init__(self, root: Path, data: Path):
|
|
||||||
self.root = root.resolve()
|
|
||||||
self.data = data.resolve()
|
|
||||||
self.data.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.db = sqlite3.connect(index_database(self.root, self.data), check_same_thread=False, timeout=30)
|
|
||||||
self.db.execute("create table if not exists chunks (path text, symbol text, text text, vector blob)")
|
|
||||||
self.db.execute("create index if not exists chunks_path on chunks (path)")
|
|
||||||
self.db.execute("create virtual table if not exists lexical using fts5(document)")
|
|
||||||
self.db.execute("create table if not exists files (path text primary key, digest text, size integer, mtime integer, ctime integer)")
|
|
||||||
columns = {row[1] for row in self.db.execute("pragma table_info(files)")}
|
|
||||||
for name in ("size", "mtime", "ctime"):
|
|
||||||
if name not in columns:
|
|
||||||
self.db.execute(f"alter table files add column {name} integer")
|
|
||||||
self.db.execute("create table if not exists query_cache (cache_key text primary key, result text not null)")
|
|
||||||
self.db.commit()
|
|
||||||
self.lock = threading.RLock()
|
|
||||||
self.max_files = read_max_files()
|
|
||||||
self.vector_index = None
|
|
||||||
|
|
||||||
def files(self):
|
|
||||||
return indexable_files(self.root, self.data, self.max_files)
|
|
||||||
|
|
||||||
def encoder(self):
|
|
||||||
return shared_encoder()
|
|
||||||
|
|
||||||
def reranker(self):
|
|
||||||
return shared_reranker()
|
|
||||||
|
|
||||||
def rerank(self, query: str, rows: list[dict], limit: int):
|
|
||||||
candidates = rows[:RERANK_CANDIDATES]
|
|
||||||
scores = self.reranker().predict([(query, document_text(item["path"], item["symbol"], item["text"]))
|
|
||||||
for item in candidates], batch_size=8)
|
|
||||||
for item, score in zip(candidates, scores):
|
|
||||||
item["score"] = round(float(score), 6)
|
|
||||||
return sorted(candidates, key=lambda item: item["score"], reverse=True)[:min(limit, FINAL_RESULTS)]
|
|
||||||
|
|
||||||
def delete_path(self, path):
|
|
||||||
self.db.execute("delete from lexical where rowid in (select rowid from chunks where path = ?)", (path,))
|
|
||||||
self.db.execute("delete from chunks where path = ?", (path,))
|
|
||||||
|
|
||||||
def rebuild(self):
|
|
||||||
with self.lock:
|
|
||||||
self.refresh(force=True)
|
|
||||||
return self.db.execute("select count(*) from chunks").fetchone()[0]
|
|
||||||
|
|
||||||
def refresh(self, force=False):
|
|
||||||
previous = {path: (digest, size, mtime, ctime)
|
|
||||||
for path, digest, size, mtime, ctime in self.db.execute("select path, digest, size, mtime, ctime from files")}
|
|
||||||
encoder = None
|
|
||||||
changed = []
|
|
||||||
documents = []
|
|
||||||
document_offsets = []
|
|
||||||
for path in self.files():
|
|
||||||
relative = path.relative_to(self.root).as_posix()
|
|
||||||
metadata = path.stat()
|
|
||||||
size, mtime, ctime = metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns
|
|
||||||
old_digest, old_size, old_mtime, old_ctime = previous.pop(relative, (None, None, None, None))
|
|
||||||
if not force and old_digest and (size, mtime, ctime) == (old_size, old_mtime, old_ctime):
|
|
||||||
continue
|
|
||||||
content = path.read_bytes()
|
|
||||||
digest = hashlib.sha256(content).hexdigest()
|
|
||||||
if not force and digest == old_digest:
|
|
||||||
with self.db:
|
|
||||||
self.db.execute("update files set size = ?, mtime = ?, ctime = ? where path = ?",
|
|
||||||
(size, mtime, ctime, relative))
|
|
||||||
continue
|
|
||||||
if encoder is None:
|
|
||||||
encoder = self.encoder()
|
|
||||||
values = list(chunks(content.decode("utf-8", errors="ignore"), relative, encoder.tokenizer))
|
|
||||||
file_documents = [document_text(relative, symbol, value) for symbol, value in values]
|
|
||||||
document_offsets.append(len(documents))
|
|
||||||
documents.extend(file_documents)
|
|
||||||
changed.append((relative, digest, size, mtime, ctime, values, file_documents))
|
|
||||||
vectors = encoder.encode(documents, normalize_embeddings=True) if documents else []
|
|
||||||
with self.db:
|
|
||||||
for number, (relative, digest, size, mtime, ctime, values, file_documents) in enumerate(changed):
|
|
||||||
start = document_offsets[number]
|
|
||||||
file_vectors = vectors[start:start + len(file_documents)]
|
|
||||||
self.delete_path(relative)
|
|
||||||
for (symbol, value), document, vector in zip(values, file_documents, file_vectors):
|
|
||||||
if len(vector) != EMBEDDING_DIMENSIONS:
|
|
||||||
raise ValueError("Embedding must have 1024 dimensions")
|
|
||||||
cursor = self.db.execute("insert into chunks values (?, ?, ?, ?)",
|
|
||||||
(relative, symbol, value, vector.tobytes()))
|
|
||||||
self.db.execute("insert into lexical(rowid, document) values (?, ?)", (cursor.lastrowid, document))
|
|
||||||
self.db.execute("insert or replace into files values (?, ?, ?, ?, ?)",
|
|
||||||
(relative, digest, size, mtime, ctime))
|
|
||||||
for relative in previous:
|
|
||||||
self.delete_path(relative)
|
|
||||||
self.db.execute("delete from files where path = ?", (relative,))
|
|
||||||
if changed or previous:
|
|
||||||
self.db.execute("delete from query_cache")
|
|
||||||
self.vector_index = None
|
|
||||||
|
|
||||||
def dense_candidates(self, vector):
|
|
||||||
import numpy as np
|
|
||||||
from usearch.index import Index as VectorIndex
|
|
||||||
|
|
||||||
if self.vector_index is None:
|
|
||||||
index = VectorIndex(ndim=EMBEDDING_DIMENSIONS, metric="cos", dtype="f32",
|
|
||||||
connectivity=16, expansion_add=128, expansion_search=64)
|
|
||||||
cursor = self.db.execute("select rowid, vector from chunks")
|
|
||||||
while rows := cursor.fetchmany(4096):
|
|
||||||
vectors = np.frombuffer(b"".join(row[1] for row in rows), dtype=np.float32).reshape(-1, EMBEDDING_DIMENSIONS)
|
|
||||||
index.add([row[0] for row in rows], vectors)
|
|
||||||
self.vector_index = index
|
|
||||||
return [int(match.key) for match in self.vector_index.search(vector, DENSE_CANDIDATES)]
|
|
||||||
|
|
||||||
def cache_key(self, query: str, top_k: int):
|
|
||||||
identity = json.dumps([
|
|
||||||
query, top_k,
|
|
||||||
RERANKER_MODEL, RERANKER_MODEL_REVISION, RETRIEVAL_INSTRUCTION, QUERY_PROMPT,
|
|
||||||
DENSE_CANDIDATES, LEXICAL_CANDIDATES, RERANK_CANDIDATES, FINAL_RESULTS, RRF_K,
|
|
||||||
], ensure_ascii=False, separators=(",", ":"))
|
|
||||||
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
def search(self, query: str, top_k: int = FINAL_RESULTS):
|
|
||||||
if not query.strip():
|
|
||||||
raise ValueError("query must not be empty")
|
|
||||||
with self.lock:
|
|
||||||
self.refresh()
|
|
||||||
if not self.db.execute("select 1 from chunks limit 1").fetchone():
|
|
||||||
return []
|
|
||||||
key = self.cache_key(query, top_k)
|
|
||||||
cached = self.db.execute("select result from query_cache where cache_key = ?", (key,)).fetchone()
|
|
||||||
if cached:
|
|
||||||
try:
|
|
||||||
return json.loads(cached[0])
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
with self.db:
|
|
||||||
self.db.execute("delete from query_cache where cache_key = ?", (key,))
|
|
||||||
vector = self.encoder().encode([query], prompt_name="query", normalize_embeddings=True)[0]
|
|
||||||
if len(vector) != EMBEDDING_DIMENSIONS:
|
|
||||||
raise ValueError("Embedding must have 1024 dimensions")
|
|
||||||
dense = self.dense_candidates(vector)
|
|
||||||
terms = list(dict.fromkeys(re.findall(r"[^\W_]+", query)))[:128]
|
|
||||||
expression = " OR ".join(f'"{term}"' for term in terms)
|
|
||||||
lexical = [row[0] for row in self.db.execute(
|
|
||||||
"select rowid from lexical where lexical match ? order by bm25(lexical), rowid limit ?",
|
|
||||||
(expression, LEXICAL_CANDIDATES))] if terms else []
|
|
||||||
identifiers = reciprocal_rank_fusion(dense, lexical)[:RERANK_CANDIDATES]
|
|
||||||
placeholders = ",".join("?" for _ in identifiers)
|
|
||||||
rows = {identifier: {"path": path, "symbol": symbol, "text": text}
|
|
||||||
for identifier, path, symbol, text in self.db.execute(
|
|
||||||
f"select rowid, path, symbol, text from chunks where rowid in ({placeholders})", identifiers)}
|
|
||||||
candidates = [rows[identifier] for identifier in identifiers]
|
|
||||||
result = self.rerank(query, candidates, max(1, min(top_k, FINAL_RESULTS)))
|
|
||||||
with self.db:
|
|
||||||
self.db.execute("insert or replace into query_cache values (?, ?)",
|
|
||||||
(key, json.dumps(result, ensure_ascii=False)))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def pipes_available() -> bool:
|
|
||||||
"""Sandboxes such as Codex on Windows forbid the pipes that the daemon and worker process need."""
|
|
||||||
try:
|
|
||||||
first, second = multiprocessing.Pipe()
|
|
||||||
except PermissionError:
|
|
||||||
return False
|
|
||||||
first.close()
|
|
||||||
second.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def run_direct(action: str, root: Path, data_dir: Path, model_cache: Path, query: str | None, top_k: int):
|
|
||||||
if not pipes_available():
|
|
||||||
# ponytail: in-process fallback has no worker timeout; the client's own command timeout still applies.
|
|
||||||
output = queue.Queue(1)
|
|
||||||
run_action(action, root, data_dir, model_cache, query, top_k, output)
|
|
||||||
return output.get()[1]
|
|
||||||
output = multiprocessing.Queue(1)
|
|
||||||
worker = multiprocessing.Process(target=run_action, args=(action, root, data_dir, model_cache, query, top_k, output))
|
|
||||||
worker.start()
|
|
||||||
worker.join(CLI_TIMEOUT_SECONDS)
|
|
||||||
if worker.is_alive():
|
|
||||||
worker.terminate()
|
|
||||||
worker.join()
|
|
||||||
raise TimeoutError(f"Qwen retrieval exceeded the {CLI_TIMEOUT_SECONDS} second timeout.")
|
|
||||||
if worker.exitcode != 0 or output.empty():
|
|
||||||
raise RuntimeError("Qwen retrieval worker failed.")
|
|
||||||
success, result = output.get()
|
|
||||||
if not success:
|
|
||||||
raise RuntimeError(str(result))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("action", choices=("search", "rebuild", "status", "stop", "daemon", "preload"))
|
|
||||||
parser.add_argument("--query")
|
|
||||||
parser.add_argument("--top-k", type=int, default=FINAL_RESULTS)
|
|
||||||
parser.add_argument("--root", default=os.getcwd(), type=Path)
|
|
||||||
parser.add_argument("--data-dir", type=Path)
|
|
||||||
parser.add_argument("--model-cache", required=True, type=Path)
|
|
||||||
args = parser.parse_args()
|
|
||||||
model_cache = args.model_cache.resolve()
|
|
||||||
if args.action == "preload":
|
|
||||||
preload_models(model_cache)
|
|
||||||
return
|
|
||||||
if args.data_dir is None:
|
|
||||||
parser.error(f"{args.action} requires --data-dir")
|
|
||||||
data_dir = args.data_dir.resolve()
|
|
||||||
if args.action == "daemon":
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
run_daemon(data_dir, model_cache)
|
|
||||||
return
|
|
||||||
if args.action == "status":
|
|
||||||
print(json.dumps(index_status(args.root, data_dir)))
|
|
||||||
return
|
|
||||||
if args.action == "stop":
|
|
||||||
try:
|
|
||||||
request_daemon(daemon_address(data_dir), {"action": "stop"}, data_dir)
|
|
||||||
stopped = True
|
|
||||||
except (OSError, EOFError, AuthenticationError):
|
|
||||||
stopped = False
|
|
||||||
print(json.dumps({"stopped": stopped}))
|
|
||||||
return
|
|
||||||
if args.action == "search" and not args.query:
|
|
||||||
parser.error("search requires --query")
|
|
||||||
root = args.root.resolve()
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
result = try_daemon(root, data_dir, model_cache, args.action, args.query, args.top_k) if pipes_available() else None
|
|
||||||
if result is None:
|
|
||||||
result = run_direct(args.action, root, data_dir, model_cache, args.query, args.top_k)
|
|
||||||
print(json.dumps(result, ensure_ascii=False))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
use_runtime_python()
|
|
||||||
main()
|
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
* Avoid vague names such as `data`, `item`, `value` or `temp` when better names exist.
|
* Avoid vague names such as `data`, `item`, `value` or `temp` when better names exist.
|
||||||
* Remove dead code.
|
* Remove dead code.
|
||||||
* Remove unused imports and dependencies.
|
* Remove unused imports and dependencies.
|
||||||
* Remove commented-out code instead of keeping it in source files.
|
|
||||||
* Add comments only for non-obvious decisions or public contracts.
|
* Add comments only for non-obvious decisions or public contracts.
|
||||||
* Treat Angular, TypeScript and lint warnings seriously.
|
* Treat Angular, TypeScript and lint warnings seriously.
|
||||||
* Do not suppress diagnostics without a concrete reason.
|
* Do not suppress diagnostics without a concrete reason.
|
||||||
|
|||||||
@@ -2,14 +2,10 @@
|
|||||||
|
|
||||||
Apply these rules when designing public APIs, service boundaries, or application architecture.
|
Apply these rules when designing public APIs, service boundaries, or application architecture.
|
||||||
|
|
||||||
* Give APIs explicit, strongly typed contracts. Validate external input at the boundary and keep trusted internal paths free of redundant checks.
|
* Give APIs explicit, strongly typed contracts. Keep trusted internal paths free of checks already done at the boundary.
|
||||||
* Separate request models from persistence and domain models. Bind only fields the caller may set, and project only fields the caller may read.
|
* Separate request models from persistence and domain models. Bind only fields the caller may set, and project only fields the caller may read.
|
||||||
* Authenticate and authorize before accessing protected resources. Enforce resource ownership and tenant scope in the data access path, not only in the UI or route layer.
|
* Authenticate and authorize before accessing protected resources. Keep the decision close to the protected operation, enforce resource ownership and tenant scope in the data access path, not only in the UI or route layer, and fail closed when identity, ownership, or policy evaluation is unavailable.
|
||||||
* Bound page size, payload size, query complexity, and operation cost. Use explicit pagination for potentially large collections.
|
* Use explicit, bounded pagination for potentially large collections.
|
||||||
* Keep application, domain, and infrastructure concerns separate. Avoid cross-layer coupling and service-locator patterns.
|
* Keep application, domain, and infrastructure concerns separate. Avoid cross-layer coupling and service-locator patterns.
|
||||||
* Use dependency injection when substitution or lifecycle management is needed. Keep dependencies explicit and avoid excessive constructor dependencies.
|
* Use dependency injection when substitution or lifecycle management is needed. Keep dependencies explicit and avoid excessive constructor dependencies.
|
||||||
* Prefer composition over inheritance; use inheritance only when the contract requires it. Seal types when extension is not intended.
|
|
||||||
* Keep classes and methods focused. Avoid wrappers, abstractions, reflection, and global mutable state without a concrete need.
|
|
||||||
* Preserve established project and public API conventions unless a concrete compatibility or correctness issue requires a change.
|
* Preserve established project and public API conventions unless a concrete compatibility or correctness issue requires a change.
|
||||||
* Keep authorization decisions close to the protected operation and fail closed when identity, ownership, or policy evaluation is unavailable.
|
|
||||||
* Avoid returning persistence entities, internal exception details, or fields that are not part of the public contract.
|
|
||||||
@@ -7,7 +7,4 @@ Apply these rules when editing C# syntax, class structure, and everyday code org
|
|||||||
* Keep classes, methods, and responsibilities focused. Use descriptive names and established domain terms; avoid unexplained abbreviations.
|
* Keep classes, methods, and responsibilities focused. Use descriptive names and established domain terms; avoid unexplained abbreviations.
|
||||||
* Prefer composition over inheritance. Use inheritance only for a real substitutable contract, and seal types when extension is not intended.
|
* Prefer composition over inheritance. Use inheritance only for a real substitutable contract, and seal types when extension is not intended.
|
||||||
* Avoid unnecessary wrappers, abstractions, reflection, runtime type checks, and global mutable state.
|
* Avoid unnecessary wrappers, abstractions, reflection, runtime type checks, and global mutable state.
|
||||||
* Prefer immutable or readonly state when it simplifies ownership and reduces accidental mutation.
|
|
||||||
* Keep side effects visible. Make behavior deterministic and easy to test where practical.
|
* Keep side effects visible. Make behavior deterministic and easy to test where practical.
|
||||||
* Add comments or API documentation only for non-obvious decisions, public contracts, or documented project requirements.
|
|
||||||
* Remove dead code instead of leaving commented-out implementations.
|
|
||||||
@@ -5,10 +5,10 @@ Apply these rules when optimizing code or reviewing runtime and resource costs.
|
|||||||
* Establish a representative baseline before changing code. Measure the affected workload, input size, concurrency, and environment; distinguish average latency from tail latency and throughput.
|
* Establish a representative baseline before changing code. Measure the affected workload, input size, concurrency, and environment; distinguish average latency from tail latency and throughput.
|
||||||
* Find the dominant cost before optimizing: architecture, database access, network and I/O, algorithms, concurrency, allocations and GC, then CPU-level work.
|
* Find the dominant cost before optimizing: architecture, database access, network and I/O, algorithms, concurrency, allocations and GC, then CPU-level work.
|
||||||
* Choose collections and algorithms for actual access patterns. Check asymptotic cost and data growth; avoid repeated scans, unnecessary sorting, copies, and unbounded result sets.
|
* Choose collections and algorithms for actual access patterns. Check asymptotic cost and data growth; avoid repeated scans, unnecessary sorting, copies, and unbounded result sets.
|
||||||
* Keep data close to where it is processed. Filter and project in the database, avoid N+1 queries, bound page sizes, and avoid materializing rows or columns the caller does not use.
|
* Keep data close to where it is processed. Filter and project in the database, avoid N+1 queries, and avoid materializing rows or columns the caller does not use.
|
||||||
* Stream large inputs and outputs when buffering the whole value would waste memory. Bound queues, batches, and parallel work to match downstream capacity.
|
* Stream large inputs and outputs when buffering the whole value would waste memory. Bound queues, batches, and parallel work to match downstream capacity.
|
||||||
* Reduce allocations in measured hot paths: avoid repeated string construction, iterator/delegate capture, boxing, and collection resizing when profiling identifies them as material costs.
|
* Reduce allocations in measured hot paths: avoid repeated string construction, iterator/delegate capture, boxing, and collection resizing when profiling identifies them as material costs.
|
||||||
* Consider pooling only for expensive reusable objects with clear ownership and reset rules. Pooling can retain memory or leak state between users; measure it and cap retained capacity.
|
* Consider pooling only for expensive reusable objects with clear ownership and reset rules. Pooling can retain memory or leak state between users; measure it and cap retained capacity.
|
||||||
* Use `Span<T>`, `ValueTask`, preallocation, or specialized low-level APIs only when their benefit is demonstrated and they preserve readable, safe ownership.
|
* Use `Span<T>`, preallocation, or specialized low-level APIs only when their benefit is demonstrated and they preserve readable, safe ownership.
|
||||||
* Cache only when repeated work is measurable and the key, lifetime, size bound, invalidation, and sensitive-data handling are explicit.
|
* Cache only when repeated work is measurable and the key, lifetime, size bound, invalidation, and sensitive-data handling are explicit.
|
||||||
* After an optimization, run the same workload and compare correctness, allocations, GC, CPU, latency, and throughput against the baseline. Keep the simplest change that meets the target.
|
* After an optimization, run the same workload and compare correctness, allocations, GC, CPU, latency, and throughput against the baseline. Keep the simplest change that meets the target.
|
||||||
@@ -7,8 +7,7 @@ Apply these rules when handling external input, failures, logs, or sensitive inf
|
|||||||
* Fail fast for invalid arguments or impossible state. Do not use exceptions for routine control flow or swallow unexpected exceptions.
|
* Fail fast for invalid arguments or impossible state. Do not use exceptions for routine control flow or swallow unexpected exceptions.
|
||||||
* Preserve useful exception context without exposing secrets, personal data, or internal details in logs, APIs, or user-facing errors.
|
* Preserve useful exception context without exposing secrets, personal data, or internal details in logs, APIs, or user-facing errors.
|
||||||
* Use structured logging and avoid excessive logging, especially in hot paths. Never log credentials, tokens, or sensitive payloads.
|
* Use structured logging and avoid excessive logging, especially in hot paths. Never log credentials, tokens, or sensitive payloads.
|
||||||
* Keep authorization, certificate validation, encryption, and other security controls intact. Follow the focused security rules for the affected boundary.
|
* Dispose owned resources correctly, including on failure paths.
|
||||||
* Keep side effects explicit and deterministic where practical; propagate cancellation and dispose owned resources correctly.
|
|
||||||
* Use narrow exception handling around the operation that can fail. Do not catch broadly merely to retry, continue with partial state, or convert failures into success.
|
* Use narrow exception handling around the operation that can fail. Do not catch broadly merely to retry, continue with partial state, or convert failures into success.
|
||||||
* Avoid returning different error detail to unauthenticated callers when it would disclose whether protected accounts, records, or resources exist.
|
* Avoid returning different error detail to unauthenticated callers when it would disclose whether protected accounts, records, or resources exist.
|
||||||
* Treat resource limits as part of safe error handling: cap request bodies, decompressed data, collection sizes, parser depth, and other attacker-controlled work.
|
* Treat resource limits as part of safe error handling: cap request bodies, page sizes, query complexity, decompressed data, collection sizes, parser depth, and other attacker-controlled work.
|
||||||
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
Apply these rules when implementing or reviewing security-sensitive .NET code. Load the matching shared security rule for the affected API, identity, data, file, network, cryptographic, or dependency boundary as well.
|
Apply these rules when implementing or reviewing security-sensitive .NET code. Load the matching shared security rule for the affected API, identity, data, file, network, cryptographic, or dependency boundary as well.
|
||||||
|
|
||||||
* Validate untrusted input at the boundary and enforce authorization for the specific resource and operation. Static types and client-side checks do not establish trust.
|
|
||||||
* Use parameterized database APIs. If a query identifier or sort expression cannot be parameterized, select it from a fixed allowlist; never concatenate untrusted input into SQL.
|
* Use parameterized database APIs. If a query identifier or sort expression cannot be parameterized, select it from a fixed allowlist; never concatenate untrusted input into SQL.
|
||||||
* For file access, canonicalize the resolved path and verify it remains inside the authorized root. Account for reparse points and symlinks when the attacker can affect directory contents.
|
* For file access, canonicalize the resolved path and verify it remains inside the authorized root. Account for reparse points and symlinks when the attacker can affect directory contents.
|
||||||
* For process execution, avoid a shell. Pass executable and arguments separately using APIs supported by the target framework, validate each argument, and restrict executable paths.
|
* For process execution, avoid a shell. Pass executable and arguments separately using APIs supported by the target framework, validate each argument, and restrict executable paths.
|
||||||
* Deserialize into explicit DTOs or allowlisted types. Avoid unsafe polymorphic type activation and unbounded object graphs for untrusted data.
|
* Deserialize into explicit DTOs or allowlisted types. Avoid unsafe polymorphic type activation and unbounded object graphs for untrusted data.
|
||||||
* Bound request and parser work before allocating or processing large values. Apply timeouts to regular expressions that evaluate untrusted input and prefer patterns with predictable complexity.
|
* Apply timeouts to regular expressions that evaluate untrusted input and prefer patterns with predictable complexity.
|
||||||
* Keep secrets in an approved secret store or protected configuration source. Never embed them in assemblies, client bundles, source, diagnostics, or exception messages.
|
* Keep secrets in an approved secret store or protected configuration source. Never embed them in assemblies, client bundles, or source.
|
||||||
* Use platform TLS and established cryptographic APIs. Do not create custom cryptography or disable certificate validation; use cryptographically secure randomness for security tokens.
|
* Use platform TLS and established cryptographic APIs. Do not create custom cryptography or disable certificate validation; use cryptographically secure randomness for security tokens.
|
||||||
* Use framework output encoding and safe APIs for HTML, XML, and other interpreters. Do not build executable markup or queries from untrusted strings.
|
* Use framework output encoding and safe APIs for HTML, XML, and other interpreters. Do not build executable markup or queries from untrusted strings.
|
||||||
* Review security-sensitive changes with tests for rejected, malformed, oversized, unauthenticated, and cross-tenant inputs, as applicable.
|
* Review security-sensitive changes with tests for rejected, malformed, oversized, unauthenticated, and cross-tenant inputs, as applicable.
|
||||||
@@ -5,6 +5,5 @@ Apply these rules when changing tests, package references, analyzers, or build d
|
|||||||
* Write deterministic behavior-based tests. Avoid coupling assertions to private call order, implementation details, current time, or uncontrolled external services.
|
* Write deterministic behavior-based tests. Avoid coupling assertions to private call order, implementation details, current time, or uncontrolled external services.
|
||||||
* Make code testable through clear contracts and dependencies; do not add production hooks or abstractions solely for tests without a concrete need.
|
* Make code testable through clear contracts and dependencies; do not add production hooks or abstractions solely for tests without a concrete need.
|
||||||
* Prefer the .NET BCL and existing supported packages over new libraries when they solve the problem adequately.
|
* Prefer the .NET BCL and existing supported packages over new libraries when they solve the problem adequately.
|
||||||
* Keep dependencies minimal, maintained, compatible, and updated through the repository's normal package-management process.
|
|
||||||
* Treat compiler, analyzer, and nullable warnings seriously. Suppress a diagnostic only for a specific, documented reason and narrow the suppression to the affected code.
|
* Treat compiler, analyzer, and nullable warnings seriously. Suppress a diagnostic only for a specific, documented reason and narrow the suppression to the affected code.
|
||||||
* Run focused tests and analyzers for the changed path; broaden verification when shared behavior or compatibility could be affected.
|
* Include analyzers in the verification of the changed path.
|
||||||
@@ -8,5 +8,4 @@ Apply these rules when designing type contracts, choosing collection shapes, or
|
|||||||
* Expose collection interfaces such as `IReadOnlyList<T>`, `IEnumerable<T>`, `IDictionary<TKey, TValue>`, or `ICollection<T>` only when their capability is part of an intentional boundary or public contract.
|
* Expose collection interfaces such as `IReadOnlyList<T>`, `IEnumerable<T>`, `IDictionary<TKey, TValue>`, or `ICollection<T>` only when their capability is part of an intentional boundary or public contract.
|
||||||
* Choose collection types based on lookup, ordering, mutation, and iteration needs. Pre-size collections only when expected capacity is known and material.
|
* Choose collection types based on lookup, ordering, mutation, and iteration needs. Pre-size collections only when expected capacity is known and material.
|
||||||
* Avoid large mutable structs and direct exposure of mutable internal collections.
|
* Avoid large mutable structs and direct exposure of mutable internal collections.
|
||||||
* Prefer immutable data where practical, while preserving established API and serialization contracts.
|
* Prefer immutable data and readonly state where practical, while preserving established API and serialization contracts.
|
||||||
* Use `Span<T>` and related low-level types only when the performance rule's measurement justifies their lifetime and ownership constraints.
|
|
||||||
@@ -2,49 +2,28 @@
|
|||||||
|
|
||||||
A task is only considered done when all applicable requirements below are fulfilled.
|
A task is only considered done when all applicable requirements below are fulfilled.
|
||||||
|
|
||||||
## Coding
|
## Requirements
|
||||||
|
|
||||||
|
* Every requirement in the work item description and every acceptance criterion is implemented or otherwise resolved; none is left partially implemented.
|
||||||
|
* Requirements are checked against the actual implementation and the relevant code paths, not assumed to be complete.
|
||||||
|
* Any deviation from the description or acceptance criteria has been explicitly approved by the user.
|
||||||
|
* No known unresolved blocker, defect, or regression introduced by the change remains.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
* The implementation fully satisfies the requested behavior.
|
|
||||||
* All relevant acceptance criteria are implemented.
|
|
||||||
* The work item description is fully addressed.
|
|
||||||
* Verification is sufficient for the change's risk and scope, following `general.md`'s Verification rules. A build, test run, new test, or independent review is not required for every change.
|
* Verification is sufficient for the change's risk and scope, following `general.md`'s Verification rules. A build, test run, new test, or independent review is not required for every change.
|
||||||
* Checks selected on that basis, and checks explicitly required by the user or project, have passed. Material gaps and pre-existing failures are reported without claiming unverified success.
|
* Checks selected on that basis, and checks explicitly required by the user or project, have passed. Material gaps and pre-existing failures are reported without claiming unverified success.
|
||||||
* No known unresolved defects or regressions introduced by the change remain.
|
|
||||||
* The implementation follows the project's coding, architecture, security, and performance guidelines.
|
|
||||||
* No unrelated changes are included.
|
|
||||||
* No temporary code, debug output, commented-out code, placeholders, or TODOs remain unless explicitly intended.
|
|
||||||
* Error handling and edge cases are handled appropriately.
|
|
||||||
* The implementation is readable, maintainable, and production-ready.
|
|
||||||
* Directly affected documentation and comments are updated; other documentation or comments are added only for a concrete need.
|
|
||||||
* Any delegated research, review, or verification is complete and its relevant findings are resolved or reported.
|
* Any delegated research, review, or verification is complete and its relevant findings are resolved or reported.
|
||||||
|
|
||||||
## Work Items
|
## Code
|
||||||
|
|
||||||
A work item is only considered complete when:
|
* The implementation follows the project's coding, architecture, security, and performance guidelines and handles errors and edge cases appropriately.
|
||||||
|
* No unrelated changes are included.
|
||||||
|
* No temporary code, debug output, commented-out code, placeholders, or TODOs remain unless explicitly intended.
|
||||||
|
* Directly affected documentation and comments are updated; other documentation or comments are added only for a concrete need.
|
||||||
|
|
||||||
* Every acceptance criterion is fulfilled.
|
## Work item state
|
||||||
* Every requirement in the description is implemented or otherwise resolved.
|
|
||||||
* Acceptance criteria and description are checked against the actual implementation, not assumed to be complete.
|
|
||||||
* No known requirement is left partially implemented.
|
|
||||||
* No unresolved blocker or relevant defect remains.
|
|
||||||
* Required tests or validations have been completed successfully.
|
|
||||||
* Any deviation from the description or acceptance criteria has been explicitly approved by the user.
|
|
||||||
|
|
||||||
## Completion Check
|
* Do not mark, close, resolve, or otherwise change the state of a work item automatically. A task may be technically complete without changing its remote work item state.
|
||||||
|
|
||||||
Before declaring a task complete:
|
|
||||||
|
|
||||||
* Re-read the work item description.
|
|
||||||
* Re-read all acceptance criteria.
|
|
||||||
* Compare each requirement against the implemented result.
|
|
||||||
* Verify the relevant code paths.
|
|
||||||
* Choose and complete the smallest sufficient verification; expand only when evidence or risk warrants it.
|
|
||||||
* Check for incomplete or unrelated changes.
|
|
||||||
* Confirm that the final state matches the requested outcome.
|
|
||||||
|
|
||||||
## Important Rule
|
|
||||||
|
|
||||||
* Do not mark, close, resolve, or otherwise change the state of a work item automatically.
|
|
||||||
* A task may be technically complete without changing its remote work item state.
|
|
||||||
* Perform an Azure DevOps write only when the user explicitly requests or approves that specific action. A direct request in the current task is sufficient; do not ask for the same approval again.
|
* Perform an Azure DevOps write only when the user explicitly requests or approves that specific action. A direct request in the current task is sufficient; do not ask for the same approval again.
|
||||||
* If completion is unclear, requirements conflict, or something is missing, ask the user instead of deciding autonomously.
|
* If completion is unclear, requirements conflict, or something is missing, ask the user instead of deciding autonomously.
|
||||||
@@ -6,7 +6,8 @@ Preserve security, data, compatibility, and unrelated user work. Ask only when b
|
|||||||
|
|
||||||
Use one applicable workflow per task. Treat skills and plugins as guidance subordinate to these rules. Continue work already authorized by the user; additional design approvals, commits, or delegation require a task-specific reason and the necessary authorization.
|
Use one applicable workflow per task. Treat skills and plugins as guidance subordinate to these rules. Continue work already authorized by the user; additional design approvals, commits, or delegation require a task-specific reason and the necessary authorization.
|
||||||
|
|
||||||
Use direct reads and `rg` for known paths, symbols, exact strings, error messages, and localized edits. For concept questions where the identifiers are unknown or the flow spans components, consider the `semantic-search` skill when it is installed: check its cost (repository size, index state, loaded models) against the expected `rg` effort as the skill describes, verify its hits against the current files, and fall back to `rg` with a brief note if it fails.
|
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.
|
||||||
|
|
||||||
Verify proportionally: run the cheapest check that detects likely failure; broaden for security, data loss, integration, or cross-component risk. Never claim unrun checks passed. Report failures and material gaps.
|
Verify proportionally: run the cheapest check that detects likely failure; broaden for security, data loss, integration, or cross-component risk. Never claim unrun checks passed. Report failures and material gaps.
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ Apply these rules when designing public APIs, service boundaries, or application
|
|||||||
* Authorize the specific resource and operation where data is accessed or changed. Preserve ownership and tenant scope across alternate paths and background work.
|
* Authorize the specific resource and operation where data is accessed or changed. Preserve ownership and tenant scope across alternate paths and background work.
|
||||||
* Bound payloads, page sizes, query complexity, and operation cost. Use pagination or streaming for potentially large results.
|
* Bound payloads, page sizes, query complexity, and operation cost. Use pagination or streaming for potentially large results.
|
||||||
* Keep domain decisions separate from transport, persistence, and framework wiring. Make dependencies explicit; use dependency injection for real lifecycle or substitution needs.
|
* Keep domain decisions separate from transport, persistence, and framework wiring. Make dependencies explicit; use dependency injection for real lifecycle or substitution needs.
|
||||||
* Prefer composition and small contracts at real boundaries. Avoid service locators, broad base classes, reflection, and wrappers without a concrete purpose.
|
* Use small contracts at real boundaries. Avoid service locators.
|
||||||
* Preserve existing API and serialization behavior unless the task requires a change. Treat changes to nullability, ordering, exception types, and collection mutability as compatibility changes.
|
* Preserve existing API and serialization behavior unless the task requires a change. Treat changes to nullability, ordering, exception types, and collection mutability as compatibility changes.
|
||||||
@@ -6,7 +6,7 @@ Apply these rules when editing Java syntax, class structure, and everyday code o
|
|||||||
* Prefer direct control flow and descriptive domain names. Use streams when they clarify a transformation; loops are often clearer for stateful work or error handling.
|
* Prefer direct control flow and descriptive domain names. Use streams when they clarify a transformation; loops are often clearer for stateful work or error handling.
|
||||||
* Use local `var` only when the initializer makes the type obvious. Keep public contracts explicitly typed.
|
* Use local `var` only when the initializer makes the type obvious. Keep public contracts explicitly typed.
|
||||||
* Use records for transparent immutable carriers and sealed types for genuinely closed variants when the target release supports them. Do not force them onto mutable entities or framework-managed types.
|
* Use records for transparent immutable carriers and sealed types for genuinely closed variants when the target release supports them. Do not force them onto mutable entities or framework-managed types.
|
||||||
* Prefer composition and focused classes over inheritance, reflection, global mutable state, or speculative interfaces.
|
* Prefer composition and focused classes over inheritance, broad base classes, reflection, wrappers, global mutable state, or speculative interfaces.
|
||||||
* Make ownership, mutation, and side effects visible. Use `final` where it clarifies invariants; do not add it mechanically to every local variable.
|
* Make ownership, mutation, and side effects visible. Use `final` where it clarifies invariants; do not add it mechanically to every local variable.
|
||||||
* Prefer supported `java.time` APIs and standard library facilities over obsolete APIs or custom helpers when they fit the contract.
|
* Prefer supported `java.time` APIs and standard library facilities over obsolete APIs or custom helpers when they fit the contract.
|
||||||
* Remove dead code. Add comments only for non-obvious decisions or public contracts that code cannot express clearly.
|
* Remove dead code. Add comments only for non-obvious decisions or public contracts that code cannot express clearly.
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
* Avoid unnecessary wrapper classes and indirection.
|
* Avoid unnecessary wrapper classes and indirection.
|
||||||
* Prefer composition over inheritance.
|
* Prefer composition over inheritance.
|
||||||
* Remove dead code when it is clearly unused.
|
* Remove dead code when it is clearly unused.
|
||||||
* Remove obsolete comments and commented-out code.
|
* Remove obsolete comments.
|
||||||
* Keep methods and classes focused.
|
* Keep methods and classes focused.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
* Prefer concrete types where no abstraction is required.
|
* Prefer concrete types where no abstraction is required.
|
||||||
* Use descriptive names.
|
* Use descriptive names.
|
||||||
* Remove dead code.
|
* Remove dead code.
|
||||||
* Remove commented-out code.
|
|
||||||
* Add comments only for non-obvious decisions or public contracts.
|
* Add comments only for non-obvious decisions or public contracts.
|
||||||
* Treat compiler, analyzer and binding warnings seriously.
|
* Treat compiler, analyzer and binding warnings seriously.
|
||||||
* Do not suppress warnings without a concrete reason.
|
* Do not suppress warnings without a concrete reason.
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: repository-research
|
name: repository-research
|
||||||
description: Use for codebase-specific questions or before changing an unfamiliar area; trace existing files, callers, behavior, tests, and conventions. Do not use for general external technology research.
|
description: MUST use before changing, reviewing, or answering questions about any repository area not yet read in this session; trace files, callers, behavior, tests, and conventions. Not for external technology questions (use technical-research).
|
||||||
---
|
---
|
||||||
|
|
||||||
# Repository Research
|
# Repository Research
|
||||||
|
|
||||||
Use to understand an existing repository area before changing it or answering a codebase-specific question.
|
Understand an existing repository area before changing it or answering a codebase-specific question. Do not edit or answer from memory of an area you have not read in this session.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Start from known paths, symbols, exact strings, or configuration keys. Read relevant files directly and search callers with repository-native tools.
|
1. Locate the entry point. Known path, symbol, exact string, or configuration key: read it directly or search with `rg`. Unknown identifiers: run `semantic-search` first when it is installed.
|
||||||
2. Trace the flow from entry point through shared helpers to observable behavior. Check related implementations, tests, configuration, and documentation.
|
2. Trace the flow from entry point through shared helpers to observable behavior. Search callers and tests of every symbol you will change.
|
||||||
3. Identify established conventions and note where they differ across clients, shells, or platforms.
|
3. Check related implementations, tests, configuration, and documentation. Note established conventions and where they differ across clients, shells, or platforms.
|
||||||
4. Use semantic search only when terminology is unclear, the flow spans components, or direct search has not found the relevant code. Confirm any retrieved result against current files.
|
4. When the change depends on an external library, API, tool version, or standard whose behavior you have not verified, continue with `technical-research` for that part.
|
||||||
5. Report file paths and symbols as evidence. Separate confirmed behavior, inference, and unresolved questions.
|
5. Report file paths and symbols as evidence. Separate confirmed behavior, inference, and unresolved questions.
|
||||||
|
|
||||||
Return the relevant architecture, existing patterns, affected areas, and unknowns. Avoid changing implementation code during research unless asked.
|
Return the relevant architecture, existing patterns, affected areas, and unknowns. Do not change implementation code during research unless asked.
|
||||||
@@ -1,53 +1,31 @@
|
|||||||
---
|
---
|
||||||
name: semantic-search
|
name: semantic-search
|
||||||
description: Use when you must find where a behavior, concept, or rule lives in a repository and do not know its identifiers or exact strings, or when a first rg search returned nothing useful. Returns ranked code and documentation chunks from the local Qwen index.
|
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
|
# Semantic Search
|
||||||
|
|
||||||
Search the current Git repository by meaning with the locally installed Qwen3 embedding and reranking models.
|
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.
|
||||||
|
|
||||||
## When to use
|
## Rule
|
||||||
|
|
||||||
- Concept questions: "where are expired trials blocked", "how is the cache invalidated", "which code retries failed uploads".
|
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. This skill locates code; `repository-research` then traces the flow from the verified hits.
|
||||||
- Unfamiliar repositories where names and conventions are unknown.
|
|
||||||
- After an `rg` search for guessed terms returned nothing relevant or too many unrelated files.
|
|
||||||
|
|
||||||
Use `rg` or direct reads instead for known file paths, symbol names, exact strings, and error messages.
|
|
||||||
|
|
||||||
## Decide by cost
|
|
||||||
|
|
||||||
Check the cost of the next search first: run the command under Run with `status` in place of `search` and without `--query` and `--top-k`. It loads no models, takes about a second, and prints `{indexable_files, indexed_files, stale_files, models_loaded, in_process}`. Weigh that against the `rg` rounds you expect:
|
|
||||||
|
|
||||||
- Fewer than about 200 indexable files, or one or two guessed terms likely to hit: use `rg`; a search costs more than it saves.
|
|
||||||
- `stale_files` in the thousands, including a first index (`indexed_files` 0): the search embeds them first, which can take minutes. Pay that only for a large unfamiliar repository or several concept questions in this session.
|
|
||||||
- `models_loaded` false: the search loads the models first, which takes tens of seconds. With `in_process` true, as inside the Codex sandbox, every search pays that again; search only when `rg` would need several rounds, and batch related questions into one precise query.
|
|
||||||
- Index fresh and `models_loaded` true: a search takes seconds; use it for any concept question.
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
PowerShell:
|
The same command works in Bash and PowerShell:
|
||||||
|
|
||||||
```powershell
|
node "$HOME/.my-ai-configuration/qmd/qmd-search.mjs" --query "<full question about behavior>" --top-k 5
|
||||||
python "$HOME/.my-ai-configuration/semantic-retrieval/server.py" search --root . --data-dir "$HOME/.my-ai-configuration/semantic-retrieval/data" --model-cache "$HOME/.my-ai-configuration/semantic-retrieval/model-cache" --query "<question in natural language>" --top-k 5
|
|
||||||
```
|
|
||||||
|
|
||||||
Bash:
|
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>`.
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 "$HOME/.my-ai-configuration/semantic-retrieval/server.py" search --root . --data-dir "$HOME/.my-ai-configuration/semantic-retrieval/data" --model-cache "$HOME/.my-ai-configuration/semantic-retrieval/model-cache" --query "<question in natural language>" --top-k 5
|
|
||||||
```
|
|
||||||
|
|
||||||
The output is a JSON list of `{path, symbol, text, score}`, best match first.
|
|
||||||
|
|
||||||
## Use the results
|
## Use the results
|
||||||
|
|
||||||
1. Phrase the query as a full question about behavior, not as a keyword list.
|
1. Phrase the query as a full question about behavior, not a keyword list. Batch related questions into one precise query.
|
||||||
2. Open the top hits and verify them against the current files before acting; the index can lag behind uncommitted edits by one refresh.
|
2. Open the top hits and verify them against the current files before acting; the index can lag behind uncommitted edits.
|
||||||
3. Follow the verified hit with `rg` for its symbols to find callers and tests.
|
3. If no hit is relevant, run one rephrased search before switching to `rg`.
|
||||||
|
4. Continue with `repository-research` from the verified hits: callers, tests, and conventions.
|
||||||
|
|
||||||
## Cost and limits
|
## Failure
|
||||||
|
|
||||||
- Searches refresh only changed files. Outside a sandbox a background daemon keeps the models loaded for 30 idle minutes; inside the Codex sandbox there is no daemon.
|
If the command exits non-zero, continue with `rg` and state the limitation in one sentence.
|
||||||
- Only Git-tracked and unignored files up to 512 KB are indexed, by default at most 2000 files per repository.
|
|
||||||
- If the command fails or the setup is missing, continue with `rg` and state the limitation briefly.
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: technical-research
|
name: technical-research
|
||||||
description: Use before implementation when an unfamiliar or changing technology, API, standard, or technical trade-off needs current evidence. Prefer primary sources; do not use for repository-only code discovery.
|
description: MUST use before relying on an external library, API, tool, product version, or standard whose current behavior you have not verified in this session, and before choosing between technical options. Prefer primary sources. Not for repository code discovery (use repository-research).
|
||||||
---
|
---
|
||||||
|
|
||||||
# Technical Research
|
# Technical Research
|
||||||
|
|
||||||
Use before implementation when an unfamiliar technology, API, current product behavior, or technical trade-off could change the solution.
|
Verify external technology before it shapes the solution. Do not rely on remembered API signatures, defaults, or version behavior when they could have changed.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Turn the implementation question into specific facts or decisions that need evidence.
|
1. Turn the question into specific facts or decisions that need evidence. When the question concerns how this repository already uses the technology, run `repository-research` first and research only what the code does not answer.
|
||||||
2. Prefer current primary sources: official product documentation, standards, release notes, or the original research. Check version and date when behavior may have changed.
|
2. Prefer current primary sources: official documentation, standards, release notes, or source code of the exact version in use. Check version and date when behavior may have changed.
|
||||||
3. Compare only viable options against the user's constraints, compatibility needs, security properties, and maintenance cost.
|
3. Compare only viable options against the user's constraints, compatibility needs, security properties, and maintenance cost.
|
||||||
4. Separate source-backed facts from interpretation. Record assumptions and any material evidence that is missing or conflicting.
|
4. Separate source-backed facts from interpretation. Record assumptions and any material evidence that is missing or conflicting.
|
||||||
5. Recommend an option only when evidence supports it. Explain the deciding trade-off briefly.
|
5. Recommend an option only when evidence supports it. Explain the deciding trade-off briefly.
|
||||||
|
|||||||
Reference in new issue
Block a user