diff --git a/README.de.md b/README.de.md deleted file mode 100644 index 4f3af6f..0000000 --- a/README.de.md +++ /dev/null @@ -1,254 +0,0 @@ -# tgrep AI Skill - -[English](README.md) | **Deutsch** - -Lokale, indexierte Suche für **Claude Code und Codex** – mit frei wählbaren -Verzeichnissen und optionalen Antworten von **Qwen über Ollama**. - -Ein gemeinsamer Skill, eine CLI, gemeinsame Indizes. tgrep sucht Text und Code; -Qwen übersetzt Fragen in Suchbegriffe und beantwortet sie anhand gefundener -Ausschnitte. Die Hauptmodelle von Claude und Codex werden dabei nicht ersetzt. - -Gemessene Performance: Der [lokale Benchmarkbericht](docs/benchmarks/README.md) -zeigt reproduzierbare CLI-Zeiten, Indexkosten und die Grenzen der synthetischen Tests. - -## Installation - -Voraussetzungen: macOS oder Linux (ARM64/x86_64), Git, Python **3.10+** mit venv. -Windows: innerhalb von WSL installieren. Kein sudo, kein pip und keine -Python-Paketdownloads erforderlich. Der Installer lädt tgrep **1.0.5** und -ripgrep **15.2.0** aus offiziellen Releases; SHA256-Werte stehen fest in -[dependencies.lock.json](dependencies.lock.json). - -~~~sh -git clone https://github.com/Jeuners/tgrep-ai-skill.git -cd tgrep-ai-skill -./install.sh -export PATH="$HOME/.local/bin:$PATH" -local-search doctor -~~~ - -Der Installer richtet beide Skills ein: - -- Claude Code: ~/.claude/skills/local-search/ -- Codex: ~/.agents/skills/local-search/ - -Er installiert einen unabhängigen Laufzeitordner unter -~/.local/share/tgrep-ai-skill/. Der Checkout kann danach verschoben werden. -Bestehende Konfiguration und Modellwahl bleiben erhalten; fremde gleichnamige -Launcher oder Skills werden nicht überschrieben. Die Home-Wurzel wird registriert, -aber noch nicht indexiert. Neue Agent-Sitzung öffnen, falls der Skill nicht erscheint. - -Nur einen Agenten installieren: - -~~~sh -./install.sh --target claude -./install.sh --target codex -~~~ - -**Installation durch einen Agenten:** Gib Claude Code oder Codex diesen Auftrag: - -> Installiere https://github.com/Jeuners/tgrep-ai-skill für Claude Code und Codex. -> Lies zuerst die README, führe den Installer aus und prüfe local-search doctor. -> Verwende mein vorhandenes Ollama-Modell. Starte noch keine Home-Indexierung. - -## Ollama und Qwen - -Die direkte Suche funktioniert ohne LLM. Für **ask** muss -[Ollama](https://ollama.com/download) separat installiert und gestartet sein: - -~~~sh -ollama serve -~~~ - -Bei laufender Ollama-App ist kein zweiter Server nötig. In einem weiteren Terminal: - -~~~sh -ollama list -# Nur falls das Modell fehlt: rund 6,6 GB Download -ollama pull qwen3.5:latest -local-search doctor -~~~ - -Dieses Modell aus der offiziellen Ollama-Library ist die Voreinstellung. Ein -anderes installiertes Modell wählen, etwa eine kleinere Variante: - -~~~sh -local-search model qwen3.5:4b -~~~ - -Modell und Loopback-URL stehen in ~/.config/local-search/config.json. -Vor jeder Modellanfrage werden die Ollama-Metadaten geprüft: Cloud-Modelle, -Remote-Aliasse und Modelle ohne erkennbare lokale Gewichte werden abgewiesen. -Der Installer lädt weder Ollama noch Modellgewichte ungefragt herunter. -Modellgewichte sind nicht Teil dieses MIT-Projekts; ihre eigenen Lizenzbedingungen -gelten. Der Standardtag verweist auf die jeweils aktuelle Version und kann sich -ändern. Ein Tag wie qwen3.5:4b legt die Modellgröße fest, bleibt aber ebenfalls -veränderlich und garantiert keine unveränderten Modellgewichte. - -## Home und weitere Ordner - -~~~sh -local-search preview home -local-search index home - -local-search add projekte "/Volumes/Projekte" -local-search preview projekte -local-search index projekte - -local-search add backend "$HOME/Desktop/backend" --exclude vendor --max-filesize 16M -local-search status -~~~ - -**preview** listet eine Dateianzahl, Beispiele und Ausschlüsse; die Zahl ist eine -Schätzung vor Inhalts-/Binärprüfung. Bei gekappter Ausgabe ist sie eine Untergrenze. -**index** baut synchron auf und startet danach einen Hintergrundserver. Es gibt -keinen Login-Autostart; eine spätere Suche startet den Server bei Bedarf. - -Standardausschlüsse: .git, node_modules, .venv, venv, target, dist, build, -__pycache__, .ssh, .gnupg, .aws, .azure, .ollama, .Trash, Library, Caches. -Ausschlüsse gelten als **Verzeichnisnamen auf jeder Ebene**. -Standard-Dateigrößenlimit: **8 MiB**. Normale Ignore-Regeln gelten auch außerhalb -eines Git-Repositories. Symlinks werden nicht verfolgt. - -Versteckte Nachkommen werden nicht indexiert: tgrep 1.0.5 unterstützt -serve --hidden nicht. Einen versteckten Projektordner gegebenenfalls als eigene -Wurzel registrieren. PDFs, Office, Bilder, Archive und semantische Embedding-Suche -sind nicht enthalten. Ausschlüsse ersetzen keine allgemeine Geheimniserkennung. - -## Suchen - -~~~sh -local-search search "WebSocket" --root projekte -local-search search 'auth|login' --regex --root backend -local-search ask "Wo wird die Anmeldung geprüft?" --root backend -local-search search "TODO" --root backend --root projekte -local-search search "Rechnungsnummer" --all --paths-only -local-search search "removed_function" --root backend --fresh -~~~ - -Ohne --root wird die spezifischste registrierte Wurzel um das aktuelle -Arbeitsverzeichnis verwendet. --all durchsucht alle registrierten Wurzeln. -Überlappende Treffer werden nach kanonischem Dateipfad und Zeilennummer dedupliziert; -überlappende Indizes können trotzdem zusätzlichen Speicher und Sucharbeit kosten. - -Ausgabe: JSON mit Treffern, Quellen, Backend, Aktualität, Warnungen und -truncated. Standardmäßig höchstens 40 Treffer. --limit 100 erhöht das Limit. -Jeder Report nennt zusätzlich match_count: die pro Wurzel und Suchanfrage -gelieferten Treffer vor der globalen Zusammenführung. Diese Zahl ist bereits -durch das jeweilige Suchlimit begrenzt; bei report.truncated können weitere, -nicht gezählte Treffer existieren. Die Summe kann wegen der Deduplizierung größer -als die zusammengeführte Trefferliste sein. Bei globaler Kappung die ausgewählten -Wurzeln einzeln durchsuchen oder --limit erhöhen. -Zeilentexte sind auf 2.000 Zeichen begrenzt; ask erhält höchstens rund 12.000 -JSON-Zeichen Quellenkontext und führt maximal drei Suchbegriffe pro Wurzel aus. - -Ein laufender Index ist **eventuell konsistent**. Während Aufbau oder erkennbar -gestörter Aktualisierung wird frisch mit ripgrep gesucht. --fresh erzwingt das -auch für wichtige Negativbefunde. Ein gleichzeitig verändertes Dateisystem ist -kein atomarer Snapshot. ripgrep und tgrep können bei Randfällen ihrer -Ignore-/Binärbehandlung abweichen. - -Exitcodes: **0** Erfolg/Treffer, **1** keine Treffer, **2** Fehler, -**130** abgebrochen. Ollama-Ausfall ist ein Fehler bei ask; search bleibt nutzbar. - -In Claude Code: /local-search. In Codex: $local-search. -Bei expliziten Vorgaben „immer zuerst rg“ muss die übergeordnete Regel eine -Ausnahme erlauben; der Skill überschreibt sie nicht. - -**Datengrenze:** tgrep und Qwen arbeiten lokal. Ausgaben, die ein Claude-/Codex-Agent -liest, gelangen dennoch in dessen Kontext. --paths-only unterdrückt Ausschnitte -und generierte Antworten, nicht Dateinamen. Bei ask --paths-only wird nur die -Frage zur lokalen Suchplanung an Ollama geschickt. - -## Wartung - -~~~sh -local-search stop home -local-search index home # vollständiger Neuaufbau mit anschließendem Start -local-search remove backend # Registrierung entfernen, Index behalten -~~~ - -Konfiguration: ~/.config/local-search/config.json. -Indizes/Status/Serverlogs: ~/.local/share/local-search/indexes/. -XDG_CONFIG_HOME und XDG_DATA_HOME werden unterstützt. -Eine geänderte Wurzelkonfiguration bekommt einen neuen Indexpfad. Vor manuellen -Konfigurationsänderungen den Server stoppen; danach neu indexieren. - -Update im Checkout: - -~~~sh -git pull --ff-only -./install.sh -~~~ - -Für reproduzierbare Installation vorher einen Release-Tag auschecken. Updates -legen einen neuen Laufzeitordner an; alte bleiben für laufende Server erhalten. -Server stoppen und neu starten, damit sie die neue Binärdatei verwenden. - -Deinstallation: - -~~~sh -local-search stop home -# Weitere laufende Wurzeln ebenfalls stoppen. -./install.sh --uninstall -~~~ - -Entfernt den verwalteten Launcher und die Skills. Konfiguration, Indizes und alte -Laufzeitordner bleiben absichtlich erhalten und können nach Prüfung manuell -entfernt werden. - -## Fehlerbehebung und Entwicklung - -- command not found: PATH setzen oder ~/.local/bin/local-search aufrufen. -- Python/venv fehlt: Python 3.10+ installieren; unter Debian/Ubuntu gegebenenfalls - das passende python3-venv-Paket. -- Modell nicht erreichbar: ollama list, laufende App bzw. ollama serve prüfen. -- macOS-Zugriff verweigert: betreffende Ordner benötigen ggf. Zugriff für das - verwendete Terminal. Nicht lesbare Pfade werden als Fehler gemeldet. -- Index hängt: local-search status und server.log im gemeldeten Indexpfad lesen. -- "PID identity changed": die Identität der vermerkten Prozess-ID stimmt nicht - mehr mit dem gespeicherten Suchserver überein; deshalb wird kein Signal gesendet. - Mit ps die gemeldete PID prüfen. Nur wenn die Zuordnung nachweislich veraltet ist - und kein Suchserver mehr diesen Index verwendet, die gemeldete owner.json - entfernen. Einen fremden Prozess dafür nicht beenden; anschließend erneut starten. -- Große Verzeichnisse: mit ausgewählten Projektwurzeln beginnen; Home verbraucht - je nach Inhalt erheblich Plattenplatz. Der Server startet mit 512 MiB - Indexaufbau-Budget und 25 % CPU-Budget; dies ist kein hartes Prozess-RAM-Limit. -- Offline: Releasearchive vorher herunterladen und - ./install.sh --asset-cache /pfad/zu/archiven verwenden. Fehlende Archive werden - weiterhin online angefordert. Abhängigkeiten werden stets per SHA256 geprüft. - -~~~sh -PYTHONPATH=src python3 -m unittest discover -s tests -v -~~~ - -Integrationstests mit echten Binärdateien: - -~~~sh -LOCAL_SEARCH_INTEGRATION=1 \ -LOCAL_SEARCH_TGREP=/pfad/zu/tgrep \ -LOCAL_SEARCH_RG=/pfad/zu/rg \ -PYTHONPATH=src python3 -m unittest discover -s tests -v -~~~ - -Vollständige isolierte Installation inklusive Update und Deinstallation: -python3 scripts/smoke_install.py. Optionaler Test des vorhandenen lokalen Modells -mit ausschließlich synthetischem Quelltext: python3 scripts/smoke_ollama.py. - -## Herkunft und Lizenz - -MIT, siehe [LICENSE](LICENSE). Unabhängiges Integrationsprojekt, kein offizielles -Microsoft-, Anthropic- oder OpenAI-Produkt. - -Autorschaft: Implementierung geschrieben mit OpenAI Astra. Review und -Überarbeitung durch Claude von Anthropic. -AI Operator: [H.G.O.D.](https://github.com/Jeuners). - -- [Microsoft tgrep](https://github.com/microsoft/tgrep), MIT -- [ripgrep](https://github.com/BurntSushi/ripgrep), MIT oder Unlicense -- [Ollama API](https://docs.ollama.com/api/chat) -- [Claude Code Skills](https://code.claude.com/docs/en/skills) - -Die Installer-Downloads enthalten offizielle Binärdateien; Quellcode und -Lizenztexte der Abhängigkeiten sind in deren verlinkten Repositories verfügbar. diff --git a/README.md b/README.md index a8f0555..07a93eb 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,18 @@ # tgrep AI Skill -**English** | [Deutsch](README.de.md) +Lokale, indexierte Suche für **Claude Code und Codex** – mit frei wählbaren +Verzeichnissen und optionalen Antworten von **Qwen über Ollama**. -Local, indexed search for **Claude Code and Codex**, with directories of your -choice and optional answers from **Qwen through Ollama**. - -One shared skill, one CLI, shared indexes. tgrep searches text and code; -Qwen turns questions into search terms and answers them using matching excerpts. -It does not replace the main models used by Claude and Codex. - -Measured performance: see the [local benchmark report](docs/benchmarks/README.md) -for reproducible CLI timings, index costs, and the limits of the synthetic tests. +Ein gemeinsamer Skill, eine CLI, gemeinsame Indizes. tgrep sucht Text und Code; +Qwen übersetzt Fragen in Suchbegriffe und beantwortet sie anhand gefundener +Ausschnitte. Die Hauptmodelle von Claude und Codex werden dabei nicht ersetzt. ## Installation -Requirements: macOS or Linux (ARM64/x86_64), Git, Python **3.10+** with venv. -On Windows, install inside WSL. No sudo, pip, or Python package downloads are -required. The installer downloads tgrep **1.0.5** and ripgrep **15.2.0** from -official releases, with SHA256 hashes pinned in +Voraussetzungen: macOS oder Linux (ARM64/x86_64), Git, Python **3.10+** mit venv. +Windows: innerhalb von WSL installieren. Kein sudo, kein pip und keine +Python-Paketdownloads erforderlich. Der Installer lädt tgrep **1.0.5** und +ripgrep **15.2.0** aus offiziellen Releases; SHA256-Werte stehen fest in [dependencies.lock.json](dependencies.lock.json). ~~~sh @@ -28,67 +23,65 @@ export PATH="$HOME/.local/bin:$PATH" local-search doctor ~~~ -The installer sets up both skills: +Der Installer richtet beide Skills ein: - Claude Code: ~/.claude/skills/local-search/ - Codex: ~/.agents/skills/local-search/ -It installs a standalone runtime directory under -~/.local/share/tgrep-ai-skill/. You can move the checkout afterward. -Existing configuration and model choices are preserved; unrelated launchers or -skills with the same name are not overwritten. Your home directory is registered -as a search root, but is not indexed yet. Start a new agent session if the skill -does not appear. +Er installiert einen unabhängigen Laufzeitordner unter +~/.local/share/tgrep-ai-skill/. Der Checkout kann danach verschoben werden. +Bestehende Konfiguration und Modellwahl bleiben erhalten; fremde gleichnamige +Launcher oder Skills werden nicht überschrieben. Die Home-Wurzel wird registriert, +aber noch nicht indexiert. Neue Agent-Sitzung öffnen, falls der Skill nicht erscheint. -To install for just one agent: +Nur einen Agenten installieren: ~~~sh ./install.sh --target claude ./install.sh --target codex ~~~ -**Let an agent install it:** Give Claude Code or Codex this instruction: +**Installation durch einen Agenten:** Gib Claude Code oder Codex diesen Auftrag: -> Install https://github.com/Jeuners/tgrep-ai-skill for Claude Code and Codex. -> Read the README first, run the installer, and check local-search doctor. -> Use my existing Ollama model. Do not start indexing my home directory yet. +> Installiere https://github.com/Jeuners/tgrep-ai-skill für Claude Code und Codex. +> Lies zuerst die README, führe den Installer aus und prüfe local-search doctor. +> Verwende mein vorhandenes Ollama-Modell. Starte noch keine Home-Indexierung. -## Ollama and Qwen +## Ollama und Qwen -Direct search works without an LLM. To use **ask**, install and start -[Ollama](https://ollama.com/download) separately: +Die direkte Suche funktioniert ohne LLM. Für **ask** muss +[Ollama](https://ollama.com/download) separat installiert und gestartet sein: ~~~sh ollama serve ~~~ -If the Ollama app is already running, you do not need a second server. -In another terminal: +Bei laufender Ollama-App ist kein zweiter Server nötig. In einem weiteren Terminal: ~~~sh ollama list -# Only if the model is missing: approximately 6.6 GB download +# Nur falls das Modell fehlt: rund 6,6 GB Download ollama pull qwen3.5:latest local-search doctor ~~~ -This model from the official Ollama library is the default. To select another -installed model, such as a smaller variant: +Dieses Modell aus der offiziellen Ollama-Library ist die Voreinstellung. Ein +anderes installiertes Modell wählen, etwa eine kleinere Variante: ~~~sh local-search model qwen3.5:4b ~~~ -The model and loopback URL are stored in ~/.config/local-search/config.json. -Ollama metadata is checked before every model request: cloud models, remote -aliases, and models without identifiable local weights are rejected. -The installer does not download Ollama or model weights without being asked. -Model weights are not part of this MIT project; their own license terms apply. -The default tag points to the latest version and can change. A tag such as -qwen3.5:4b specifies the model size, but is also mutable and does not guarantee -unchanged model weights. +Modell und Loopback-URL stehen in ~/.config/local-search/config.json. +Vor jeder Modellanfrage werden die Ollama-Metadaten geprüft: Cloud-Modelle, +Remote-Aliasse und Modelle ohne erkennbare lokale Gewichte werden abgewiesen. +Der Installer lädt weder Ollama noch Modellgewichte ungefragt herunter. +Modellgewichte sind nicht Teil dieses MIT-Projekts; ihre eigenen Lizenzbedingungen +gelten. Der Standardtag verweist auf die jeweils aktuelle Version und kann sich +ändern. Ein Tag wie qwen3.5:4b legt die Modellgröße fest, bleibt aber ebenfalls +veränderlich und garantiert keine unveränderten Modellgewichte. -## Home and other directories +## Home und weitere Ordner ~~~sh local-search preview home @@ -102,23 +95,23 @@ local-search add backend "$HOME/Desktop/backend" --exclude vendor --max-filesize local-search status ~~~ -**preview** shows a file count, examples, and exclusions. The count is an estimate -before content and binary checks; if output is truncated, it is a lower bound. -**index** builds the index synchronously, then starts a background server. -There is no automatic startup at login; a later search starts the server if needed. +**preview** listet eine Dateianzahl, Beispiele und Ausschlüsse; die Zahl ist eine +Schätzung vor Inhalts-/Binärprüfung. Bei gekappter Ausgabe ist sie eine Untergrenze. +**index** baut synchron auf und startet danach einen Hintergrundserver. Es gibt +keinen Login-Autostart; eine spätere Suche startet den Server bei Bedarf. -Default exclusions: .git, node_modules, .venv, venv, target, dist, build, +Standardausschlüsse: .git, node_modules, .venv, venv, target, dist, build, __pycache__, .ssh, .gnupg, .aws, .azure, .ollama, .Trash, Library, Caches. -Exclusions match **directory names at every level**. -The default file size limit is **8 MiB**. Standard ignore rules also apply outside -Git repositories. Symlinks are not followed. +Ausschlüsse gelten als **Verzeichnisnamen auf jeder Ebene**. +Standard-Dateigrößenlimit: **8 MiB**. Normale Ignore-Regeln gelten auch außerhalb +eines Git-Repositories. Symlinks werden nicht verfolgt. -Hidden descendants are not indexed: tgrep 1.0.5 does not support serve --hidden. -If needed, register a hidden project directory as a separate root. -PDFs, Office documents, images, archives, and semantic embedding search are not -supported. Exclusions do not provide general secret detection. +Versteckte Nachkommen werden nicht indexiert: tgrep 1.0.5 unterstützt +serve --hidden nicht. Einen versteckten Projektordner gegebenenfalls als eigene +Wurzel registrieren. PDFs, Office, Bilder, Archive und semantische Embedding-Suche +sind nicht enthalten. Ausschlüsse ersetzen keine allgemeine Geheimniserkennung. -## Searching +## Suchen ~~~sh local-search search "WebSocket" --root projekte @@ -129,106 +122,103 @@ local-search search "Rechnungsnummer" --all --paths-only local-search search "removed_function" --root backend --fresh ~~~ -Without --root, search uses the most specific registered root containing the -current working directory. --all searches every registered root. -Overlapping matches are deduplicated by canonical file path and line number; -overlapping indexes can still use extra storage and search work. +Ohne --root wird die spezifischste registrierte Wurzel um das aktuelle +Arbeitsverzeichnis verwendet. --all durchsucht alle registrierten Wurzeln. +Überlappende Treffer werden nach kanonischem Dateipfad und Zeilennummer dedupliziert; +überlappende Indizes können trotzdem zusätzlichen Speicher und Sucharbeit kosten. -Output is JSON with matches, sources, backend, freshness, warnings, and -truncated. The default limit is 40 matches. --limit 100 increases the limit. -Each report also includes match_count: the matches returned per root and search -request before the global merge. This count is already capped by the individual -search limit; when report.truncated is set, additional uncounted matches may -exist. Because of deduplication, the sum can exceed the length of the merged -match list. If the global result is truncated, search the selected roots -individually or increase --limit. -Line text is capped at 2,000 characters; ask receives at most approximately -12,000 JSON characters of source context and runs no more than three search -terms per root. +Ausgabe: JSON mit Treffern, Quellen, Backend, Aktualität, Warnungen und +truncated. Standardmäßig höchstens 40 Treffer. --limit 100 erhöht das Limit. +Jeder Report nennt zusätzlich match_count: die pro Wurzel und Suchanfrage +gelieferten Treffer vor der globalen Zusammenführung. Diese Zahl ist bereits +durch das jeweilige Suchlimit begrenzt; bei report.truncated können weitere, +nicht gezählte Treffer existieren. Die Summe kann wegen der Deduplizierung größer +als die zusammengeführte Trefferliste sein. Bei globaler Kappung die ausgewählten +Wurzeln einzeln durchsuchen oder --limit erhöhen. +Zeilentexte sind auf 2.000 Zeichen begrenzt; ask erhält höchstens rund 12.000 +JSON-Zeichen Quellenkontext und führt maximal drei Suchbegriffe pro Wurzel aus. -A running index is **eventually consistent**. During index building or a detected -update problem, search falls back to a fresh ripgrep scan. --fresh forces this -behavior, including when it is important to confirm that no matches exist. -A filesystem that changes during a search is not an atomic snapshot. -ripgrep and tgrep can differ in edge cases involving ignore rules and binary -file handling. +Ein laufender Index ist **eventuell konsistent**. Während Aufbau oder erkennbar +gestörter Aktualisierung wird frisch mit ripgrep gesucht. --fresh erzwingt das +auch für wichtige Negativbefunde. Ein gleichzeitig verändertes Dateisystem ist +kein atomarer Snapshot. ripgrep und tgrep können bei Randfällen ihrer +Ignore-/Binärbehandlung abweichen. -Exit codes: **0** success/matches, **1** no matches, **2** error, -**130** interrupted. An Ollama outage is an error for ask; search remains usable. +Exitcodes: **0** Erfolg/Treffer, **1** keine Treffer, **2** Fehler, +**130** abgebrochen. Ollama-Ausfall ist ein Fehler bei ask; search bleibt nutzbar. In Claude Code: /local-search. In Codex: $local-search. -If a higher-priority instruction explicitly says “always use rg first,” that -instruction must allow an exception; the skill does not override it. +Bei expliziten Vorgaben „immer zuerst rg“ muss die übergeordnete Regel eine +Ausnahme erlauben; der Skill überschreibt sie nicht. -**Data boundary:** tgrep and Qwen run locally. Output read by a Claude or Codex -agent still enters that agent's context. --paths-only suppresses excerpts and -generated answers, but not filenames. With ask --paths-only, only the question -is sent to Ollama for local search planning. +**Datengrenze:** tgrep und Qwen arbeiten lokal. Ausgaben, die ein Claude-/Codex-Agent +liest, gelangen dennoch in dessen Kontext. --paths-only unterdrückt Ausschnitte +und generierte Antworten, nicht Dateinamen. Bei ask --paths-only wird nur die +Frage zur lokalen Suchplanung an Ollama geschickt. -## Maintenance +## Wartung ~~~sh local-search stop home -local-search index home # rebuild completely, then start the server -local-search remove backend # remove registration, keep the index +local-search index home # vollständiger Neuaufbau mit anschließendem Start +local-search remove backend # Registrierung entfernen, Index behalten ~~~ -Configuration: ~/.config/local-search/config.json. -Indexes, status, and server logs: ~/.local/share/local-search/indexes/. -XDG_CONFIG_HOME and XDG_DATA_HOME are supported. -Changing a root's configuration gives it a new index path. Stop the server -before editing configuration manually, then rebuild the index. +Konfiguration: ~/.config/local-search/config.json. +Indizes/Status/Serverlogs: ~/.local/share/local-search/indexes/. +XDG_CONFIG_HOME und XDG_DATA_HOME werden unterstützt. +Eine geänderte Wurzelkonfiguration bekommt einen neuen Indexpfad. Vor manuellen +Konfigurationsänderungen den Server stoppen; danach neu indexieren. -To update from the checkout: +Update im Checkout: ~~~sh git pull --ff-only ./install.sh ~~~ -For a reproducible installation, check out a release tag first. Updates create -a new runtime directory; old ones remain available for running servers. -Stop and restart servers so they use the new binary. +Für reproduzierbare Installation vorher einen Release-Tag auschecken. Updates +legen einen neuen Laufzeitordner an; alte bleiben für laufende Server erhalten. +Server stoppen und neu starten, damit sie die neue Binärdatei verwenden. -To uninstall: +Deinstallation: ~~~sh local-search stop home -# Stop any other running roots as well. +# Weitere laufende Wurzeln ebenfalls stoppen. ./install.sh --uninstall ~~~ -This removes the managed launcher and skills. Configuration, indexes, and old -runtime directories are deliberately retained; you can review and remove them -manually. +Entfernt den verwalteten Launcher und die Skills. Konfiguration, Indizes und alte +Laufzeitordner bleiben absichtlich erhalten und können nach Prüfung manuell +entfernt werden. -## Troubleshooting and development +## Fehlerbehebung und Entwicklung -- command not found: set PATH or run ~/.local/bin/local-search. -- Missing Python/venv: install Python 3.10+; on Debian/Ubuntu, you may also need - the matching python3-venv package. -- Model unavailable: check ollama list and the running app or ollama serve. -- macOS access denied: your terminal may need permission to access the affected - directories. Unreadable paths are reported as errors. -- Stuck index: check local-search status and server.log in the reported index path. -- "PID identity changed": the recorded process ID no longer matches the saved - search server identity, so no signal is sent. Inspect the reported PID with ps. - Remove the reported owner.json only after confirming that the record is stale - and no search server still uses that index. Do not terminate an unrelated - process to resolve this; then try starting the server again. -- Large directories: start with selected project roots. Indexing home can use - substantial disk space, depending on its contents. The server starts with a - 512 MiB index-building budget and a 25 % CPU budget; this is not a hard limit - on total process RAM. -- Offline installation: download the release archives in advance and use - ./install.sh --asset-cache /pfad/zu/archiven. Missing archives are still - requested online. Dependencies are always verified using SHA256. +- command not found: PATH setzen oder ~/.local/bin/local-search aufrufen. +- Python/venv fehlt: Python 3.10+ installieren; unter Debian/Ubuntu gegebenenfalls + das passende python3-venv-Paket. +- Modell nicht erreichbar: ollama list, laufende App bzw. ollama serve prüfen. +- macOS-Zugriff verweigert: betreffende Ordner benötigen ggf. Zugriff für das + verwendete Terminal. Nicht lesbare Pfade werden als Fehler gemeldet. +- Index hängt: local-search status und server.log im gemeldeten Indexpfad lesen. +- "PID identity changed": die Identität der vermerkten Prozess-ID stimmt nicht + mehr mit dem gespeicherten Suchserver überein; deshalb wird kein Signal gesendet. + Mit ps die gemeldete PID prüfen. Nur wenn die Zuordnung nachweislich veraltet ist + und kein Suchserver mehr diesen Index verwendet, die gemeldete owner.json + entfernen. Einen fremden Prozess dafür nicht beenden; anschließend erneut starten. +- Große Verzeichnisse: mit ausgewählten Projektwurzeln beginnen; Home verbraucht + je nach Inhalt erheblich Plattenplatz. Der Server startet mit 512 MiB + Indexaufbau-Budget und 25 % CPU-Budget; dies ist kein hartes Prozess-RAM-Limit. +- Offline: Releasearchive vorher herunterladen und + ./install.sh --asset-cache /pfad/zu/archiven verwenden. Fehlende Archive werden + weiterhin online angefordert. Abhängigkeiten werden stets per SHA256 geprüft. ~~~sh PYTHONPATH=src python3 -m unittest discover -s tests -v ~~~ -Integration tests with real binaries: +Integrationstests mit echten Binärdateien: ~~~sh LOCAL_SEARCH_INTEGRATION=1 \ @@ -237,23 +227,23 @@ LOCAL_SEARCH_RG=/pfad/zu/rg \ PYTHONPATH=src python3 -m unittest discover -s tests -v ~~~ -Full isolated installation, including update and uninstallation: -python3 scripts/smoke_install.py. Optional test of an existing local model, -using only synthetic source code: python3 scripts/smoke_ollama.py. +Vollständige isolierte Installation inklusive Update und Deinstallation: +python3 scripts/smoke_install.py. Optionaler Test des vorhandenen lokalen Modells +mit ausschließlich synthetischem Quelltext: python3 scripts/smoke_ollama.py. -## Credits and license +## Herkunft und Lizenz -MIT; see [LICENSE](LICENSE). This is an independent integration project, -not an official Microsoft, Anthropic, or OpenAI product. +MIT, siehe [LICENSE](LICENSE). Unabhängiges Integrationsprojekt, kein offizielles +Microsoft-, Anthropic- oder OpenAI-Produkt. -Authorship: implementation written with OpenAI Astra. Review and revisions -by Claude from Anthropic. +Autorschaft: Implementierung geschrieben mit OpenAI Astra. Review und +Überarbeitung durch Claude von Anthropic. AI Operator: [H.G.O.D.](https://github.com/Jeuners). - [Microsoft tgrep](https://github.com/microsoft/tgrep), MIT -- [ripgrep](https://github.com/BurntSushi/ripgrep), MIT or Unlicense +- [ripgrep](https://github.com/BurntSushi/ripgrep), MIT oder Unlicense - [Ollama API](https://docs.ollama.com/api/chat) - [Claude Code Skills](https://code.claude.com/docs/en/skills) -Installer downloads contain official binaries; dependency source code and -license texts are available in their linked repositories. +Die Installer-Downloads enthalten offizielle Binärdateien; Quellcode und +Lizenztexte der Abhängigkeiten sind in deren verlinkten Repositories verfügbar. diff --git a/docs/HANDOFF-astra.md b/docs/HANDOFF-astra.md index 45c2d06..dd653be 100644 --- a/docs/HANDOFF-astra.md +++ b/docs/HANDOFF-astra.md @@ -2,7 +2,6 @@ Repo: https://github.com/Jeuners/tgrep-ai-skill Stand: 08.09.2026 -Status ergänzt: 09.09.2026. Die Review-Befunde unten beschreiben den damaligen Stand. Rollen: Astra hat implementiert, Claude (Anthropic) hat reviewt, H.G.O.D. ist AI Operator. Dieses Dokument ist die Übergabe nach dem externen Review. Es sagt dir, was geprüft @@ -38,9 +37,7 @@ die durchgehend ehrliche Dokumentation der Grenzen (eventual consistency, ## 2. Was im Review-Commit geändert wurde -Commit `5e58144` "Default to the official qwen3.5 tag, report per-root match counts". -Der externe Patch trug ursprünglich die Commit-ID `8151ec6`; im Repository wurde -er als `5e58144` angewendet. `8151ec6` ist kein Commit dieses Repositories. +Commit `8151ec6` "Default to the official qwen3.5 tag, report per-root match counts". Fünf Dateien, 31 Zeilen rein, 10 raus. Tests danach grün. ### 2.1 Default-Modell (`src/local_search/config.py`) @@ -124,18 +121,13 @@ Wenn eine davon fallen soll, vorher begründen, nicht nebenbei entfernen. ### 4.1 Für H.G.O.D., nicht für dich (braucht Push- bzw. UI-Rechte) -- `v0.1.0` zeigt auf `a52a726`, vor den Review-Korrekturen, und bleibt unverändert. - `v0.1.1` wurde am 09.09.2026 auf dem korrigierten Stand `0fce1aa` veröffentlicht. -- Repo-Beschreibung und Topics: `claude-code`, `codex`, `agent-skills`, - `ollama`, `tgrep`, `ripgrep` wurden am 09.09.2026 auf GitHub gesetzt. +- Release-Tag `v0.1.0` setzen. Die README empfiehlt für reproduzierbare Installation + "vorher einen Release-Tag auschecken", es existiert bisher keiner. +- Repo-Beschreibung und Topics setzen: `claude-code`, `codex`, `agent-skills`, + `ollama`, `tgrep`, `ripgrep`. ### 4.2 Hauptaufgabe für dich: englische README -Erledigt in `632f6cc`: `README.md` ist Englisch, `README.de.md` enthält die deutsche -Fassung, beide sind gegenseitig verlinkt. Dieser Dokumentationscommit liegt nach -dem für `v0.1.1` ausgewählten Code-Stand `0fce1aa`. -Die folgenden Anforderungen bleiben als ursprünglicher Auftrag dokumentiert. - Die Zielgruppe für Claude-Code- und Codex-Skills ist überwiegend englischsprachig, ein rein deutsches README kostet praktisch die gesamte Auffindbarkeit. Vorschlag: `README.md` englisch als Primärdokument, `README.de.md` als deutsche Fassung, gegenseitig oben diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md deleted file mode 100644 index 8ad6bf7..0000000 --- a/docs/benchmarks/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# Local performance measurements - -These measurements compare the complete skill CLI using a running tgrep index -with the same CLI using ripgrep through `--fresh`. They include Python startup, -backend selection, subprocess execution, JSON handling, and output capture. -They are not direct tgrep-versus-ripgrep binary timings. - -## Environment and method - -- Apple M4, 10 logical CPUs, 16 GiB RAM; macOS 26.5.2, Python 3.10.5. -- tgrep 1.0.5 and ripgrep 15.2.0 from the installer's pinned releases. -- Deterministic synthetic Python files in temporary directories, on local storage. - Files contain generated functions and known search markers. Their vocabulary is - deliberately simple and repetitive, which favors a compact trigram index. -- Two warmup searches per backend and query, then nine measured searches each. - Each pair runs sequentially in a seeded randomized backend order. -- Warm filesystem caches: neither OS caches nor storage caches are flushed. - The index build also reads the corpus. This measures repeated interactive search, - not cold disk performance. The machine is not a dedicated benchmark host. -- Identical root policy and literal/regex flags; maximum 1,000 returned matches. - Every query is checked against its known match count and exact sorted - `(path, line, text)` equality across both backends and every repetition. - Truncation, warnings, or fallback from tgrep abort the benchmark. -- No Ollama or Qwen requests. These numbers measure retrieval, not AI answer latency. - -Raw timings, corpus hashes, tool versions, source commit, and benchmark script hash -are recorded in [the JSON report](macos-arm64-2026-09-09.json). - -## Results - -Median wall time in milliseconds. Speedup is fresh / indexed; below 1× means -indexed search was slower. Every row passed exact match parity. - -| Files | Query | Indexed ms | Fresh ms | Speedup | -|---:|---|---:|---:|---:| -| 1,000 | Rare literal (1 match) | 44.0 | 49.7 | 1.13× | -| 1,000 | Distributed literal (1% of files) | 44.4 | 49.0 | 1.10× | -| 1,000 | Absent long literal | 44.2 | 50.0 | 1.13× | -| 1,000 | Selective regex (1 match) | 44.0 | 49.9 | 1.13× | -| 1,000 | Absent two-character literal | 45.0 | 50.1 | 1.11× | -| 20,000 | Rare literal (1 match) | 45.5 | 250.5 | 5.51× | -| 20,000 | Distributed literal (1% of files) | 53.2 | 262.7 | 4.94× | -| 20,000 | Absent long literal | 46.0 | 254.3 | 5.53× | -| 20,000 | Selective regex (1 match) | 45.9 | 236.6 | 5.15× | -| 20,000 | Absent two-character literal | 62.8 | 239.2 | 3.81× | -| 100,000 | Rare literal (1 match) | 51.9 | 1262.8 | 24.32× | -| 100,000 | Distributed literal (1% of files) | 89.7 | 1284.6 | 14.32× | -| 100,000 | Absent long literal | 51.5 | 1253.9 | 24.33× | -| 100,000 | Selective regex (1 match) | 51.8 | 1252.2 | 24.17× | -| 100,000 | Absent two-character literal | 3637.9 | 1262.0 | 0.35× | - -| Files | Source MiB | Build + start (s) | Index directory MiB | Server RSS MiB | -|---:|---:|---:|---:|---:| -| 1,000 | 3.0 | 0.136 | 2.4 | 24.0 | -| 20,000 | 63.2 | 0.685 | 47.4 | 108.9 | -| 100,000 | 323.2 | 4.245 | 237.8 | 504.2 | - -For the rare-literal query, estimated build break-even is 24 queries at 1,000 -files and 4 queries at both larger sizes. The two-character query at 100,000 files -has no break-even in this run: indexed search was about 2.88× slower than fresh. -For such queries, `--fresh` is worth comparing rather than assuming the index wins. - -The report contains 270 timed CLI invocations and 60 warmup invocations. -A preliminary run showed noticeably different timings, including better indexed -performance on the two-character query. The complete final run is reported here, -not a selection of the best samples across runs. Run-to-run variability has not -been quantified; the results do not establish its cause. - -## Interpretation and limits - -The median is the primary statistic. The JSON also records every sample and a -nearest-rank p95; with only nine measured samples, that p95 equals the maximum. -It is not a reliable estimate of production tail latency. - -Index build plus server startup is timed once per corpus, excluding generation -of the test files. Index directory size is logical file bytes, including metadata -and logs, not allocated disk blocks. Server RSS is sampled after the queries; -it is not peak memory, total system memory, or a process memory limit. - -The break-even estimate divides build-and-start time by the median per-query time -saving. It assumes repetition of that query, an unchanged corpus, and no further -index maintenance. It does not account for background CPU, power, storage costs, -or a changing working tree. - -Do not present these numbers as Chromium/gecko-dev results or a universal speedup. -Real repositories have richer trigram vocabularies, varying file sizes, ignores, -binary content, and update activity. This suite also does not cover broad queries -whose output exceeds the limit, filesystem watcher latency, cold server restarts, -or answer quality. A real-repository benchmark and a separate Qwen latency and -answer-quality evaluation are still needed for those claims. - -## Reproduce - -Install the project first, then pass the installed binaries explicitly. Their -runtime directory is recorded in `~/.local/share/tgrep-ai-skill/install.json`. -Run from the repository checkout: - -~~~sh -python3 scripts/benchmark.py \ - --tgrep /path/to/runtime/bin/tgrep \ - --rg /path/to/runtime/bin/rg \ - --sizes 1000 20000 100000 \ - --rounds 9 --warmups 2 \ - --output benchmark-results.json -~~~ - -Python 3.10+ and local loopback/process access are required. No extra Python -packages or model downloads are needed. The largest corpus contains roughly -323 MiB of source text, plus its index and temporary build files. Each corpus -is removed before the next starts. User configuration and existing indexes are -not changed. If a server cannot be stopped, its directory is preserved and -reported for diagnosis. diff --git a/docs/benchmarks/macos-arm64-2026-09-09.json b/docs/benchmarks/macos-arm64-2026-09-09.json deleted file mode 100644 index d94379a..0000000 --- a/docs/benchmarks/macos-arm64-2026-09-09.json +++ /dev/null @@ -1,694 +0,0 @@ -{ - "schema": 1, - "utc": "2026-09-09T13:52:19Z", - "platform": "macOS-26.5.2-arm64-arm-64bit", - "machine": "arm64", - "python": "3.10.5", - "logical_cpus": 10, - "cpu_model": "Apple M4", - "memory_bytes": 17179869184, - "source_commit": "da308252e0d79cfce222e4432f3615e4a317479f", - "benchmark_sha256": "384f45ab3350e8c426332e333a1b457cb6372c4aeb381d417d86ee737d7f8a24", - "tgrep_version": "tgrep 1.0.5", - "rg_version": "ripgrep 15.2.0 (rev e89fff89ac)\n\nfeatures:+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.45 is available (JIT is available)", - "rounds": 9, - "warmups": 2, - "method": "Full CLI wall time; paired randomized order; warm OS caches; synthetic Python; identical filters; exact uncapped match parity; no LLM; RSS is a point sample, not a peak; index build is one run", - "corpora": [ - { - "files": 1000, - "bytes": 3131016, - "content_sha256": "1b1c0ec839759d3091b62daa13aef7374bfe6802a579988a7e80430932c720d2", - "build_and_start_seconds": 0.13603562500793487, - "index_directory_bytes": 2508252, - "server_rss_kib_after_queries": 24560, - "queries": [ - { - "query": "rare_literal", - "pattern": "unique_benchmark_needle", - "regex": false, - "matches": 1, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 43.61937500652857, - 44.31179098901339, - 43.95524997380562, - 44.13837500032969, - 43.9454999868758, - 44.36187501414679, - 44.33654199237935, - 42.993459006538615, - 43.78887498751283 - ], - "median_ms": 43.95524997380562, - "p95_ms": 44.36187501414679, - "min_ms": 42.993459006538615, - "max_ms": 44.36187501414679 - }, - "fresh": { - "samples_ms": [ - 49.94804199668579, - 51.16362500120886, - 47.71041599451564, - 50.432207994163036, - 49.47512500802986, - 49.03329099761322, - 49.73608400905505, - 50.42800001683645, - 48.66450000554323 - ], - "median_ms": 49.73608400905505, - "p95_ms": 51.16362500120886, - "min_ms": 47.71041599451564, - "max_ms": 51.16362500120886 - }, - "speedup": 1.131516349894368, - "build_break_even_queries": 24 - }, - { - "query": "distributed_literal", - "pattern": "batch_benchmark_marker", - "regex": false, - "matches": 10, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 44.348625000566244, - 44.457541982410476, - 44.44141700514592, - 44.35670800739899, - 44.05324999243021, - 45.027125015622005, - 45.141874987166375, - 44.143374980194494, - 44.272375002037734 - ], - "median_ms": 44.35670800739899, - "p95_ms": 45.141874987166375, - "min_ms": 44.05324999243021, - "max_ms": 45.141874987166375 - }, - "fresh": { - "samples_ms": [ - 50.79174999264069, - 51.26354200183414, - 47.867999994196, - 48.478584008989856, - 49.43908299901523, - 48.94704098114744, - 49.00087497662753, - 48.12095800298266, - 49.00637498940341 - ], - "median_ms": 49.00087497662753, - "p95_ms": 51.26354200183414, - "min_ms": 47.867999994196, - "max_ms": 51.26354200183414 - }, - "speedup": 1.104700442793317, - "build_break_even_queries": 30 - }, - { - "query": "absent_literal", - "pattern": "absent_benchmark_xyz987", - "regex": false, - "matches": 0, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 44.79016698314808, - 43.83229202358052, - 44.86550000729039, - 44.89724998711608, - 43.590999994194135, - 44.236082991119474, - 43.968166020931676, - 44.319958018604666, - 43.86754200095311 - ], - "median_ms": 44.236082991119474, - "p95_ms": 44.89724998711608, - "min_ms": 43.590999994194135, - "max_ms": 44.89724998711608 - }, - "fresh": { - "samples_ms": [ - 51.03270799736492, - 50.571750005474314, - 50.18066699267365, - 49.4932500005234, - 50.028375000692904, - 48.63591701723635, - 48.55274999863468, - 48.702333006076515, - 50.2279170032125 - ], - "median_ms": 50.028375000692904, - "p95_ms": 51.03270799736492, - "min_ms": 48.55274999863468, - "max_ms": 51.03270799736492 - }, - "speedup": 1.130940436356814, - "build_break_even_queries": 24 - }, - { - "query": "selective_regex", - "pattern": "unique_benchmark_[a-z]+", - "regex": true, - "matches": 1, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 44.6396250044927, - 43.618665979010984, - 44.53583399299532, - 44.004458002746105, - 44.15137498290278, - 44.25504201208241, - 43.58483399846591, - 43.54341700673103, - 44.02850000769831 - ], - "median_ms": 44.02850000769831, - "p95_ms": 44.6396250044927, - "min_ms": 43.54341700673103, - "max_ms": 44.6396250044927 - }, - "fresh": { - "samples_ms": [ - 49.173207982676104, - 50.38229198544286, - 48.61900000832975, - 48.50145798991434, - 49.86116700456478, - 50.28699999093078, - 50.19258300308138, - 50.690208008745685, - 48.744708008598536 - ], - "median_ms": 49.86116700456478, - "p95_ms": 50.690208008745685, - "min_ms": 48.50145798991434, - "max_ms": 50.690208008745685 - }, - "speedup": 1.1324748059971752, - "build_break_even_queries": 24 - }, - { - "query": "short_absent_literal", - "pattern": "ZQ", - "regex": false, - "matches": 0, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 44.28195900982246, - 45.14904198003933, - 44.985749991610646, - 44.815750006819144, - 44.532666011946276, - 47.29845799738541, - 45.33254200941883, - 45.52095799590461, - 44.8750409996137 - ], - "median_ms": 44.985749991610646, - "p95_ms": 47.29845799738541, - "min_ms": 44.28195900982246, - "max_ms": 47.29845799738541 - }, - "fresh": { - "samples_ms": [ - 49.157625006046146, - 49.074084003223106, - 50.866250006947666, - 49.54208401613869, - 50.290584011236206, - 48.762082995381206, - 50.253374996827915, - 50.214750022860244, - 50.08729099063203 - ], - "median_ms": 50.08729099063203, - "p95_ms": 50.866250006947666, - "min_ms": 48.762082995381206, - "max_ms": 50.866250006947666 - }, - "speedup": 1.113403488881985, - "build_break_even_queries": 27 - } - ] - }, - { - "files": 20000, - "bytes": 66311766, - "content_sha256": "24656adaa6852d218976d9e03a5ccad22e7873ee1898a44a505ae57f77a0711b", - "build_and_start_seconds": 0.6846851670125034, - "index_directory_bytes": 49737999, - "server_rss_kib_after_queries": 111536, - "queries": [ - { - "query": "rare_literal", - "pattern": "unique_benchmark_needle", - "regex": false, - "matches": 1, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 45.78887499519624, - 45.71445900364779, - 44.09775001113303, - 45.378749986412004, - 44.348958996124566, - 45.47354200622067, - 46.32125000352971, - 44.827875011833385, - 46.41525002080016 - ], - "median_ms": 45.47354200622067, - "p95_ms": 46.41525002080016, - "min_ms": 44.09775001113303, - "max_ms": 46.41525002080016 - }, - "fresh": { - "samples_ms": [ - 259.78191700414754, - 236.13649999606423, - 245.58208300732076, - 249.6871660114266, - 250.00729100429453, - 252.05895799444988, - 250.4555000050459, - 252.94183299411088, - 275.92891702079214 - ], - "median_ms": 250.4555000050459, - "p95_ms": 275.92891702079214, - "min_ms": 236.13649999606423, - "max_ms": 275.92891702079214 - }, - "speedup": 5.5077191913219385, - "build_break_even_queries": 4 - }, - { - "query": "distributed_literal", - "pattern": "batch_benchmark_marker", - "regex": false, - "matches": 200, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 53.4849590039812, - 52.302207972388715, - 53.51008297293447, - 53.13770900829695, - 53.515125007834285, - 53.90387499937788, - 53.103750018635765, - 53.02570800995454, - 53.203499992378056 - ], - "median_ms": 53.203499992378056, - "p95_ms": 53.90387499937788, - "min_ms": 52.302207972388715, - "max_ms": 53.90387499937788 - }, - "fresh": { - "samples_ms": [ - 250.48620800953358, - 262.7261670131702, - 263.6788750241976, - 247.8203329956159, - 277.5383749976754, - 240.2544590004254, - 249.16170799406245, - 270.4827080015093, - 271.8161670200061 - ], - "median_ms": 262.7261670131702, - "p95_ms": 277.5383749976754, - "min_ms": 240.2544590004254, - "max_ms": 277.5383749976754 - }, - "speedup": 4.938136909241091, - "build_break_even_queries": 4 - }, - { - "query": "absent_literal", - "pattern": "absent_benchmark_xyz987", - "regex": false, - "matches": 0, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 44.159957993542776, - 47.39529200014658, - 47.84866701811552, - 45.57337501319125, - 46.56354198232293, - 45.963167009176686, - 46.02929198881611, - 44.34749999199994, - 45.48666600021534 - ], - "median_ms": 45.963167009176686, - "p95_ms": 47.84866701811552, - "min_ms": 44.159957993542776, - "max_ms": 47.84866701811552 - }, - "fresh": { - "samples_ms": [ - 256.36966701131314, - 227.4162499816157, - 242.26879101479426, - 245.46620799810626, - 268.4037090220954, - 264.9954580119811, - 254.34612500248477, - 257.285250001587, - 253.62666699220426 - ], - "median_ms": 254.34612500248477, - "p95_ms": 268.4037090220954, - "min_ms": 227.4162499816157, - "max_ms": 268.4037090220954 - }, - "speedup": 5.533694511339999, - "build_break_even_queries": 4 - }, - { - "query": "selective_regex", - "pattern": "unique_benchmark_[a-z]+", - "regex": true, - "matches": 1, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 46.00400000344962, - 47.031791997142136, - 60.57037500431761, - 45.73762498330325, - 45.81824998604134, - 46.00641698925756, - 45.28516699792817, - 45.94125002040528, - 44.34270798810758 - ], - "median_ms": 45.94125002040528, - "p95_ms": 60.57037500431761, - "min_ms": 44.34270798810758, - "max_ms": 60.57037500431761 - }, - "fresh": { - "samples_ms": [ - 236.63554099039175, - 234.45345801883377, - 231.19745799340308, - 244.03829101356678, - 266.61883300403133, - 250.95816698740236, - 230.55508398101665, - 222.56295799161308, - 237.75845798081718 - ], - "median_ms": 236.63554099039175, - "p95_ms": 266.61883300403133, - "min_ms": 222.56295799161308, - "max_ms": 266.61883300403133 - }, - "speedup": 5.150829393742827, - "build_break_even_queries": 4 - }, - { - "query": "short_absent_literal", - "pattern": "ZQ", - "regex": false, - "matches": 0, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 62.238290993263945, - 61.93695799447596, - 61.6332910140045, - 60.99708398687653, - 62.79262498719618, - 87.44116697926074, - 67.36795799224637, - 67.13825001497753, - 67.49579100869596 - ], - "median_ms": 62.79262498719618, - "p95_ms": 87.44116697926074, - "min_ms": 60.99708398687653, - "max_ms": 87.44116697926074 - }, - "fresh": { - "samples_ms": [ - 223.0282919772435, - 235.64808300579898, - 229.89424999104813, - 256.0791669820901, - 249.86866599647328, - 239.1713339893613, - 246.4875830046367, - 237.04770900076255, - 247.23533299402334 - ], - "median_ms": 239.1713339893613, - "p95_ms": 256.0791669820901, - "min_ms": 223.0282919772435, - "max_ms": 256.0791669820901 - }, - "speedup": 3.8089080371800015, - "build_break_even_queries": 4 - } - ] - }, - { - "files": 100000, - "bytes": 338891766, - "content_sha256": "5fbbe8ac70e440c600f5d0e436221ac91b59c632142806fa781bd5723acaaed3", - "build_and_start_seconds": 4.24470595800085, - "index_directory_bytes": 249299483, - "server_rss_kib_after_queries": 516288, - "queries": [ - { - "query": "rare_literal", - "pattern": "unique_benchmark_needle", - "regex": false, - "matches": 1, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 52.986708004027605, - 52.21820899168961, - 47.65658298856579, - 51.93000001600012, - 48.175208998145536, - 51.420874980976805, - 52.188791014486924, - 49.957500013988465, - 52.81466600717977 - ], - "median_ms": 51.93000001600012, - "p95_ms": 52.986708004027605, - "min_ms": 47.65658298856579, - "max_ms": 52.986708004027605 - }, - "fresh": { - "samples_ms": [ - 1267.4464579904452, - 1250.1287919876631, - 1269.8567090264987, - 1244.8364160081837, - 1226.5618329984136, - 1262.7933750045486, - 1264.028707984835, - 1278.853291994892, - 1256.525833014166 - ], - "median_ms": 1262.7933750045486, - "p95_ms": 1278.853291994892, - "min_ms": 1226.5618329984136, - "max_ms": 1278.853291994892 - }, - "speedup": 24.31722269623474, - "build_break_even_queries": 4 - }, - { - "query": "distributed_literal", - "pattern": "batch_benchmark_marker", - "regex": false, - "matches": 1000, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 89.71654099877924, - 85.02966701053083, - 88.51554201100953, - 91.63329200237058, - 90.64820801722817, - 91.06499998597428, - 86.064000002807, - 89.87129098386504, - 89.1526660125237 - ], - "median_ms": 89.71654099877924, - "p95_ms": 91.63329200237058, - "min_ms": 85.02966701053083, - "max_ms": 91.63329200237058 - }, - "fresh": { - "samples_ms": [ - 1281.3319999841042, - 1284.5738749892917, - 1278.7110829958692, - 1278.9893750159536, - 1310.8776670123916, - 1328.5628340090625, - 1291.7908749950584, - 1282.07966600894, - 1302.044209005544 - ], - "median_ms": 1284.5738749892917, - "p95_ms": 1328.5628340090625, - "min_ms": 1278.7110829958692, - "max_ms": 1328.5628340090625 - }, - "speedup": 14.318138669732829, - "build_break_even_queries": 4 - }, - { - "query": "absent_literal", - "pattern": "absent_benchmark_xyz987", - "regex": false, - "matches": 0, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 47.1354580076877, - 51.449541992042214, - 54.270499997073784, - 51.953833986772224, - 51.542542001698166, - 53.11279100715183, - 51.19249998824671, - 46.72704200493172, - 52.20345899579115 - ], - "median_ms": 51.542542001698166, - "p95_ms": 54.270499997073784, - "min_ms": 46.72704200493172, - "max_ms": 54.270499997073784 - }, - "fresh": { - "samples_ms": [ - 1248.3060410013422, - 1253.853500005789, - 1248.6065829871222, - 1257.0107500068843, - 1265.3977500158362, - 1232.0264170120936, - 1260.5766249762382, - 1245.083749992773, - 1350.622707977891 - ], - "median_ms": 1253.853500005789, - "p95_ms": 1350.622707977891, - "min_ms": 1232.0264170120936, - "max_ms": 1350.622707977891 - }, - "speedup": 24.326574734410237, - "build_break_even_queries": 4 - }, - { - "query": "selective_regex", - "pattern": "unique_benchmark_[a-z]+", - "regex": true, - "matches": 1, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 51.999042014358565, - 52.205084008164704, - 50.98245799308643, - 50.01950002042577, - 52.03399999300018, - 52.279625000664964, - 46.93654100992717, - 51.81987499236129, - 47.0645840105135 - ], - "median_ms": 51.81987499236129, - "p95_ms": 52.279625000664964, - "min_ms": 46.93654100992717, - "max_ms": 52.279625000664964 - }, - "fresh": { - "samples_ms": [ - 1224.8768330027815, - 1263.8722909905482, - 1279.6648340008687, - 1251.7542080022395, - 1244.3842499924358, - 1273.0346659955103, - 1252.243124996312, - 1250.611333991401, - 1252.7479999989737 - ], - "median_ms": 1252.243124996312, - "p95_ms": 1279.6648340008687, - "min_ms": 1224.8768330027815, - "max_ms": 1279.6648340008687 - }, - "speedup": 24.165305786262582, - "build_break_even_queries": 4 - }, - { - "query": "short_absent_literal", - "pattern": "ZQ", - "regex": false, - "matches": 0, - "exact_match_parity": true, - "indexed": { - "samples_ms": [ - 3813.451374997385, - 3136.63400002406, - 3656.280249997508, - 3087.3178749752697, - 3637.896333995741, - 3078.818250010954, - 3662.4065420182887, - 3077.5334170029964, - 3688.4330000029877 - ], - "median_ms": 3637.896333995741, - "p95_ms": 3813.451374997385, - "min_ms": 3077.5334170029964, - "max_ms": 3813.451374997385 - }, - "fresh": { - "samples_ms": [ - 1278.5090419929475, - 1295.0507079949602, - 1287.7029590017628, - 1185.7254579954315, - 1262.0302079885732, - 1154.1498750157189, - 1374.8570830211975, - 1228.1200000143144, - 1250.1640419941396 - ], - "median_ms": 1262.0302079885732, - "p95_ms": 1374.8570830211975, - "min_ms": 1154.1498750157189, - "max_ms": 1374.8570830211975 - }, - "speedup": 0.346912086580104, - "build_break_even_queries": null - } - ] - } - ] -} diff --git a/scripts/benchmark.py b/scripts/benchmark.py deleted file mode 100644 index 5fc48a8..0000000 --- a/scripts/benchmark.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -"""Measure full CLI indexed/fresh latency on deterministic synthetic corpora.""" - -import argparse -import hashlib -import json -import math -import os -from pathlib import Path -import platform -import random -import shutil -import statistics -import subprocess -import sys -import tempfile -import time -from typing import Any - -SOURCE = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(SOURCE / "src")) -from local_search import config, engine # noqa: E402 - -QUERIES = [ - ("rare_literal", "unique_benchmark_needle", False), - ("distributed_literal", "batch_benchmark_marker", False), - ("absent_literal", "absent_benchmark_xyz987", False), - ("selective_regex", "unique_benchmark_[a-z]+", True), - ("short_absent_literal", "ZQ", False), -] - - -def run(command: list[str], env: dict[str, str] | None = None) -> str: - return subprocess.run( - command, capture_output=True, text=True, env=env, timeout=120, check=True - ).stdout.strip() - - -def corpus(path: Path, count: int) -> dict[str, Any]: - """Create roughly 4 KiB per file, with known sparse and distributed matches.""" - path.mkdir() - digest = hashlib.sha256() - total = 0 - for number in range(count): - folder = path / f"package_{number // 100:05d}" - folder.mkdir(exist_ok=True) - lines = [f"# synthetic module {number}\n"] - if number == count // 2: - lines.append("# unique_benchmark_needle\n") - if number % 100 == 0: - lines.append("# batch_benchmark_marker\n") - for item in range(64): - lines.append( - f"def function_{number}_{item}(value): " - f"return value + {number * 64 + item}\n" - ) - data = "".join(lines).encode() - (folder / f"module_{number:06d}.py").write_bytes(data) - digest.update(data) - total += len(data) - return {"files": count, "bytes": total, "content_sha256": digest.hexdigest()} - - -def measure( - env: dict[str, str], pattern: str, regex: bool, fresh: bool, -) -> tuple[float, list[tuple[str, int, str]]]: - command = [ - sys.executable, "-m", "local_search.cli", "search", pattern, - "--root", "benchmark", "--limit", "1000", - ] - if regex: - command.append("--regex") - if fresh: - command.append("--fresh") - started = time.perf_counter() - result = subprocess.run( - command, env=env, capture_output=True, text=True, timeout=120 - ) - elapsed = (time.perf_counter() - started) * 1000 - if result.returncode not in (0, 1): - raise RuntimeError(f"Search failed: {result.stderr} {result.stdout}") - response = json.loads(result.stdout) - if response.get("truncated"): - raise RuntimeError("Truncated output cannot establish exact match parity") - reports = response["reports"] - expected = "rg" if fresh else "tgrep" - for report in reports: - if report["backend"] != expected or report["warnings"]: - raise RuntimeError(f"Unexpected backend or warning: {report}") - matches = sorted( - (item["path"], item["line"], item["text"]) - for item in response["matches"] - ) - return elapsed, matches - - -def summarize(samples: list[float]) -> dict[str, Any]: - ordered = sorted(samples) - return { - "samples_ms": samples, - "median_ms": statistics.median(samples), - "p95_ms": ordered[math.ceil(len(ordered) * 0.95) - 1], - "min_ms": min(samples), - "max_ms": max(samples), - } - - -def benchmark( - count: int, rounds: int, warmups: int, binaries: dict[str, str], -) -> dict[str, Any]: - base = Path(tempfile.mkdtemp(prefix="local-search-benchmark-")) - safe_to_remove = True - try: - metadata = corpus(base / "corpus", count) - env = { - **os.environ, - "XDG_CONFIG_HOME": str(base / "config"), - "XDG_DATA_HOME": str(base / "data"), - "PYTHONPATH": str(SOURCE / "src"), - **binaries, - } - previous = os.environ.copy() - os.environ.update(env) - root = None - try: - root = config.add_root("benchmark", base / "corpus") - started = time.perf_counter() - server = engine.start(root, rebuild=True) - deadline = time.monotonic() + 120 - while ( - server.get("indexing", True) - or server.get("reconcile_running") - or server.get("reconcile_pending") - ): - if time.monotonic() > deadline: - raise RuntimeError("Index did not become ready") - time.sleep(0.1) - server = engine.rpc_status(config.index_dir(root)) or {} - build_seconds = time.perf_counter() - started - if server["num_files"] != count: - raise RuntimeError("Unexpected indexed file count") - results = [] - rng = random.Random(20260909) - for name, pattern, regex in QUERIES: - timings = {False: [], True: []} - reference = None - for iteration in range(warmups + rounds): - order = [False, True] - rng.shuffle(order) - for fresh in order: - elapsed, matches = measure(env, pattern, regex, fresh) - if reference is None: - reference = matches - if reference != matches: - raise RuntimeError(f"Match mismatch for {name}") - if iteration >= warmups: - timings[fresh].append(elapsed) - expected_count = ( - 1 if name in ("rare_literal", "selective_regex") - else (count + 99) // 100 if name == "distributed_literal" - else 0 - ) - if len(reference) != expected_count: - raise RuntimeError(f"Unexpected match count for {name}") - indexed = summarize(timings[False]) - fresh = summarize(timings[True]) - saving = (fresh["median_ms"] - indexed["median_ms"]) / 1000 - results.append({ - "query": name, "pattern": pattern, "regex": regex, - "matches": len(reference), "exact_match_parity": True, - "indexed": indexed, "fresh": fresh, - "speedup": fresh["median_ms"] / indexed["median_ms"], - "build_break_even_queries": ( - math.ceil(build_seconds / saving) if saving > 0 else None - ), - }) - print( - f"{count} files / {name}: " - f"{indexed['median_ms']:.1f} vs {fresh['median_ms']:.1f} ms", - file=sys.stderr, flush=True, - ) - directory = config.index_dir(root) - index_bytes = sum(p.stat().st_size for p in directory.rglob("*") - if p.is_file()) - rss_kib = int(run(["ps", "-p", str(server["pid"]), "-o", "rss="])) - return { - **metadata, "build_and_start_seconds": build_seconds, - "index_directory_bytes": index_bytes, - "server_rss_kib_after_queries": rss_kib, "queries": results, - } - finally: - try: - if root is not None: - safe_to_remove = False - try: - engine.stop(root) - except BaseException: - print(f"Stop failed; preserving diagnostics: {base}", - file=sys.stderr) - raise - safe_to_remove = True - finally: - os.environ.clear() - os.environ.update(previous) - finally: - if safe_to_remove: - shutil.rmtree(base) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--tgrep", required=True, type=Path) - parser.add_argument("--rg", required=True, type=Path) - parser.add_argument("--sizes", nargs="+", type=int, default=[1000, 20000]) - parser.add_argument("--rounds", type=int, default=9) - parser.add_argument("--warmups", type=int, default=2) - parser.add_argument("--output", required=True, type=Path) - args = parser.parse_args() - if (args.rounds < 3 or args.warmups < 1 - or any(n < 1 or n > 100000 for n in args.sizes)): - parser.error("Use rounds >= 3, warmups >= 1 and sizes in 1..100000") - binaries = { - "LOCAL_SEARCH_TGREP": str(args.tgrep.resolve(strict=True)), - "LOCAL_SEARCH_RG": str(args.rg.resolve(strict=True)), - } - report = { - "schema": 1, "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "platform": platform.platform(), "machine": platform.machine(), - "python": platform.python_version(), "logical_cpus": os.cpu_count(), - "cpu_model": run(["sysctl", "-n", "machdep.cpu.brand_string"]) - if sys.platform == "darwin" else platform.processor(), - "memory_bytes": int(run(["sysctl", "-n", "hw.memsize"])) - if sys.platform == "darwin" else None, - "source_commit": run(["git", "-C", str(SOURCE), "rev-parse", "HEAD"]), - "benchmark_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), - "tgrep_version": run([binaries["LOCAL_SEARCH_TGREP"], "--version"]), - "rg_version": run([binaries["LOCAL_SEARCH_RG"], "--version"]), - "rounds": args.rounds, "warmups": args.warmups, - "method": "Full CLI wall time; paired randomized order; warm OS caches; " - "synthetic Python; identical filters; exact uncapped match parity; " - "no LLM; RSS is a point sample, not a peak; index build is one run", - "corpora": [], - } - for count in args.sizes: - report["corpora"].append( - benchmark(count, args.rounds, args.warmups, binaries) - ) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(report, indent=2) + "\n") - - -if __name__ == "__main__": - main()