From 2baaed8cf2425f9cb937c1cb385a9d9997e45705 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste THERY Date: Thu, 9 Jul 2026 15:41:53 +0700 Subject: [PATCH 01/20] feat(ragmir): improve retrieval evidence fidelity Improve Ragmir retrieval evidence fidelity, MCP smoke coverage, and query sanitizer hardening. --- AGENTS.md | 5 + README.md | 41 +++- docs/agent-integration.md | 8 + docs/api-reference.md | 23 ++- docs/cli-reference.md | 16 +- packages/ragmir-core/README.md | 6 +- .../document-evidence-benchmark/.gitignore | 9 + .../.ragmir/config.json | 21 ++ .../document-evidence-benchmark/README.md | 33 +++ .../golden-queries.json | 35 ++++ .../contracts/master-services-agreement.md | 11 + .../raw/legal-tax/residency-review-note.md | 10 + .../raw/rfp/security-questionnaire.md | 12 ++ .../raw/runbooks/incident-response-runbook.md | 11 + .../raw/specs/agent-integration-spec.md | 11 + packages/ragmir-core/src/chunking.test.ts | 33 +++ packages/ragmir-core/src/chunking.ts | 60 +++++- packages/ragmir-core/src/cli-options.ts | 9 + packages/ragmir-core/src/cli.ts | 97 ++++++--- packages/ragmir-core/src/defaults.ts | 1 + packages/ragmir-core/src/evaluate.test.ts | 104 +++++++++- packages/ragmir-core/src/evaluate.ts | 58 +++++- .../ragmir-core/src/index-diagnostics.test.ts | 13 ++ packages/ragmir-core/src/index-diagnostics.ts | 12 +- packages/ragmir-core/src/index.ts | 1 + packages/ragmir-core/src/ingest.test.ts | 4 + packages/ragmir-core/src/ingest.ts | 8 + packages/ragmir-core/src/mcp.test.ts | 1 + packages/ragmir-core/src/mcp.ts | 28 ++- .../ragmir-core/src/query-sanitizer.test.ts | 35 ++++ packages/ragmir-core/src/query-sanitizer.ts | 194 ++++++++++++++++++ packages/ragmir-core/src/query.test.ts | 62 +++++- packages/ragmir-core/src/query.ts | 151 +++++++++++++- packages/ragmir-core/src/research.ts | 9 + packages/ragmir-core/src/store.test.ts | 9 +- packages/ragmir-core/src/store.ts | 44 +++- packages/ragmir-core/src/types.ts | 38 ++++ packages/ragmir-landing/messages/en.json | 14 +- packages/ragmir-landing/messages/fr.json | 14 +- scripts/smoke.mjs | 134 +++++++++++- 40 files changed, 1285 insertions(+), 100 deletions(-) create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/.gitignore create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/.ragmir/config.json create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/README.md create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/golden-queries.json create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/raw/contracts/master-services-agreement.md create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/raw/legal-tax/residency-review-note.md create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/raw/rfp/security-questionnaire.md create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/raw/runbooks/incident-response-runbook.md create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/raw/specs/agent-integration-spec.md create mode 100644 packages/ragmir-core/src/query-sanitizer.test.ts create mode 100644 packages/ragmir-core/src/query-sanitizer.ts diff --git a/AGENTS.md b/AGENTS.md index 016dcb5..b0aaadd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,11 @@ any other), because several agents may run against this repo in parallel. - **Never create, rename, delete, switch, or reset Git branches on your own.** Ask the user for explicit confirmation first, and state the exact branch name and base you intend to use. A high-level task is not blanket permission to spawn branches — confirm the branch itself. +- **Check the branch before editing.** Run `git status --short --branch` before code or docs changes. + If the current branch is `develop` or `main`, stop after reporting the status and ask for the exact + `feature/*`, `fix/*`, or `chore/*` branch name and base before editing. Uncommitted work already + present on a protected branch is not permission to keep working there; move it only after the user + confirms the branch operation. - **Always follow the repository Git Flow.** `main` is production and `develop` is integration; both are protected and only change through a pull request with green required checks (Quality gate, Commitlint, Analyze TypeScript). Start work from `develop` under `feature/*` (fixes `fix/*`, chores diff --git a/README.md b/README.md index ef34d55..d92300c 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,13 @@ OpenCode, Cline, or any MCP client answer from your real sources. Ragmir install repository, stores vectors locally with LanceDB, and runs fully offline by default, with built-in local-hash retrieval or optional Transformers.js semantic embeddings. -Ragmir Core returns cited retrieval context. Answer synthesis belongs to the AI agent, LLM, or local -model runtime you choose around it, so every answer stays grounded in your real evidence. +Ragmir Core returns cited retrieval context with line-aware citations after indexing. Answer +synthesis belongs to the AI agent, LLM, or local model runtime you choose around it, so every answer +stays grounded in your real evidence. + +Ragmir complements agent memory. It does not replace the conversation state, task plan, or native +code index of Claude, Codex, Kimi, OpenCode, Cline, or another agent. It gives those agents a local, +read-focused evidence layer for documents they should cite instead of guess from. Created by Jean-Baptiste Thery and published under the JCode Labs npm scope. @@ -497,12 +502,18 @@ Run an audit-backed multi-query research pass before a broad synthesis or implem npx rgr research "release readiness and risks" --compact ``` -Measure recall against a golden query file: +Measure recall@k against a golden query file: ```bash npx rgr evaluate --golden golden-queries.json ``` +Golden queries can require file-level hits with `expectedPaths` and exact citation hits with +`expectedCitations` in `relative/path:Lx-Ly#chunkIndex` format. Use exact citations when the benchmark +needs to prove that Ragmir retrieved the right passage, not only the right file. Older indexes without +line metadata fall back to `relative/path#chunkIndex` until they are rebuilt. Evaluation reports +hit-rate recall, MRR, and nDCG. + For private dogfooding, keep the real corpus and golden query file outside Git or under an ignored local path, then use a threshold that matches the evaluation phase: @@ -644,6 +655,14 @@ The MCP server exposes `ragmir_status`, `ragmir_route_prompt`, `ragmir_search`, Ragmir for prompt-routing advice, ranked passages, cited context, audit-backed research, local recall gates, or metadata-only usage summaries and uses the returned citations. +The read-focused MCP surface is intentional. Ragmir is the evidence layer next to the agent, not the +agent's memory, editor, or repository mutation engine. That keeps confidential retrieval useful +without handing a connected model broader write capabilities. + +`ragmir_search` and `ragmir_ask` accept `contextRadius` for bounded neighboring chunks. Long agent +prompts are sanitized before retrieval so system or developer instructions are not embedded as the +search query. + `rgr route-prompt "..." --json` is the local opt-in prompt router for agent hooks. It does not store prompt text or call an LLM; it returns an explainable decision such as `shouldUseRagmir`, `confidence`, `tool`, `query`, and matched routing signals. @@ -731,7 +750,7 @@ Ragmir is designed for private repositories and sensitive local evidence. - Metadata-only access logs: query hashes and action metadata are logged, not raw queries. - Metadata-only usage reports: `rgr usage-report --days 7` summarizes recent local activity without exposing query text or local paths. -- MCP is read-focused and bounded by `mcpMaxTopK`. +- MCP is read-focused, non-destructive, and bounded by `mcpMaxTopK`. - Generated local state is ignored by Git. Run: @@ -861,6 +880,20 @@ node ../../dist/cli.js evaluate --golden golden-queries.json --fail-under 1 node ../../dist/cli.js audit ``` +### Document evidence benchmark (`document-evidence-benchmark`) + +[`document-evidence-benchmark`](./packages/ragmir-core/examples/document-evidence-benchmark) is a +synthetic benchmark for contracts, RFP answers, runbooks, specs, and legal/tax notes. It checks both +`recall@k` and exact `path:Lx-Ly#chunkIndex` citations. + +```bash +pnpm build +cd packages/ragmir-core/examples/document-evidence-benchmark +node ../../dist/cli.js ingest +node ../../dist/cli.js evaluate --golden golden-queries.json --json +node ../../dist/cli.js evaluate --golden golden-queries.json --fail-under 1 +``` + ### Library API demo (`library-api-demo`) [`library-api-demo`](./packages/ragmir-core/examples/library-api-demo) exercises the **library** API diff --git a/docs/agent-integration.md b/docs/agent-integration.md index fec392d..a87944a 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -100,6 +100,14 @@ knowledge base. The LLM does not need to know about LanceDB or the raw file layo prompt-routing advice, ranked passages, cited context, audit-backed research reports, local recall gates, or metadata-only usage summaries and uses the returned citations. +Ragmir complements agent memory instead of replacing it. The agent keeps its conversation state, +task plan, and native code index; Ragmir provides a read-focused local evidence layer for documents +that need citations. Keeping MCP read-focused is a security boundary, not a missing write feature. + +`ragmir_search` and `ragmir_ask` accept `contextRadius` when an agent needs neighboring chunks around +a cited hit. Ragmir also sanitizes unusually long agent prompts before retrieval, so system, +developer, or task-planning text does not become the embedded search query. + ## Prompt Routing `rgr route-prompt` and MCP `ragmir_route_prompt` are the opt-in bridge for agents that support diff --git a/docs/api-reference.md b/docs/api-reference.md index bc60a9f..60d02be 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -156,7 +156,8 @@ they are not treated as missing while their checksum remains unchanged. ### `search(query, options?)` -Returns ranked cited passages. Ragmir combines vector candidates with bounded lexical scoring. +Returns ranked cited passages. Ragmir combines vector candidates with full-text lexical candidates +when the index is available, then falls back to bounded lexical scoring for older indexes. ```ts import { search } from "@jcode.labs/ragmir" @@ -164,6 +165,7 @@ import { search } from "@jcode.labs/ragmir" const passages = await search("Who approved offline operation?", { cwd: "/path/to/workspace", topK: 8, + contextRadius: 1, }) ``` @@ -174,8 +176,11 @@ Each `SearchResult` includes: | `relativePath` | Source path relative to the Ragmir project root. | | `source` | Source category used by discovery. | | `chunkIndex` | Chunk number inside that source file. | +| `citation` | Stable citation including line span when available, for example `docs/policy.md:L4-L8#2`. | | `text` | Retrieved redacted chunk text. | | `distance` | Vector distance when available; `null` for lexical-only rows. | +| `lineStart` / `lineEnd` | 1-based line span for the matched chunk, or `null` for legacy indexes. | +| `context` | Neighboring chunks when `contextRadius` is set. The matched chunk remains the cited result. | Use `compactSearchResults(passages)` when an agent or MCP client needs short snippets instead of full retrieved chunks. @@ -219,11 +224,13 @@ import { ask } from "@jcode.labs/ragmir" const answer = await ask("What evidence supports the project timeline?", { cwd: "/path/to/workspace", + contextRadius: 1, }) ``` `AskResult.answer` is not an LLM synthesis. It is a deterministic retrieval-only text block that -lists cited passages. +lists cited passages. `contextRadius` adds adjacent chunks around each matched passage without +changing the source citation. ### `research(query, options?)` @@ -420,16 +427,20 @@ MCP tools exposed by the server: | --- | --- | | `ragmir_status` | `{}` | | `ragmir_route_prompt` | `{ prompt: string }` | -| `ragmir_search` | `{ query: string, topK?: number, compact?: boolean }` | -| `ragmir_ask` | `{ query: string, topK?: number }` | +| `ragmir_search` | `{ query: string, topK?: number, contextRadius?: number, compact?: boolean }` | +| `ragmir_ask` | `{ query: string, topK?: number, contextRadius?: number }` | | `ragmir_research` | `{ query: string, topK?: number, includeCode?: boolean, compact?: boolean }` | | `ragmir_audit` | `{}` | | `ragmir_evaluate` | `{ goldenPath: string, topK?: number, failUnder?: number }` | | `ragmir_usage_report` | `{ days?: number }` | | `ragmir_security_audit` | `{}` | -`topK` is bounded by `mcpMaxTopK` from config. `ragmir_evaluate` also requires `goldenPath` to stay -inside the MCP project root. +`topK` is bounded by `mcpMaxTopK` from config, and `contextRadius` is capped at 3 chunks on each +side. `ragmir_evaluate` also requires `goldenPath` to stay inside the MCP project root. Evaluation +golden files support `expectedPaths` for file-level recall and `expectedCitations` for exact +`relative/path:Lx-Ly#chunkIndex` checks. Older indexes without line metadata fall back to +`relative/path#chunkIndex` until they are rebuilt. Evaluation output includes hit-rate recall, MRR, +and nDCG. ## Package Manager Helpers diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6b9a04a..0bca6cf 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -30,7 +30,7 @@ Ragmir ships three CLIs: | `rgr chat doctor --json` | Inspect optional local chat readiness without generating an answer. | | `rgr research ""` | Run audit, security, multi-query retrieval, source diagnostics, and lightweight code matching for broad agent tasks. | | `rgr route-prompt "..."` | Classify a prompt and suggest whether an agent should use Ragmir local context. | -| `rgr evaluate --golden golden-queries.json` | Measure retrieval recall against expected source paths. | +| `rgr evaluate --golden golden-queries.json` | Measure retrieval recall against expected source paths or exact citations. | | `rgr security-audit` | Inspect privacy posture: telemetry, providers, redaction, Git ignore, MCP. | | `rgr usage-report` | Summarize metadata-only local access-log activity for recent private dogfooding without query text or local paths. | | `rgr status` | Print raw config paths, provider settings, and indexed chunk count. | @@ -75,6 +75,7 @@ Ragmir ships three CLIs: | `--mcp-arg ` | `setup`, `install-skill` | Add one argument to `--mcp-command`; repeat for multiple arguments. Use `--mcp-arg=--flag` for dash-prefixed values. | | `--semantic` | `setup` | Explicitly download the configured Transformers.js embedding model once, enable `embeddingProvider: "transformers"`, and keep remote model loading disabled for normal indexing. | | `--top-k ` | `search`, `ask`, `chat`, `research`, `evaluate` | Number of passages to return or keep. | +| `--context-radius ` | `search`, `ask` | Include neighboring chunks around each matched passage. MCP clamps this to 3 chunks on each side. | | `--fail-under ` | `evaluate` | Exit non-zero only when recall is below a threshold from `0` to `1`; without this option evaluation remains strict and fails on any miss. | | `--days ` | `usage-report` | Number of recent days to include in the metadata-only usage summary. | | `--json` | `setup`, `doctor`, `ingest`, `search`, `ask`, `chat`, `research`, `route-prompt`, `evaluate`, `audit`, `usage-report`, `status`, `security-audit`, `audio --doctor`, `rgr-chat doctor`, `rgr-tts doctor` | Print machine-readable JSON. | @@ -157,9 +158,18 @@ index, or run retrieval. It returns `shouldUseRagmir`, `confidence`, the suggest only when Ragmir should be used, matched routing signals, and privacy safeguards. Agents can then call `ragmir_search`, `ragmir_ask`, or `ragmir_research` over MCP. +`rgr search` and `rgr ask` sanitize unusually long agent prompts before embedding them, so accidental +system/developer context is not treated as the retrieval query. Search results include line-aware +citations such as `docs/policy.md:L4-L8#2` after a schema v2 rebuild. + ## Retrieval Evaluation Gates `rgr evaluate` expects a JSON golden query file with queries and expected relative source paths. +Use `expectedPaths` for file-level `recall@k`, and add `expectedCitations` when the benchmark must +verify exact `relative/path:Lx-Ly#chunkIndex` citations. Older indexes without line metadata fall back +to `relative/path#chunkIndex` until they are rebuilt. When citations are present, a query only counts +as a hit if the expected citation is retrieved. + Use the default strict behavior for synthetic examples and release checks: ```bash @@ -174,6 +184,8 @@ rgr --project-root /path/to/workspace evaluate --golden .ragmir/evaluations/gold ``` The JSON output includes `embeddingProvider` and `embeddingModel`. Use those fields when comparing a -default local-hash run with a private Transformers semantic run. +default local-hash run with a private Transformers semantic run. It also includes +`meanReciprocalRank` and `ndcg` so benchmark regressions can distinguish late-but-present hits from +top-ranked evidence. Fresh setup and docs use a single `.ragmir/` project folder. diff --git a/packages/ragmir-core/README.md b/packages/ragmir-core/README.md index e05b544..e88d73a 100644 --- a/packages/ragmir-core/README.md +++ b/packages/ragmir-core/README.md @@ -22,7 +22,8 @@ same evidence through: workflows. Ragmir does not send documents to a hosted RAG service and does not generate final LLM answers in -core. It returns cited retrieval context so the agent or model you trust can write from local +core. It complements agent memory with a read-focused local evidence layer, then returns cited +retrieval context with line-aware citations so the agent or model you trust can write from local evidence. ## Use It For @@ -76,7 +77,8 @@ text. `rgr setup` adds the matching Git ignore entry for local Ragmir state. The primary workflow is agent-first: Claude Code, Codex, Kimi, OpenCode, Cline, or another MCP-capable assistant asks Ragmir for cited local context, then writes or reasons from those citations. For terminal checks, use `npx rgr search "your question"` or -`npx rgr ask "your question"`. For broader implementation or review work, use +`npx rgr ask "your question"`. Add `--context-radius 1` when a matched passage needs bounded +neighboring chunks. For broader implementation or review work, use `npx rgr research "your topic" --compact` before asking the agent to synthesize. Agent hooks can call `npx rgr route-prompt "..." --json` first to decide whether a prompt needs Ragmir local context without storing prompt text or calling an LLM. diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/.gitignore b/packages/ragmir-core/examples/document-evidence-benchmark/.gitignore new file mode 100644 index 0000000..2e688bf --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/.gitignore @@ -0,0 +1,9 @@ +.ragmir/ +!.ragmir/ +!.ragmir/config.json +.ragmir/storage/ +.ragmir/access.log +.ragmir/models/ +.ragmir/reports/ +.ragmir/audio/ +node_modules/ diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/.ragmir/config.json b/packages/ragmir-core/examples/document-evidence-benchmark/.ragmir/config.json new file mode 100644 index 0000000..4bd42aa --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/.ragmir/config.json @@ -0,0 +1,21 @@ +{ + "rawDir": "raw", + "storageDir": ".ragmir/storage", + "sourcesFile": ".ragmir/sources.txt", + "accessLogPath": ".ragmir/access.log", + "embeddingModelPath": ".ragmir/models", + "tableName": "chunks", + "embeddingProvider": "local-hash", + "embeddingModel": "mixedbread-ai/mxbai-embed-xsmall-v1", + "transformersAllowRemoteModels": false, + "redaction": { + "enabled": true, + "builtIn": true, + "patterns": [] + }, + "accessLog": true, + "mcpMaxTopK": 10, + "topK": 5, + "chunkSize": 1800, + "chunkOverlap": 120 +} diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/README.md b/packages/ragmir-core/examples/document-evidence-benchmark/README.md new file mode 100644 index 0000000..823e18b --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/README.md @@ -0,0 +1,33 @@ +# Document Evidence Benchmark + +Synthetic document benchmark for Ragmir retrieval. It is intentionally safe to commit: the contract, +RFP, runbook, spec, and legal/tax notes are fictional and contain no customer data. + +This benchmark checks two things: + +- `recall@k`: each query retrieves the expected source file. +- exact citations: each query retrieves the expected `relative/path:Lx-Ly#chunkIndex` citation. + +The corpus is small on purpose so every source file fits in a single chunk. That keeps the expected +citations deterministic while exercising the same CLI path used for private dogfooding. + +## Run From This Repository Checkout + +Build Ragmir once from the repository root: + +```bash +pnpm build +``` + +Then run the benchmark from this folder: + +```bash +cd packages/ragmir-core/examples/document-evidence-benchmark +node ../../dist/cli.js ingest +node ../../dist/cli.js evaluate --golden golden-queries.json --json +node ../../dist/cli.js evaluate --golden golden-queries.json --fail-under 1 +``` + +Use this example as a public-safe template for private document evaluations. Keep real contracts, +RFPs, runbooks, tax notes, golden queries, and generated JSON reports outside Git or under ignored +local Ragmir state. diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/golden-queries.json b/packages/ragmir-core/examples/document-evidence-benchmark/golden-queries.json new file mode 100644 index 0000000..b4fe79a --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/golden-queries.json @@ -0,0 +1,35 @@ +{ + "topK": 3, + "queries": [ + { + "id": "contract-data-export-residency", + "query": "MSA client data export residency renewal notice DOCBENCH-MSA-001", + "expectedPaths": ["raw/contracts/master-services-agreement.md"], + "expectedCitations": ["raw/contracts/master-services-agreement.md:L1-L11#0"] + }, + { + "id": "rfp-no-hosted-account", + "query": "security questionnaire no hosted account no telemetry read focused MCP", + "expectedPaths": ["raw/rfp/security-questionnaire.md"], + "expectedCitations": ["raw/rfp/security-questionnaire.md:L1-L12#0"] + }, + { + "id": "runbook-evidence-pack", + "query": "What runbook evidence must be collected after a payment incident?", + "expectedPaths": ["raw/runbooks/incident-response-runbook.md"], + "expectedCitations": ["raw/runbooks/incident-response-runbook.md:L1-L11#0"] + }, + { + "id": "spec-complements-agent-memory", + "query": "agent integration spec complements agent memory conversation state", + "expectedPaths": ["raw/specs/agent-integration-spec.md"], + "expectedCitations": ["raw/specs/agent-integration-spec.md:L1-L11#0"] + }, + { + "id": "legal-tax-professional-review", + "query": "legal tax residency review professional review filing positions", + "expectedPaths": ["raw/legal-tax/residency-review-note.md"], + "expectedCitations": ["raw/legal-tax/residency-review-note.md:L1-L10#0"] + } + ] +} diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/raw/contracts/master-services-agreement.md b/packages/ragmir-core/examples/document-evidence-benchmark/raw/contracts/master-services-agreement.md new file mode 100644 index 0000000..4be31f1 --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/raw/contracts/master-services-agreement.md @@ -0,0 +1,11 @@ +# Synthetic Master Services Agreement + +Agreement ID: DOCBENCH-MSA-001. This fictional contract is for retrieval testing only. + +The supplier may process implementation notes, support tickets, and project specifications only from +the customer-designated workstation. Client data exports must remain in the agreed local evidence +folder and may not be copied to a hosted account without written approval from the customer owner. + +Renewal notice window: either party may stop automatic renewal by sending written notice at least +60 days before the renewal date. The contract owner must attach the local export residency evidence +to the renewal review. diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/raw/legal-tax/residency-review-note.md b/packages/ragmir-core/examples/document-evidence-benchmark/raw/legal-tax/residency-review-note.md new file mode 100644 index 0000000..05f74b2 --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/raw/legal-tax/residency-review-note.md @@ -0,0 +1,10 @@ +# Synthetic Legal And Tax Review Note + +Matter: fictional cross-border founder residency review for benchmark testing. + +The note compares board minutes, travel calendars, service contracts, and tax residency assumptions. +It is not legal advice. Professional review is required before relying on residency analysis, +changing filing positions, or sending a client-facing recommendation. + +The cited evidence package must separate facts, assumptions, and uncertainty. Missing documents +should be listed explicitly instead of filled with model guesses. diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/raw/rfp/security-questionnaire.md b/packages/ragmir-core/examples/document-evidence-benchmark/raw/rfp/security-questionnaire.md new file mode 100644 index 0000000..969aa90 --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/raw/rfp/security-questionnaire.md @@ -0,0 +1,12 @@ +# Synthetic RFP Security Questionnaire + +Question: Does the proposed retrieval layer require a hosted account, product telemetry, or broad +write access to the repository? + +Answer: No hosted account is required. The default mode runs locally with no telemetry. MCP access is +read-focused: the agent can ask for status, prompt-routing advice, search results, cited answers, +audits, evaluation results, usage summaries, and security posture, but it does not receive a +destructive repository tool from Ragmir. + +Follow-up: customer-specific evidence stays on disk and is reviewed by a human before an RFP response +is sent. diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/raw/runbooks/incident-response-runbook.md b/packages/ragmir-core/examples/document-evidence-benchmark/raw/runbooks/incident-response-runbook.md new file mode 100644 index 0000000..af82e4d --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/raw/runbooks/incident-response-runbook.md @@ -0,0 +1,11 @@ +# Synthetic Payment Incident Runbook + +Trigger: payment queue latency above the internal threshold for ten minutes. + +The on-call engineer opens the local incident template and collects a payment incident evidence pack: +timeline, customer impact summary, rollback decision, command transcript, and links to cited runbook +sections. The evidence pack remains under the local Ragmir reports folder until the incident lead +approves external sharing. + +Do not paste raw customer records into an agent prompt. Use cited retrieval snippets and summarize +uncertainty separately from confirmed evidence. diff --git a/packages/ragmir-core/examples/document-evidence-benchmark/raw/specs/agent-integration-spec.md b/packages/ragmir-core/examples/document-evidence-benchmark/raw/specs/agent-integration-spec.md new file mode 100644 index 0000000..5470e7f --- /dev/null +++ b/packages/ragmir-core/examples/document-evidence-benchmark/raw/specs/agent-integration-spec.md @@ -0,0 +1,11 @@ +# Synthetic Agent Integration Spec + +Goal: let external coding agents use local project evidence without turning Ragmir into another chat +memory system. + +Ragmir complements agent memory. It does not replace the agent conversation state, task plan, or +native code index. The agent keeps its normal reasoning loop, then calls Ragmir when it needs cited +evidence from local documents such as contracts, RFPs, runbooks, or specs. + +The MCP surface stays read-focused and bounded by topK. This is a security advantage: agents receive +verifiable passages and audit data without gaining a broad document mutation channel. diff --git a/packages/ragmir-core/src/chunking.test.ts b/packages/ragmir-core/src/chunking.test.ts index d6f6cb9..2b51f8a 100644 --- a/packages/ragmir-core/src/chunking.test.ts +++ b/packages/ragmir-core/src/chunking.test.ts @@ -23,4 +23,37 @@ describe("chunkDocument", () => { expect(chunks[0]?.relativePath).toBe(".ragmir/raw/example.md") expect(chunks.every((chunk) => chunk.text.length > 0)).toBe(true) }) + + it("records character and line spans for each chunk", () => { + const doc: ParsedDocument = { + file: { + absolutePath: "/tmp/example.md", + relativePath: ".ragmir/raw/example.md", + source: "example.md", + extension: ".md", + bytes: 100, + mtimeMs: 1, + checksum: "abc", + }, + text: "Alpha line\n\nBeta line with evidence\nGamma line continues\n", + } + + const chunks = chunkDocument(doc, 18, 0) + + expect(chunks[0]).toEqual( + expect.objectContaining({ + text: "Alpha line", + charStart: 0, + charEnd: 10, + lineStart: 1, + lineEnd: 1, + }), + ) + expect(chunks[1]).toEqual( + expect.objectContaining({ + lineStart: 3, + lineEnd: 3, + }), + ) + }) }) diff --git a/packages/ragmir-core/src/chunking.ts b/packages/ragmir-core/src/chunking.ts index 54babb5..5c63bb2 100644 --- a/packages/ragmir-core/src/chunking.ts +++ b/packages/ragmir-core/src/chunking.ts @@ -15,23 +15,28 @@ export function chunkDocument( } const chunks: TextChunk[] = [] + const lineStarts = lineStartOffsets(document.text) let cursor = 0 let chunkIndex = 0 while (cursor < document.text.length) { const end = chooseChunkEnd(document.text, cursor, chunkSize) - const text = document.text.slice(cursor, end).trim() + const span = trimmedSpan(document.text, cursor, end) - if (text) { + if (span.text) { const id = createHash("sha256") - .update(`${document.file.relativePath}:${chunkIndex}:${text}`) + .update(`${document.file.relativePath}:${chunkIndex}:${span.text}`) .digest("hex") chunks.push({ id, source: document.file.source, relativePath: document.file.relativePath, chunkIndex, - text, + text: span.text, + charStart: span.start, + charEnd: span.end, + lineStart: lineNumberForOffset(lineStarts, span.start), + lineEnd: lineNumberForOffset(lineStarts, Math.max(span.start, span.end - 1)), checksum: document.file.checksum, bytes: document.file.bytes, mtimeMs: document.file.mtimeMs, @@ -76,3 +81,50 @@ function chooseChunkEnd(text: string, cursor: number, chunkSize: number): number return hardEnd } + +interface TextSpan { + start: number + end: number + text: string +} + +function trimmedSpan(text: string, start: number, end: number): TextSpan { + let trimmedStart = start + let trimmedEnd = end + while (trimmedStart < trimmedEnd && /\s/u.test(text[trimmedStart] ?? "")) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && /\s/u.test(text[trimmedEnd - 1] ?? "")) { + trimmedEnd -= 1 + } + return { + start: trimmedStart, + end: trimmedEnd, + text: text.slice(trimmedStart, trimmedEnd), + } +} + +function lineStartOffsets(text: string): number[] { + const starts = [0] + for (let index = 0; index < text.length; index += 1) { + if (text[index] === "\n" && index + 1 < text.length) { + starts.push(index + 1) + } + } + return starts +} + +function lineNumberForOffset(lineStarts: number[], offset: number): number { + let low = 0 + let high = lineStarts.length - 1 + while (low <= high) { + const mid = Math.floor((low + high) / 2) + const lineStart = lineStarts[mid] ?? 0 + if (lineStart <= offset) { + low = mid + 1 + } else { + high = mid - 1 + } + } + return Math.max(1, high + 1) +} diff --git a/packages/ragmir-core/src/cli-options.ts b/packages/ragmir-core/src/cli-options.ts index 5baeb24..c6fc923 100644 --- a/packages/ragmir-core/src/cli-options.ts +++ b/packages/ragmir-core/src/cli-options.ts @@ -31,6 +31,15 @@ export function parsePositiveInt(value: string): number { return parsed } +/** Parse and validate a non-negative integer CLI argument. */ +export function parseNonNegativeInt(value: string): number { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error("Expected a non-negative integer.") + } + return parsed +} + /** Parse and validate a finite number CLI argument. */ export function parseNumber(value: string): number { const parsed = Number.parseFloat(value) diff --git a/packages/ragmir-core/src/cli.ts b/packages/ragmir-core/src/cli.ts index b1b602d..13be481 100644 --- a/packages/ragmir-core/src/cli.ts +++ b/packages/ragmir-core/src/cli.ts @@ -11,6 +11,7 @@ import { audioLanguage, parseAgentInstallMode, parseAgentInstallScope, + parseNonNegativeInt, parseNumber, parsePositiveInt, parseRecallThreshold, @@ -297,6 +298,9 @@ program if (result.vectorIndexWarning) { console.log(pc.yellow(result.vectorIndexWarning)) } + if (result.lexicalIndexWarning) { + console.log(pc.yellow(result.lexicalIndexWarning)) + } if (result.unsupportedFiles > 0 || result.oversizedFiles > 0 || result.sensitiveFiles > 0) { const auditCommand = await rgrCommand(cwd, ["audit", "--unsupported"]) console.log( @@ -316,16 +320,21 @@ program .description("Retrieve the most relevant passages without calling an LLM.") .argument("", "Search query.") .option("-k, --top-k ", "Number of passages to return.", parsePositiveInt) + .option( + "--context-radius ", + "Include neighboring chunks around each matched passage.", + parseNonNegativeInt, + ) .option("--compact", "Return short snippets instead of full passages.") .option("--json", "Print machine-readable JSON.") .action( async ( query: string, - options: { topK?: number; compact?: boolean; json?: boolean }, + options: { topK?: number; contextRadius?: number; compact?: boolean; json?: boolean }, command: Command, ) => { const cwd = projectRoot(command) - const results = await search(query, withTopK(cwd, options.topK)) + const results = await search(query, withSearchOptions(cwd, options)) const outputResults = options.compact ? compactSearchResults(results) : results if (options.json) { console.log(JSON.stringify({ query, results: outputResults }, null, 2)) @@ -347,7 +356,7 @@ program for (const [index, result] of outputResults.entries()) { const distance = result.distance === null ? "n/a" : result.distance.toFixed(4) console.log( - `\n${pc.cyan(`[${index + 1}] ${result.relativePath}`)} chunk=${result.chunkIndex} distance=${distance}`, + `\n${pc.cyan(`[${index + 1}] ${result.citation}`)} chunk=${result.chunkIndex} distance=${distance}`, ) console.log( "snippet" in result ? result.snippet : result.text.slice(0, SEARCH_TEXT_PREVIEW_LENGTH), @@ -361,29 +370,40 @@ program .description("Return cited retrieval context for a question without calling an LLM.") .argument("", "Question to answer.") .option("-k, --top-k ", "Number of passages to use.", parsePositiveInt) + .option( + "--context-radius ", + "Include neighboring chunks around each matched passage.", + parseNonNegativeInt, + ) .option("--json", "Print machine-readable JSON.") - .action(async (query: string, options: { topK?: number; json?: boolean }, command: Command) => { - const cwd = projectRoot(command) - const result = await ask(query, withTopK(cwd, options.topK)) - if (options.json) { - console.log(JSON.stringify({ query, ...result }, null, 2)) - if (result.sources.length === 0) { - process.exitCode = 1 + .action( + async ( + query: string, + options: { topK?: number; contextRadius?: number; json?: boolean }, + command: Command, + ) => { + const cwd = projectRoot(command) + const result = await ask(query, withSearchOptions(cwd, options)) + if (options.json) { + console.log(JSON.stringify({ query, ...result }, null, 2)) + if (result.sources.length === 0) { + process.exitCode = 1 + } + return } - return - } - console.log(`\n${result.answer}\n`) - if (result.staleWarning) { - console.error(pc.yellow(result.staleWarning)) - } - if (result.sources.length > 0) { - console.log(pc.dim("Sources:")) - for (const [index, source] of result.sources.entries()) { - console.log(` [${index + 1}] ${source.relativePath} chunk=${source.chunkIndex}`) + console.log(`\n${result.answer}\n`) + if (result.staleWarning) { + console.error(pc.yellow(result.staleWarning)) } - } - }) + if (result.sources.length > 0) { + console.log(pc.dim("Sources:")) + for (const [index, source] of result.sources.entries()) { + console.log(` [${index + 1}] ${source.citation} chunk=${source.chunkIndex}`) + } + } + }, + ) program .command("research") @@ -454,7 +474,10 @@ program program .command("evaluate") .description("Measure retrieval recall against a JSON golden query file.") - .requiredOption("--golden ", "JSON file with queries and expected relative source paths.") + .requiredOption( + "--golden ", + "JSON file with queries and expected paths or exact path:Lx-Ly#chunk citations.", + ) .option( "-k, --top-k ", "Default number of passages to evaluate per query.", @@ -495,16 +518,26 @@ program ? "" : ` minimumRecall=${minimumRecall.toFixed(3)} passed=${passed}` console.log( - `golden=${result.goldenPath} total=${result.total} hits=${result.hits} misses=${result.misses} recall=${result.recall.toFixed(3)}${thresholdSummary}`, + `golden=${result.goldenPath} total=${result.total} hits=${result.hits} misses=${result.misses} recall=${result.recall.toFixed(3)} mrr=${result.meanReciprocalRank.toFixed(3)} ndcg=${result.ndcg.toFixed(3)}${thresholdSummary}`, ) for (const testCase of result.cases) { const label = testCase.id ? `${testCase.id}: ${testCase.query}` : testCase.query const status = testCase.hit ? pc.green("hit") : pc.red("miss") const rank = testCase.bestRank === null ? "n/a" : String(testCase.bestRank) - console.log(`${status} rank=${rank} topK=${testCase.topK} ${label}`) + console.log( + `${status} rank=${rank} rr=${testCase.reciprocalRank.toFixed(3)} ndcg=${testCase.ndcg.toFixed(3)} topK=${testCase.topK} ${label}`, + ) if (!testCase.hit) { - console.log(` expected=${testCase.expectedPaths.join(",")}`) - console.log(` returned=${testCase.returnedPaths.join(",")}`) + const expected = + testCase.expectedCitations === undefined + ? testCase.expectedPaths + : testCase.expectedCitations + const returned = + testCase.expectedCitations === undefined + ? testCase.returnedPaths + : testCase.returnedCitations + console.log(` expected=${expected.join(",")}`) + console.log(` returned=${returned.join(",")}`) } } if (!passed) { @@ -1044,6 +1077,16 @@ function withTopK(cwd: string, topK: number | undefined): { cwd: string; topK?: return topK === undefined ? { cwd } : { cwd, topK } } +function withSearchOptions( + cwd: string, + options: { topK?: number; contextRadius?: number }, +): { cwd: string; topK?: number; contextRadius?: number } { + const result: { cwd: string; topK?: number; contextRadius?: number } = { cwd } + addOption(result, "topK", options.topK) + addOption(result, "contextRadius", options.contextRadius) + return result +} + interface AudioOptions { out?: string engine?: string diff --git a/packages/ragmir-core/src/defaults.ts b/packages/ragmir-core/src/defaults.ts index c01c8b5..94153e9 100644 --- a/packages/ragmir-core/src/defaults.ts +++ b/packages/ragmir-core/src/defaults.ts @@ -9,6 +9,7 @@ export const LEGACY_CONFIG_PATH = `${LEGACY_KB_DIR}/config.json` export const DEFAULT_SKILL_TARGET_DIR = `${RAGMIR_DIR}/skills` export const RAGMIR_PROJECT_ROOT_ENV = "RAGMIR_PROJECT_ROOT" +export const VECTOR_DISTANCE_METRIC = "l2" export const RAGMIR_GITIGNORE_ENTRY = `${RAGMIR_DIR}/` export const LEGACY_KB_GITIGNORE_ENTRY = `${LEGACY_KB_DIR}/` diff --git a/packages/ragmir-core/src/evaluate.test.ts b/packages/ragmir-core/src/evaluate.test.ts index df3ed80..cef783a 100644 --- a/packages/ragmir-core/src/evaluate.test.ts +++ b/packages/ragmir-core/src/evaluate.test.ts @@ -20,9 +20,7 @@ describe("evaluateGoldenQueries", () => { const parent = await mkdtemp(path.join(os.tmpdir(), "ragmir-evaluate-")) tempDirs.push(parent) const root = path.join(parent, "example") - await cp(path.join(packageRoot, "examples", "sovereign-rag-demo"), root, { - recursive: true, - }) + await copySovereignDemo(root) await ingest({ cwd: root }) const report = await evaluateGoldenQueries({ @@ -34,16 +32,18 @@ describe("evaluateGoldenQueries", () => { expect(report.embeddingProvider).toBe("local-hash") expect(report.misses).toBe(0) expect(report.recall).toBe(1) + expect(report.meanReciprocalRank).toBeGreaterThan(0) + expect(report.ndcg).toBeGreaterThan(0) expect(report.cases.every((result) => result.hit)).toBe(true) + expect(report.cases.every((result) => result.reciprocalRank > 0)).toBe(true) + expect(report.cases.every((result) => result.ndcg > 0)).toBe(true) }) it("caps query topK when a caller provides a maximum", async () => { const parent = await mkdtemp(path.join(os.tmpdir(), "ragmir-evaluate-cap-")) tempDirs.push(parent) const root = path.join(parent, "example") - await cp(path.join(packageRoot, "examples", "sovereign-rag-demo"), root, { - recursive: true, - }) + await copySovereignDemo(root) await writeFile( path.join(root, "large-top-k-golden.json"), `${JSON.stringify( @@ -75,13 +75,50 @@ describe("evaluateGoldenQueries", () => { expect(report.cases[0]?.topK).toBe(3) }) + it("measures recall against exact expected citations when provided", async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), "ragmir-evaluate-citation-")) + tempDirs.push(parent) + const root = path.join(parent, "example") + await copySovereignDemo(root) + await writeFile( + path.join(root, "citation-golden.json"), + `${JSON.stringify( + { + queries: [ + { + id: "exact-citation", + query: "Which dataset was rejected for confidential tests?", + expectedPaths: ["raw/dataset-inventory.csv"], + expectedCitations: ["raw/dataset-inventory.csv:L1-L5#0"], + topK: 3, + }, + ], + }, + null, + 2, + )}\n`, + "utf8", + ) + + await ingest({ cwd: root }) + const report = await evaluateGoldenQueries({ + cwd: root, + goldenPath: "citation-golden.json", + }) + + expect(report.hits).toBe(1) + expect(report.recall).toBe(1) + expect(report.meanReciprocalRank).toBe(1) + expect(report.ndcg).toBe(1) + expect(report.cases[0]?.matchedPaths).toContain("raw/dataset-inventory.csv") + expect(report.cases[0]?.matchedCitations).toEqual(["raw/dataset-inventory.csv:L1-L5#0"]) + }) + it("reports a miss when no expected path is retrieved", async () => { const parent = await mkdtemp(path.join(os.tmpdir(), "ragmir-evaluate-miss-")) tempDirs.push(parent) const root = path.join(parent, "example") - await cp(path.join(packageRoot, "examples", "sovereign-rag-demo"), root, { - recursive: true, - }) + await copySovereignDemo(root) await writeFile( path.join(root, "miss-golden.json"), `${JSON.stringify( @@ -115,4 +152,53 @@ describe("evaluateGoldenQueries", () => { expect(caseResult?.hit).toBe(false) expect(caseResult?.bestRank).toBeNull() }) + + it("reports a miss when only the expected path matches an exact citation query", async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), "ragmir-evaluate-citation-miss-")) + tempDirs.push(parent) + const root = path.join(parent, "example") + await copySovereignDemo(root) + await writeFile( + path.join(root, "citation-miss-golden.json"), + `${JSON.stringify( + { + queries: [ + { + id: "wrong-citation", + query: "Which dataset was rejected for confidential tests?", + expectedPaths: ["raw/dataset-inventory.csv"], + expectedCitations: ["raw/dataset-inventory.csv#99"], + topK: 3, + }, + ], + }, + null, + 2, + )}\n`, + "utf8", + ) + + await ingest({ cwd: root }) + const report = await evaluateGoldenQueries({ + cwd: root, + goldenPath: "citation-miss-golden.json", + }) + + expect(report.misses).toBe(1) + expect(report.recall).toBe(0) + const caseResult = report.cases[0] + expect(caseResult?.matchedPaths).toContain("raw/dataset-inventory.csv") + expect(caseResult?.matchedCitations).toEqual([]) + expect(caseResult?.hit).toBe(false) + expect(caseResult?.bestRank).toBeNull() + expect(caseResult?.reciprocalRank).toBe(0) + expect(caseResult?.ndcg).toBe(0) + }) }) + +async function copySovereignDemo(root: string): Promise { + await cp(path.join(packageRoot, "examples", "sovereign-rag-demo"), root, { + recursive: true, + }) + await rm(path.join(root, ".ragmir", "storage"), { recursive: true, force: true }) +} diff --git a/packages/ragmir-core/src/evaluate.ts b/packages/ragmir-core/src/evaluate.ts index bf947ed..bd6ec4a 100644 --- a/packages/ragmir-core/src/evaluate.ts +++ b/packages/ragmir-core/src/evaluate.ts @@ -16,6 +16,7 @@ const goldenQuerySchema = z id: z.string().min(1).optional(), query: z.string().min(1), expectedPaths: z.array(z.string().min(1)).min(1), + expectedCitations: z.array(z.string().min(1)).min(1).optional(), topK: z.number().int().positive().optional(), }) .strict() @@ -42,24 +43,45 @@ export async function evaluateGoldenQueries(options: EvaluationOptions): Promise const topK = boundedTopK(goldenQuery.topK ?? defaultTopK, options.maxTopK) const results = await search(goldenQuery.query, { cwd, topK }) const returnedPaths = results.map((result) => result.relativePath) + const returnedCitations = results.map(citationForResult) + const expectedCitations = goldenQuery.expectedCitations ?? [] + const requiresExactCitation = expectedCitations.length > 0 + const expectedValues = requiresExactCitation ? expectedCitations : goldenQuery.expectedPaths const matchedPaths = returnedPaths.filter((resultPath) => goldenQuery.expectedPaths.includes(resultPath), ) - const bestRank = - returnedPaths.findIndex((resultPath) => goldenQuery.expectedPaths.includes(resultPath)) + 1 + const matchedCitations = returnedCitations.filter((citation) => + expectedCitations.includes(citation), + ) + const bestRank = requiresExactCitation + ? returnedCitations.findIndex((citation) => expectedCitations.includes(citation)) + 1 + : returnedPaths.findIndex((resultPath) => goldenQuery.expectedPaths.includes(resultPath)) + 1 + const reciprocalRank = bestRank > 0 ? 1 / bestRank : 0 + const ndcg = ndcgAtK( + requiresExactCitation ? returnedCitations : returnedPaths, + expectedValues, + topK, + ) const result: EvaluationCaseResult = { query: goldenQuery.query, expectedPaths: goldenQuery.expectedPaths, topK, returnedPaths, + returnedCitations, matchedPaths, - hit: matchedPaths.length > 0, + matchedCitations, + hit: requiresExactCitation ? matchedCitations.length > 0 : matchedPaths.length > 0, bestRank: bestRank > 0 ? bestRank : null, + reciprocalRank, + ndcg, } if (goldenQuery.id !== undefined) { result.id = goldenQuery.id } + if (goldenQuery.expectedCitations !== undefined) { + result.expectedCitations = goldenQuery.expectedCitations + } cases.push(result) } @@ -78,6 +100,8 @@ export async function evaluateGoldenQueries(options: EvaluationOptions): Promise hits, misses: cases.length - hits, recall: hits / cases.length, + meanReciprocalRank: mean(cases.map((result) => result.reciprocalRank)), + ndcg: mean(cases.map((result) => result.ndcg)), cases, } } @@ -104,6 +128,9 @@ function normalizeGoldenQuery(value: z.infer): GoldenQ query: value.query, expectedPaths: value.expectedPaths, } + if (value.expectedCitations !== undefined) { + result.expectedCitations = value.expectedCitations + } if (value.id !== undefined) { result.id = value.id } @@ -116,3 +143,28 @@ function normalizeGoldenQuery(value: z.infer): GoldenQ function boundedTopK(topK: number, maxTopK: number | undefined): number { return maxTopK === undefined ? topK : Math.min(topK, maxTopK) } + +function citationForResult(result: { citation: string }): string { + return result.citation +} + +function ndcgAtK(returned: string[], expected: string[], topK: number): number { + const expectedSet = new Set(expected) + const returnedAtK = returned.slice(0, topK) + const dcg = returnedAtK.reduce((score, value, index) => { + if (!expectedSet.has(value)) { + return score + } + return score + 1 / Math.log2(index + 2) + }, 0) + const idealMatches = Math.min(expectedSet.size, topK) + const idealDcg = Array.from( + { length: idealMatches }, + (_value, index) => 1 / Math.log2(index + 2), + ).reduce((score, gain) => score + gain, 0) + return idealDcg === 0 ? 0 : dcg / idealDcg +} + +function mean(values: number[]): number { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length +} diff --git a/packages/ragmir-core/src/index-diagnostics.test.ts b/packages/ragmir-core/src/index-diagnostics.test.ts index 7f30d7d..4e5748b 100644 --- a/packages/ragmir-core/src/index-diagnostics.test.ts +++ b/packages/ragmir-core/src/index-diagnostics.test.ts @@ -26,6 +26,8 @@ function baseManifest(overrides: Partial = {}): IndexManifest { ragmirVersion: "0.4.12", embeddingProvider: "local-hash", embeddingModel: "mixedbread-ai/mxbai-embed-xsmall-v1", + vectorDimension: 384, + vectorDistanceMetric: "l2", chunkSize: 1200, chunkOverlap: 200, fileCount: 1, @@ -99,6 +101,17 @@ describe("getIndexFreshnessWarning", () => { expect(warning).not.toBeNull() expect(warning).toContain("chunkSize") }) + + it("warns when the vector distance metric differs", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-freshness-metric-")) + tempDirs.push(root) + const config = testConfig(root) + await writeIndexManifest({ ...baseManifest(), vectorDistanceMetric: "cosine" }, config) + + const warning = await getIndexFreshnessWarning(config) + expect(warning).not.toBeNull() + expect(warning).toContain("vector distance metric") + }) }) describe("getLexicalScanWarning", () => { diff --git a/packages/ragmir-core/src/index-diagnostics.ts b/packages/ragmir-core/src/index-diagnostics.ts index fbe4598..025daa6 100644 --- a/packages/ragmir-core/src/index-diagnostics.ts +++ b/packages/ragmir-core/src/index-diagnostics.ts @@ -1,3 +1,4 @@ +import { VECTOR_DISTANCE_METRIC } from "./defaults.js" import { readIndexManifest } from "./store.js" import type { Config } from "./types.js" @@ -7,7 +8,7 @@ import type { Config } from "./types.js" * required metadata). A stored manifest with a lower schemaVersion means the * index predates the current code and should be rebuilt. */ -export const INDEX_SCHEMA_VERSION = 1 +export const INDEX_SCHEMA_VERSION = 2 /** * Detect a stale or incompatible index without re-scanning every source file. @@ -38,6 +39,13 @@ export async function getIndexFreshnessWarning(config: Config): Promise { const result = await ingest({ cwd: root }) expect(result.vectorIndexWarning).toBeNull() + expect(result.lexicalIndexWarning).toBeNull() const manifest = await readIndexManifest(await loadConfig(root)) expect(manifest).not.toBeNull() expect(manifest?.embeddingProvider).toBe("local-hash") + expect(manifest?.schemaVersion).toBe(2) + expect(manifest?.vectorDimension).toBeGreaterThan(0) + expect(manifest?.vectorDistanceMetric).toBe("l2") expect(manifest?.chunkCount).toBe(result.chunks) }) diff --git a/packages/ragmir-core/src/ingest.ts b/packages/ragmir-core/src/ingest.ts index 54cbc08..e44b77c 100644 --- a/packages/ragmir-core/src/ingest.ts +++ b/packages/ragmir-core/src/ingest.ts @@ -2,6 +2,7 @@ import path from "node:path" import { recordAccess } from "./access-log.js" import { chunkDocument } from "./chunking.js" import { loadConfig } from "./config.js" +import { VECTOR_DISTANCE_METRIC } from "./defaults.js" import { embedTexts } from "./embeddings.js" import { countSkippedByReason, @@ -124,6 +125,10 @@ export async function ingest(options: IngestOptions = {}): Promise const indexRows = [...reusableRows, ...rows] const writeResult = await writeRows(indexRows, config) if (indexRows.length > 0) { + const firstRow = indexRows[0] + if (!firstRow) { + throw new Error("Cannot write an index manifest without indexed rows.") + } await writeIndexManifest( { schemaVersion: INDEX_SCHEMA_VERSION, @@ -131,6 +136,8 @@ export async function ingest(options: IngestOptions = {}): Promise ragmirVersion: VERSION, embeddingProvider: config.embeddingProvider, embeddingModel: config.embeddingModel, + vectorDimension: firstRow.vector.length, + vectorDistanceMetric: VECTOR_DISTANCE_METRIC, chunkSize: config.chunkSize, chunkOverlap: config.chunkOverlap, fileCount: new Set(indexRows.map((row) => row.relativePath)).size, @@ -167,6 +174,7 @@ export async function ingest(options: IngestOptions = {}): Promise unsupportedExtensions: summarizeUnsupportedExtensions(inventory.skippedFiles), redactions: totalRedactions(redactionCounts), vectorIndexWarning: writeResult.vectorIndexWarning, + lexicalIndexWarning: writeResult.lexicalIndexWarning, errors, } } diff --git a/packages/ragmir-core/src/mcp.test.ts b/packages/ragmir-core/src/mcp.test.ts index 98f2b5a..b0e521b 100644 --- a/packages/ragmir-core/src/mcp.test.ts +++ b/packages/ragmir-core/src/mcp.test.ts @@ -52,6 +52,7 @@ describe("searchOptions", () => { expect((await searchOptions(root, 50)).topK).toBe(5) expect((await searchOptions(root, 2)).topK).toBe(2) expect((await searchOptions(root, undefined)).topK).toBe(5) + expect((await searchOptions(root, 2, 20)).contextRadius).toBe(3) }) }) diff --git a/packages/ragmir-core/src/mcp.ts b/packages/ragmir-core/src/mcp.ts index 9897bcb..828fc85 100644 --- a/packages/ragmir-core/src/mcp.ts +++ b/packages/ragmir-core/src/mcp.ts @@ -18,14 +18,17 @@ import { VERSION } from "./version.js" const queryToolInputSchema = z.object({ query: z.string().min(1), topK: z.number().int().positive().optional(), + contextRadius: z.number().int().min(0).optional(), }) -const searchToolInputSchema = queryToolInputSchema.extend({ +const researchToolInputSchema = z.object({ + query: z.string().min(1), + topK: z.number().int().positive().optional(), + includeCode: z.boolean().optional(), compact: z.boolean().optional(), }) -const researchToolInputSchema = queryToolInputSchema.extend({ - includeCode: z.boolean().optional(), +const searchToolInputSchema = queryToolInputSchema.extend({ compact: z.boolean().optional(), }) @@ -106,8 +109,8 @@ export async function serveMcp(cwd = resolveMcpProjectRoot()): Promise { description: "Retrieve relevant passages from the local Ragmir knowledge base.", inputSchema: searchToolInputSchema, }, - async ({ query, topK, compact }) => { - const results = await search(query, await searchOptions(cwd, topK)) + async ({ query, topK, contextRadius, compact }) => { + const results = await search(query, await searchOptions(cwd, topK, contextRadius)) return textResult(compact ? compactSearchResults(results) : results) }, ) @@ -119,7 +122,8 @@ export async function serveMcp(cwd = resolveMcpProjectRoot()): Promise { description: "Return cited retrieval context for a question without calling an LLM.", inputSchema: queryToolInputSchema, }, - async ({ query, topK }) => textResult(await ask(query, await searchOptions(cwd, topK))), + async ({ query, topK, contextRadius }) => + textResult(await ask(query, await searchOptions(cwd, topK, contextRadius))), ) server.registerTool( @@ -231,10 +235,18 @@ function textResult(value: unknown): { content: Array<{ type: "text"; text: stri export async function searchOptions( cwd: string, topK: number | undefined, -): Promise<{ cwd: string; topK?: number }> { + contextRadius?: number | undefined, +): Promise<{ cwd: string; topK?: number; contextRadius?: number }> { const config = await loadConfig(cwd) const boundedTopK = Math.min(topK ?? config.topK, config.mcpMaxTopK) - return { cwd, topK: boundedTopK } + const boundedContextRadius = + contextRadius === undefined ? undefined : Math.min(Math.max(0, contextRadius), 3) + const result: { cwd: string; topK?: number; contextRadius?: number } = { + cwd, + topK: boundedTopK, + } + addOption(result, "contextRadius", boundedContextRadius) + return result } async function evaluationOptions( diff --git a/packages/ragmir-core/src/query-sanitizer.test.ts b/packages/ragmir-core/src/query-sanitizer.test.ts new file mode 100644 index 0000000..0058646 --- /dev/null +++ b/packages/ragmir-core/src/query-sanitizer.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" +import { sanitizeRetrievalQuery } from "./query-sanitizer.js" + +describe("sanitizeRetrievalQuery", () => { + it("passes through concise retrieval queries", () => { + const result = sanitizeRetrievalQuery("token rotation policy") + + expect(result.query).toBe("token rotation policy") + expect(result.changed).toBe(false) + expect(result.method).toBe("passthrough") + }) + + it("extracts the final user question from a long prompt", () => { + const prompt = [ + "System: You are an agent. Follow all repository rules. ".repeat(20), + "The context above is not the retrieval query.", + "What document proves offline text-to-speech is required?", + ].join("\n") + + const result = sanitizeRetrievalQuery(prompt) + + expect(result.query).toBe("What document proves offline text-to-speech is required?") + expect(result.changed).toBe(true) + expect(result.method).toBe("question") + }) + + it("falls back to labeled query tails before truncating", () => { + const prompt = `${"developer instructions ".repeat(40)}\nquery: release workflow approval checksums` + + const result = sanitizeRetrievalQuery(prompt) + + expect(result.query).toBe("release workflow approval checksums") + expect(result.method).toBe("labeled-tail") + }) +}) diff --git a/packages/ragmir-core/src/query-sanitizer.ts b/packages/ragmir-core/src/query-sanitizer.ts new file mode 100644 index 0000000..ffc15c1 --- /dev/null +++ b/packages/ragmir-core/src/query-sanitizer.ts @@ -0,0 +1,194 @@ +const MAX_RETRIEVAL_QUERY_LENGTH = 250 +const TAIL_RETRIEVAL_QUERY_LENGTH = 200 +const MIN_RETRIEVAL_QUERY_LENGTH = 10 +const QUERY_LABELS = new Set([ + "question", + "query", + "search", + "recherche", + "demande", + "user", + "utilisateur", +]) + +export interface SanitizedQuery { + query: string + changed: boolean + method: "passthrough" | "question" | "labeled-tail" | "tail-sentence" | "tail" + originalLength: number +} + +export function sanitizeRetrievalQuery(input: string): SanitizedQuery { + const normalized = compactWhitespace(stripLoneSurrogates(input)) + if (normalized.length <= MAX_RETRIEVAL_QUERY_LENGTH) { + return { + query: normalized, + changed: normalized !== input, + method: "passthrough", + originalLength: input.length, + } + } + + const question = finalQuestion(normalized) + if (question !== null) { + return sanitized(input, question, "question") + } + + const labeled = labeledTail(input) + if (labeled !== null) { + return sanitized(input, labeled, "labeled-tail") + } + + const sentence = finalSentence(normalized) + if (sentence !== null) { + return sanitized(input, sentence, "tail-sentence") + } + + return sanitized(input, normalized.slice(-TAIL_RETRIEVAL_QUERY_LENGTH), "tail") +} + +function sanitized(input: string, query: string, method: SanitizedQuery["method"]): SanitizedQuery { + const cleaned = compactWhitespace(stripLoneSurrogates(query)) + const bounded = cleaned.slice(0, MAX_RETRIEVAL_QUERY_LENGTH).trim() + return { + query: bounded, + changed: bounded !== input, + method, + originalLength: input.length, + } +} + +function finalQuestion(text: string): string | null { + for (let end = text.length - 1; end >= 0; end -= 1) { + if (text[end] !== "?") { + continue + } + const start = previousSentenceBoundary(text, end - 1) + const value = text.slice(start + 1, end + 1).trim() + if (isUsableRetrievalQuery(value)) { + return value + } + } + return null +} + +function labeledTail(text: string): string | null { + const lines = text.split("\n") + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim() + if (!line) { + continue + } + const separator = line.indexOf(":") + if (separator <= 0) { + continue + } + const label = line.slice(0, separator).trim().toLowerCase() + if (!QUERY_LABELS.has(label)) { + continue + } + const value = line.slice(separator + 1).trim() + if (isUsableRetrievalQuery(value)) { + return value + } + } + return null +} + +function finalSentence(text: string): string | null { + let end = text.length + while (end > 0) { + while (end > 0 && isBoundaryOrWhitespace(text[end - 1] ?? "")) { + end -= 1 + } + if (end <= 0) { + break + } + const start = previousSentenceBoundary(text, end - 1) + const value = text.slice(start + 1, end).trim() + if (isUsableRetrievalQuery(value)) { + return value + } + end = start + } + return null +} + +function isUsableRetrievalQuery(value: string | undefined): value is string { + if (value === undefined) { + return false + } + return ( + value.length >= MIN_RETRIEVAL_QUERY_LENGTH && + value.length <= MAX_RETRIEVAL_QUERY_LENGTH && + hasLetterOrNumber(value) + ) +} + +function stripLoneSurrogates(value: string): string { + let output = "" + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + output += value[index] ?? "" + output += value[index + 1] ?? "" + index += 1 + } + continue + } + if (code >= 0xdc00 && code <= 0xdfff) { + continue + } + output += value[index] ?? "" + } + return output +} + +function compactWhitespace(value: string): string { + let output = "" + let previousWasWhitespace = false + for (const char of value) { + if (char.trim() === "") { + if (!previousWasWhitespace) { + output += " " + } + previousWasWhitespace = true + continue + } + output += char + previousWasWhitespace = false + } + return output.trim() +} + +function previousSentenceBoundary(text: string, fromIndex: number): number { + for (let index = fromIndex; index >= 0; index -= 1) { + if (isSentenceBoundary(text[index] ?? "")) { + return index + } + } + return -1 +} + +function isBoundaryOrWhitespace(char: string): boolean { + return isSentenceBoundary(char) || char.trim() === "" +} + +function isSentenceBoundary(char: string): boolean { + return char === "." || char === "?" || char === "!" || char === "\n" || char === "\r" +} + +function hasLetterOrNumber(value: string): boolean { + for (const char of value) { + const codePoint = char.codePointAt(0) + if (codePoint !== undefined && codePoint >= 48 && codePoint <= 57) { + return true + } + if (char.toLocaleLowerCase() !== char.toLocaleUpperCase()) { + return true + } + } + return false +} diff --git a/packages/ragmir-core/src/query.test.ts b/packages/ragmir-core/src/query.test.ts index 419d9e6..79ff08a 100644 --- a/packages/ragmir-core/src/query.test.ts +++ b/packages/ragmir-core/src/query.test.ts @@ -44,6 +44,66 @@ describe("search", () => { expect(results).toHaveLength(1) expect(results[0]?.relativePath).toBe(".ragmir/raw/security-policy.md") + expect(results[0]?.citation).toContain(".ragmir/raw/security-policy.md:L1-") + expect(results[0]?.lineStart).toBe(1) + }) + + it("uses the full-text lexical index when the fallback scan limit is narrow", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-query-fts-")) + tempDirs.push(root) + await initProject(root) + await mkdir(path.join(root, ".ragmir", "raw"), { recursive: true }) + await writeFile( + path.join(root, ".ragmir", "config.json"), + JSON.stringify({ hybridTextScanLimit: 1, topK: 1 }), + "utf8", + ) + await writeFile( + path.join(root, ".ragmir", "raw", "alpha.md"), + "Routine planning notes with no target keyword.\n", + "utf8", + ) + await writeFile( + path.join(root, ".ragmir", "raw", "zeta.md"), + "The zanzibar-token policy is the authoritative retention proof.\n", + "utf8", + ) + + await ingest({ cwd: root }) + const results = await search("zanzibar-token retention proof", { cwd: root, topK: 1 }) + + expect(results[0]?.relativePath).toBe(".ragmir/raw/zeta.md") + }) + + it("hydrates neighboring context chunks when requested", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-query-context-")) + tempDirs.push(root) + await initProject(root) + await mkdir(path.join(root, ".ragmir", "raw"), { recursive: true }) + await writeFile( + path.join(root, ".ragmir", "config.json"), + JSON.stringify({ chunkSize: 36, chunkOverlap: 0, topK: 1 }), + "utf8", + ) + await writeFile( + path.join(root, ".ragmir", "raw", "context.md"), + [ + "Opening operational note.", + "", + "Rare needle evidence belongs here.", + "", + "Closing consequence line.", + ].join("\n"), + "utf8", + ) + + await ingest({ cwd: root }) + const results = await search("rare needle evidence", { cwd: root, topK: 1, contextRadius: 1 }) + + expect(results[0]?.relativePath).toBe(".ragmir/raw/context.md") + expect(results[0]?.context.length).toBeGreaterThanOrEqual(2) + expect(results[0]?.context.some((chunk) => chunk.text.includes("Opening"))).toBe(true) + expect(results[0]?.context.some((chunk) => chunk.text.includes("Rare needle"))).toBe(true) }) it("retrieves expected evidence from the sovereign RAG demo golden set", async () => { @@ -100,7 +160,7 @@ describe("ask", () => { expect(result.sources).toHaveLength(1) expect(result.answer).toContain("retrieval context only") expect(result.answer).toContain("[1]") - expect(result.answer).toContain("policy.md#0") + expect(result.answer).toContain("policy.md:L1-") expect(result.staleWarning).toBeNull() }) }) diff --git a/packages/ragmir-core/src/query.ts b/packages/ragmir-core/src/query.ts index 7d1d53e..9ad3498 100644 --- a/packages/ragmir-core/src/query.ts +++ b/packages/ragmir-core/src/query.ts @@ -1,16 +1,24 @@ import { recordAccess } from "./access-log.js" import { loadConfig } from "./config.js" +import { VECTOR_DISTANCE_METRIC } from "./defaults.js" import { embedText } from "./embeddings.js" import { getIndexFreshnessWarning } from "./index-diagnostics.js" -import { openRowsTable } from "./store.js" +import { sanitizeRetrievalQuery } from "./query-sanitizer.js" +import { openRowsTable, readIndexManifest } from "./store.js" import { tokenize } from "./text.js" -import type { AskResult, SearchOptions, SearchResult } from "./types.js" +import type { AskResult, SearchContextChunk, SearchOptions, SearchResult } from "./types.js" + +type RowsTable = NonNullable>> interface SearchRow { source: string relativePath: string chunkIndex: number text: string + charStart?: number + charEnd?: number + lineStart?: number + lineEnd?: number _distance?: number } @@ -41,6 +49,7 @@ const RRF_VECTOR_WEIGHT = 0.7 const RRF_LEXICAL_WEIGHT = 0.3 const BM25_K1 = 1.2 const BM25_B = 0.75 +const MAX_CONTEXT_RADIUS = 3 export async function search(query: string, options: SearchOptions = {}): Promise { const config = await loadConfig(String(options.cwd ?? process.cwd())) @@ -49,25 +58,38 @@ export async function search(query: string, options: SearchOptions = {}): Promis return [] } + const sanitized = sanitizeRetrievalQuery(query) + if (!sanitized.query) { + return [] + } + const topK = options.topK ?? config.topK - const vector = await embedText(query, config) + const vector = await embedText(sanitized.query, config) + await assertVectorIndexCompatibility(config, vector.length) const vectorRows = (await table .vectorSearch(vector) .limit(vectorCandidateLimit(topK)) .toArray()) as SearchRow[] - const textRows = (await table.query().limit(config.hybridTextScanLimit).toArray()) as SearchRow[] - const rows = rankHybridRows(query, vectorRows, textRows).slice(0, topK) + const textRows = await lexicalCandidateRows(table, sanitized.query, config.hybridTextScanLimit) + const rows = rankHybridRows(sanitized.query, vectorRows, textRows).slice(0, topK) + const contextByRow = await contextChunksByRow(table, rows, options.contextRadius ?? 0) const results = rows.map((row) => ({ source: row.row.source, relativePath: row.row.relativePath, chunkIndex: row.row.chunkIndex, + citation: citationForRow(row.row), text: row.row.text, distance: typeof row.row._distance === "number" ? row.row._distance : null, + charStart: nullableNumber(row.row.charStart), + charEnd: nullableNumber(row.row.charEnd), + lineStart: nullableNumber(row.row.lineStart), + lineEnd: nullableNumber(row.row.lineEnd), + context: contextByRow.get(rowKey(row.row)) ?? [], })) await recordAccess(config, { action: "search", - query, + query: sanitized.query, topK, resultCount: results.length, }) @@ -93,7 +115,7 @@ export async function ask(query: string, options: SearchOptions = {}): Promise { - const text = source.text.replace(/\s+/gu, " ").trim() - return `[${index + 1}] ${source.relativePath}#${source.chunkIndex}: ${text}` + const text = answerText(source).replace(/\s+/gu, " ").trim() + return `[${index + 1}] ${source.citation}: ${text}` }) .join("\n\n") @@ -120,6 +142,92 @@ function retrievalOnlyAnswer(sources: SearchResult[]): string { ].join("\n") } +async function lexicalCandidateRows( + table: RowsTable, + query: string, + limit: number, +): Promise { + const ftsQuery = lexicalQuery(query) + if (ftsQuery) { + try { + return (await table.search(ftsQuery, "fts", "text").limit(limit).toArray()) as SearchRow[] + } catch { + // Older indexes may not have the FTS index yet. Keep retrieval usable and + // let doctor/index freshness tell the operator to rebuild. + } + } + return (await table.query().limit(limit).toArray()) as SearchRow[] +} + +async function contextChunksByRow( + table: RowsTable, + rows: RankedRow[], + requestedRadius: number, +): Promise> { + const radius = Math.min(MAX_CONTEXT_RADIUS, Math.max(0, requestedRadius)) + if (radius === 0 || rows.length === 0) { + return new Map() + } + + const contexts = new Map() + for (const ranked of rows) { + const row = ranked.row + const minChunk = Math.max(0, row.chunkIndex - radius) + const maxChunk = row.chunkIndex + radius + const contextRows = (await table + .query() + .where( + `relativePath = ${sqlString(row.relativePath)} AND chunkIndex >= ${minChunk} AND chunkIndex <= ${maxChunk}`, + ) + .limit(radius * 2 + 1) + .toArray()) as SearchRow[] + contexts.set(rowKey(row), contextRows.sort(compareChunkRows).map(contextChunkForRow)) + } + return contexts +} + +async function assertVectorIndexCompatibility( + config: Awaited>, + vectorDimension: number, +): Promise { + const manifest = await readIndexManifest(config) + if (!manifest) { + return + } + if (manifest.vectorDimension !== undefined && manifest.vectorDimension !== vectorDimension) { + throw new Error( + `Index vector dimension is ${manifest.vectorDimension} but the active embedding produced ${vectorDimension}. Rebuild with \`rgr ingest --rebuild\`.`, + ) + } + if ( + manifest.vectorDistanceMetric !== undefined && + manifest.vectorDistanceMetric !== VECTOR_DISTANCE_METRIC + ) { + throw new Error( + `Index vector distance metric is ${manifest.vectorDistanceMetric} but Ragmir expects ${VECTOR_DISTANCE_METRIC}. Rebuild with \`rgr ingest --rebuild\`.`, + ) + } +} + +function answerText(source: SearchResult): string { + if (source.context.length === 0) { + return source.text + } + return source.context.map((chunk) => `[${chunk.citation}] ${chunk.text}`).join("\n\n") +} + +function contextChunkForRow(row: SearchRow): SearchContextChunk { + return { + chunkIndex: row.chunkIndex, + text: row.text, + charStart: nullableNumber(row.charStart), + charEnd: nullableNumber(row.charEnd), + lineStart: nullableNumber(row.lineStart), + lineEnd: nullableNumber(row.lineEnd), + citation: citationForRow(row), + } +} + /** * Reciprocal Rank Fusion of vector and lexical retrievers. Rank-only: each * candidate scores `1/(RRF_K + rank)` per retriever it appears in, summed. @@ -260,3 +368,28 @@ function rowDistance(row: SearchRow): number { function rowKey(row: SearchRow): string { return `${row.relativePath}\0${row.chunkIndex}` } + +function citationForRow(row: SearchRow): string { + const lineStart = nullableNumber(row.lineStart) + const lineEnd = nullableNumber(row.lineEnd) + if (lineStart === null || lineEnd === null) { + return `${row.relativePath}#${row.chunkIndex}` + } + return `${row.relativePath}:L${lineStart}-L${lineEnd}#${row.chunkIndex}` +} + +function lexicalQuery(query: string): string { + return [...new Set(tokenize(query))].join(" ") +} + +function compareChunkRows(a: SearchRow, b: SearchRow): number { + return a.chunkIndex - b.chunkIndex +} + +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null +} + +function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} diff --git a/packages/ragmir-core/src/research.ts b/packages/ragmir-core/src/research.ts index f77e8ac..6cd0c4a 100644 --- a/packages/ragmir-core/src/research.ts +++ b/packages/ragmir-core/src/research.ts @@ -156,8 +156,11 @@ export function compactSearchResults( source: result.source, relativePath: result.relativePath, chunkIndex: result.chunkIndex, + citation: result.citation, snippet: compactText(result.text, maxLength), distance: result.distance, + lineStart: result.lineStart, + lineEnd: result.lineEnd, })) } @@ -170,8 +173,11 @@ export function compactResearchReport(report: ResearchReport): Omit { const result = await writeRows([sampleRow(".ragmir/raw/a.md", 0, [0.1, 0.2], config)], config) expect(result.vectorIndexWarning).toBeNull() + expect(result.lexicalIndexWarning).toBeNull() }) }) @@ -179,11 +184,13 @@ describe("empty-text-files manifest", () => { describe("index manifest", () => { const sampleManifest: IndexManifest = { - schemaVersion: 1, + schemaVersion: 2, createdAt: "2026-01-01T00:00:00.000Z", ragmirVersion: "0.4.12", embeddingProvider: "local-hash", embeddingModel: "mixedbread-ai/mxbai-embed-xsmall-v1", + vectorDimension: 384, + vectorDistanceMetric: "l2", chunkSize: 1200, chunkOverlap: 200, fileCount: 3, diff --git a/packages/ragmir-core/src/store.ts b/packages/ragmir-core/src/store.ts index d1b218d..9b4cc42 100644 --- a/packages/ragmir-core/src/store.ts +++ b/packages/ragmir-core/src/store.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises" import path from "node:path" import * as lancedb from "@lancedb/lancedb" +import { VECTOR_DISTANCE_METRIC } from "./defaults.js" import { isRecord } from "./guards.js" import type { Config, IndexManifest, VectorRow } from "./types.js" @@ -27,6 +28,7 @@ export interface EmptyTextFileRecord { export interface IndexWriteResult { vectorIndexWarning: string | null + lexicalIndexWarning: string | null } export async function writeRows(rows: VectorRow[], config: Config): Promise { @@ -39,7 +41,7 @@ export async function writeRows(rows: VectorRow[], config: Config): Promise ({ ...row })) @@ -52,11 +54,16 @@ export async function writeRows(rows: VectorRow[], config: Config): Promise { + const existing = await table.listIndices() + const hasTextIndex = existing.some((index) => index.name === "text_idx") + if (hasTextIndex) { + return { created: false, warning: null } + } + + try { + await table.createIndex("text", { + config: lancedb.Index.fts(), + }) + return { created: true, warning: null } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + return { + created: false, + warning: `Full-text index training failed (${detail}). Falling back to bounded lexical scans; keyword recall may be lower on large corpora.`, + } + } +} + function clampIvfPartitions(value: number): number { return Math.min(MAX_IVF_PARTITIONS, Math.max(MIN_IVF_PARTITIONS, value)) } @@ -140,6 +172,8 @@ function isIndexManifest(value: unknown): value is IndexManifest { typeof value.ragmirVersion === "string" && (value.embeddingProvider === "local-hash" || value.embeddingProvider === "transformers") && typeof value.embeddingModel === "string" && + (!("vectorDimension" in value) || typeof value.vectorDimension === "number") && + (!("vectorDistanceMetric" in value) || typeof value.vectorDistanceMetric === "string") && typeof value.chunkSize === "number" && typeof value.chunkOverlap === "number" && typeof value.fileCount === "number" && diff --git a/packages/ragmir-core/src/types.ts b/packages/ragmir-core/src/types.ts index 5d96919..0889382 100644 --- a/packages/ragmir-core/src/types.ts +++ b/packages/ragmir-core/src/types.ts @@ -71,6 +71,8 @@ export interface IndexManifest { ragmirVersion: string embeddingProvider: EmbeddingProvider embeddingModel: string + vectorDimension?: number + vectorDistanceMetric?: string chunkSize: number chunkOverlap: number fileCount: number @@ -135,6 +137,10 @@ export interface TextChunk { relativePath: string chunkIndex: number text: string + charStart: number + charEnd: number + lineStart: number + lineEnd: number checksum: string bytes: number mtimeMs: number @@ -166,28 +172,49 @@ export interface IngestResult { unsupportedExtensions: Array<{ extension: string; count: number }> redactions: number vectorIndexWarning: string | null + lexicalIndexWarning: string | null errors: Array<{ path: string; message: string }> } export interface SearchOptions { cwd?: PathLike topK?: number + contextRadius?: number +} + +export interface SearchContextChunk { + chunkIndex: number + text: string + charStart: number | null + charEnd: number | null + lineStart: number | null + lineEnd: number | null + citation: string } export interface SearchResult { source: string relativePath: string chunkIndex: number + citation: string text: string distance: number | null + charStart: number | null + charEnd: number | null + lineStart: number | null + lineEnd: number | null + context: SearchContextChunk[] } export interface CompactSearchResult { source: string relativePath: string chunkIndex: number + citation: string snippet: string distance: number | null + lineStart: number | null + lineEnd: number | null } export interface SourceDuplicateCandidate { @@ -216,8 +243,11 @@ export interface ResearchEvidence { source: string relativePath: string chunkIndex: number + citation: string text: string distance: number | null + lineStart: number | null + lineEnd: number | null queries: string[] } @@ -254,6 +284,7 @@ export interface GoldenQuery { id?: string query: string expectedPaths: string[] + expectedCitations?: string[] topK?: number } @@ -270,9 +301,14 @@ export interface EvaluationCaseResult { expectedPaths: string[] topK: number returnedPaths: string[] + returnedCitations: string[] matchedPaths: string[] + matchedCitations: string[] + expectedCitations?: string[] hit: boolean bestRank: number | null + reciprocalRank: number + ndcg: number } export interface EvaluationResult { @@ -284,6 +320,8 @@ export interface EvaluationResult { hits: number misses: number recall: number + meanReciprocalRank: number + ndcg: number cases: EvaluationCaseResult[] } diff --git a/packages/ragmir-landing/messages/en.json b/packages/ragmir-landing/messages/en.json index 3151730..b18dfe9 100644 --- a/packages/ragmir-landing/messages/en.json +++ b/packages/ragmir-landing/messages/en.json @@ -22,7 +22,7 @@ "hero_badge": "Local-first | Cited context | Fewer tokens | Native MCP | MIT", "hero_title_line_1": "With Ragmir, give your agents the cited context", "hero_title_line_2": "from your confidential documents.", - "hero_description": "The open-source local RAG for your AI agents. Index your confidential documents, repository files, and docs on your machine. Your agents get only the cited passages that matter, over MCP. Nothing leaves, nothing leaks.", + "hero_description": "The open-source local RAG for your AI agents. Index your confidential documents, repository files, and docs on your machine. Your agents keep their own memory and reasoning; Ragmir adds the cited evidence layer over MCP. Nothing leaves, nothing leaks.", "hero_primary_cta": "Install Ragmir", "hero_secondary_cta": "See use cases", "hero_npm_downloads_label": "Ragmir downloads/month on npm", @@ -33,7 +33,7 @@ "hero_metric_agents_value": "100% local", "hero_metric_agents_label": "Your confidential documents stay on your machine.", "hero_metric_license_value": "Native MCP", - "hero_metric_license_label": "Claude, Codex, Kimi, GLM-style cloud agents, and OpenCode query the same cited sources.", + "hero_metric_license_label": "Claude, Codex, Kimi, GLM-style cloud agents, and OpenCode query the same read-focused cited sources.", "workspace_title": "Cited context for AI", "workspace_badge": "Synthetic", "workspace_text": "A preview of the expected flow: project files, team question, traceable answer.", @@ -166,13 +166,13 @@ "features_chat_item_5": "Avoids Ollama, Python, and hosted LLM APIs for the default path.", "agents_eyebrow": "Agent workflows", "agents_title": "A local knowledge base for agents, onboarding, and teams.", - "agents_text": "Ragmir connects the same local project state to the agents you actually use. Cloud agents like Claude, Codex, Kimi, and GLM query cited context; OpenCode stays the local runner option; Ragmir Chat adds a one-command local Transformers.js chat path when synthesis must stay on your machine.", + "agents_text": "Ragmir does not replace agent memory. It connects the same local project evidence to the agents you actually use. Cloud agents like Claude, Codex, Kimi, and GLM query cited context; OpenCode stays the local runner option; Ragmir Chat adds a one-command local Transformers.js chat path when synthesis must stay on your machine.", "agents_command_label": "Agent command", "agents_command": "npx rgr install-agent --agents claude,codex,kimi,opencode", "agents_targets_title": "Target clients", "agents_targets_text": "Keep the categories explicit: cloud agents query Ragmir, OpenCode can run locally, Ragmir Chat is the optional built-in local synthesis path, and Ollama remains an external model runtime.", "agents_cloud_name": "Cloud agents", - "agents_cloud_text": "Claude, Codex, Kimi, and GLM-style cloud agents are the same category here: they ask Ragmir for cited local context, then reason from the returned passages.", + "agents_cloud_text": "Claude, Codex, Kimi, and GLM-style cloud agents are the same category here: they ask Ragmir for cited local context through read-focused MCP, then reason from the returned passages.", "agents_opencode_text": "OpenCode is the local agent runner option when you want to keep the workflow close to the workstation and avoid another hosted RAG service.", "agents_chat_name": "Ragmir Chat", "agents_chat_text": "The optional chat add-on preloads a small Transformers.js model and answers from Ragmir citations without adding an Ollama server.", @@ -240,7 +240,7 @@ "faq_title": "Frequently asked questions", "faq_text": "What Ragmir does, what it never sends off your machine, and how to connect it to your agents.", "faq_what_question": "What is Ragmir?", - "faq_what_answer": "Ragmir is an open-source library, CLI, and MCP server that turns your confidential document corpus (cahier des charges, specs, runbooks, corpora from Google Drive) into cited, queryable context for your AI agents. It indexes your documents on your machine and returns cited passages: your agents answer from your real documents instead of guessing. It is not an IDE code indexer; it complements it for non-code material. It works offline by default.", + "faq_what_answer": "Ragmir is an open-source library, CLI, and MCP server that turns your confidential document corpus (cahier des charges, specs, runbooks, corpora from Google Drive) into cited, queryable context for your AI agents. It indexes your documents on your machine and returns cited passages: your agents answer from your real documents instead of guessing. It complements their memory and code index for non-code material. It works offline by default.", "faq_private_question": "Does Ragmir send my confidential code or documents?", "faq_private_answer": "No. Ragmir only indexes the local folders you choose and keeps all generated state in the git-ignored .ragmir/ folder. No telemetry, no upload to the cloud, and offline by default: it fits confidential code, customer data, and regulated environments.", "faq_agents_question": "Which AI agents and tools does Ragmir work with?", @@ -248,7 +248,7 @@ "faq_offline_question": "Does Ragmir require an API key, a model download, or an internet connection?", "faq_offline_answer": "No. The default local-hash mode runs entirely offline, with no model or API key. Optional semantic embeddings and Ragmir Chat models can be preloaded once with Transformers.js, then run offline. You can also hand citations to a cloud agent or an external local runtime such as Ollama.", "faq_compare_question": "How does Ragmir differ from an IDE's code indexing or a cloud RAG?", - "faq_compare_answer": "IDEs already index your code well. Ragmir targets what they cannot reach: your non-code documents (cahier des charges, specs, contracts, corpora from Google Drive). Unlike cloud RAG, Ragmir keeps everything on your machine, exposes it over MCP to any agent, and returns cited passages: local, cited, MCP, with no vendor lock-in.", + "faq_compare_answer": "IDEs already index your code well. Ragmir targets what they cannot reach: your non-code documents (cahier des charges, specs, contracts, corpora from Google Drive). Unlike cloud RAG, Ragmir keeps everything on your machine, exposes read-focused MCP to any agent, and returns cited passages: local, cited, MCP, with no vendor lock-in.", "faq_role_question": "Does Ragmir write the answers for me?", "faq_role_answer": "Ragmir Core returns cited passages from your local files; your agent or model writes from those sources. The optional rgr chat add-on can generate a local cited answer, but the core stays retrieval-only so every citation remains verifiable.", "faq_formats_question": "Which file formats can Ragmir index?", @@ -279,7 +279,7 @@ "strengths_best_item_1": "Confidential non-code corpora: PDFs, DOCX, XLSX, specs, contracts, cahier des charges that Claude Code cannot grep into.", "strengths_best_item_2": "Corpora that exceed the model context window: when 200k tokens are not enough, retrieval is necessary, not optional.", "strengths_best_item_3": "Strict confidentiality: legal, tax, client dossiers that must never leave your machine.", - "strengths_best_item_4": "Grounding AI agents in real evidence: cited passages with file and chunk references, traceable answers.", + "strengths_best_item_4": "Complementing AI agents with real evidence: cited passages with file and chunk references, traceable answers.", "strengths_limits_title": "Less useful for", "strengths_limits_item_1": "Code search: Claude Code with ripgrep and native model understanding is already better on structured code.", "strengths_limits_item_2": "Small text corpora that fit in the context window: paste them directly, the model sees everything.", diff --git a/packages/ragmir-landing/messages/fr.json b/packages/ragmir-landing/messages/fr.json index 5977d56..6318f1b 100644 --- a/packages/ragmir-landing/messages/fr.json +++ b/packages/ragmir-landing/messages/fr.json @@ -22,7 +22,7 @@ "hero_badge": "Local-first | Contexte cité | Moins de tokens | MCP natif | MIT", "hero_title_line_1": "Avec Ragmir, donnez à vos agents le contexte cité", "hero_title_line_2": "issu de vos documents confidentiels.", - "hero_description": "Le RAG local open-source pour vos agents IA. Indexez vos documents confidentiels, les fichiers de votre repo et votre documentation sur votre machine. Vos agents ne reçoivent que les passages cités qui comptent, via MCP. Rien ne sort, rien ne fuite.", + "hero_description": "Le RAG local open-source pour vos agents IA. Indexez vos documents confidentiels, les fichiers de votre repo et votre documentation sur votre machine. Vos agents gardent leur mémoire et leur raisonnement ; Ragmir ajoute la couche de preuves citées via MCP. Rien ne sort, rien ne fuite.", "hero_primary_cta": "Installer Ragmir", "hero_secondary_cta": "Voir les cas d'usage", "hero_npm_downloads_label": "téléchargements/mois de Ragmir sur npm", @@ -33,7 +33,7 @@ "hero_metric_agents_value": "100% local", "hero_metric_agents_label": "Vos documents confidentiels restent sur votre machine.", "hero_metric_license_value": "MCP natif", - "hero_metric_license_label": "Claude, Codex, Kimi, agents cloud type GLM et OpenCode interrogent les mêmes sources citées.", + "hero_metric_license_label": "Claude, Codex, Kimi, agents cloud type GLM et OpenCode interrogent les mêmes sources citées, via un MCP orienté lecture.", "workspace_title": "Contexte cité pour l'IA", "workspace_badge": "Synthétique", "workspace_text": "Un aperçu du flux attendu : fichiers projet, question d'équipe, réponse traçable.", @@ -166,13 +166,13 @@ "features_chat_item_5": "Évite Ollama, Python et les API LLM hébergées dans le chemin par défaut.", "agents_eyebrow": "Workflows agents", "agents_title": "Un socle de connaissance local pour agents, onboarding et équipes.", - "agents_text": "Ragmir connecte le même état projet local aux agents que vous utilisez vraiment. Les agents cloud comme Claude, Codex, Kimi et GLM interrogent le contexte cité ; OpenCode reste l'option runner local ; Ragmir Chat ajoute un chat local Transformers.js en une commande quand la synthèse doit rester sur votre machine.", + "agents_text": "Ragmir ne remplace pas la mémoire des agents. Il connecte les mêmes preuves projet locales aux agents que vous utilisez vraiment. Les agents cloud comme Claude, Codex, Kimi et GLM interrogent le contexte cité ; OpenCode reste l'option runner local ; Ragmir Chat ajoute un chat local Transformers.js en une commande quand la synthèse doit rester sur votre machine.", "agents_command_label": "Commande agent", "agents_command": "npx rgr install-agent --agents claude,codex,kimi,opencode", "agents_targets_title": "Clients visés", "agents_targets_text": "Gardez les catégories nettes : les agents cloud interrogent Ragmir, OpenCode peut tourner localement, Ragmir Chat est le chemin local intégré optionnel, et Ollama reste un runtime externe.", "agents_cloud_name": "Agents cloud", - "agents_cloud_text": "Claude, Codex, Kimi et les agents cloud type GLM sont la même catégorie ici : ils demandent à Ragmir le contexte local cité, puis raisonnent depuis les passages récupérés.", + "agents_cloud_text": "Claude, Codex, Kimi et les agents cloud type GLM sont la même catégorie ici : ils demandent à Ragmir le contexte local cité via MCP orienté lecture, puis raisonnent depuis les passages récupérés.", "agents_opencode_text": "OpenCode est l'option runner local quand vous voulez garder le workflow près du poste et éviter un autre service RAG hébergé.", "agents_chat_name": "Ragmir Chat", "agents_chat_text": "L'add-on chat optionnel précharge un petit modèle Transformers.js et répond depuis les citations Ragmir sans ajouter de serveur Ollama.", @@ -240,7 +240,7 @@ "faq_title": "Questions fréquentes", "faq_text": "Ce que fait Ragmir, ce qu'il n'envoie jamais hors de votre machine, et comment le brancher à vos agents.", "faq_what_question": "Qu'est-ce que Ragmir ?", - "faq_what_answer": "Ragmir est une librairie, une CLI et un serveur MCP open-source qui transforme votre corpus de documents confidentiels (cahier des charges, specs, runbooks, corpus depuis Google Drive) en contexte cité et interrogeable pour vos agents IA. Il indexe vos documents sur votre machine et renvoie des passages cités : vos agents répondent à partir de vos vrais documents au lieu de deviner. Ce n'est pas un indexeur de code d'IDE ; il le complète pour les documents non-code. Il fonctionne hors-ligne par défaut.", + "faq_what_answer": "Ragmir est une librairie, une CLI et un serveur MCP open-source qui transforme votre corpus de documents confidentiels (cahier des charges, specs, runbooks, corpus depuis Google Drive) en contexte cité et interrogeable pour vos agents IA. Il indexe vos documents sur votre machine et renvoie des passages cités : vos agents répondent à partir de vos vrais documents au lieu de deviner. Il complète leur mémoire et leur index de code pour les documents non-code. Il fonctionne hors-ligne par défaut.", "faq_private_question": "Ragmir envoie-t-il mon code ou mes documents confidentiels ?", "faq_private_answer": "Non. Ragmir indexe uniquement les dossiers locaux que vous choisissez et garde tout l'état généré dans le dossier .ragmir/ ignoré par Git. Aucune télémétrie, aucun envoi vers le cloud, et hors-ligne par défaut : il convient au code confidentiel, aux données clients et aux environnements régulés.", "faq_agents_question": "Avec quels agents IA et outils Ragmir fonctionne-t-il ?", @@ -248,7 +248,7 @@ "faq_offline_question": "Ragmir nécessite-t-il une clé API, un téléchargement de modèle ou une connexion internet ?", "faq_offline_answer": "Non. Le mode local-hash par défaut fonctionne entièrement hors-ligne, sans modèle ni clé API. Les embeddings sémantiques optionnels et les modèles Ragmir Chat peuvent être préchargés une fois avec Transformers.js, puis tourner hors-ligne. Vous pouvez aussi donner les citations à un agent cloud ou à un runtime local externe comme Ollama.", "faq_compare_question": "En quoi Ragmir diffère-t-il de l'indexation de code d'un IDE ou d'un RAG cloud ?", - "faq_compare_answer": "Les IDE indexent déjà bien votre code. Ragmir cible ce qu'ils ne peuvent pas atteindre : vos documents non-code (cahier des charges, specs, contrats, corpus depuis Google Drive). Contrairement au RAG cloud, Ragmir garde tout sur votre machine, l'expose via MCP à n'importe quel agent, et renvoie des passages cités : local, cité, MCP, sans dépendance à un fournisseur.", + "faq_compare_answer": "Les IDE indexent déjà bien votre code. Ragmir cible ce qu'ils ne peuvent pas atteindre : vos documents non-code (cahier des charges, specs, contrats, corpus depuis Google Drive). Contrairement au RAG cloud, Ragmir garde tout sur votre machine, expose un MCP orienté lecture à n'importe quel agent, et renvoie des passages cités : local, cité, MCP, sans dépendance à un fournisseur.", "faq_role_question": "Est-ce que Ragmir rédige les réponses à ma place ?", "faq_role_answer": "Ragmir Core renvoie des passages cités depuis vos fichiers locaux ; c'est votre agent ou votre modèle qui rédige à partir de ces sources. L'add-on optionnel rgr chat peut générer une réponse locale citée, mais le cœur reste retrieval-only pour que chaque citation reste vérifiable.", "faq_formats_question": "Quels formats de fichiers Ragmir peut-il indexer ?", @@ -279,7 +279,7 @@ "strengths_best_item_1": "Les corpus confidentiels non-code : PDF, DOCX, XLSX, specs, contrats, cahier des charges que Claude Code ne peut pas parcourir.", "strengths_best_item_2": "Les corpus qui dépassent la fenêtre de contexte du modèle : quand 200k tokens ne suffisent plus, la recherche est nécessaire, pas optionnelle.", "strengths_best_item_3": "La confidentialité stricte : dossiers juridiques, fiscaux, clients qui ne doivent jamais quitter votre machine.", - "strengths_best_item_4": "Ancrer les agents IA dans de vraies preuves : passages cités avec références de fichier et de chunk, réponses traçables.", + "strengths_best_item_4": "Compléter les agents IA avec de vraies preuves : passages cités avec références de fichier et de chunk, réponses traçables.", "strengths_limits_title": "Moins utile pour", "strengths_limits_item_1": "La recherche dans le code : Claude Code avec ripgrep et la compréhension native du modèle est déjà meilleur sur du code structuré.", "strengths_limits_item_2": "Les petits corpus texte qui tiennent dans la fenêtre de contexte : collez-les directement, le modèle voit tout.", diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 2762d8b..b8537b3 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -197,11 +197,24 @@ try { if (searchJson.results?.[0]?.relativePath !== ".ragmir/raw/tax.md") { throw new Error(`search --json should return tax.md, got ${JSON.stringify(searchJson)}`) } + if (!searchJson.results?.[0]?.citation?.includes(".ragmir/raw/tax.md:L")) { + throw new Error( + `search --json should expose line-aware citations, got ${JSON.stringify(searchJson)}`, + ) + } const askJson = parseJson( ( await runKb( - ["ask", "What proves the French tax residency risk?", "--top-k", "1", "--json"], + [ + "ask", + "What proves the French tax residency risk?", + "--top-k", + "1", + "--context-radius", + "1", + "--json", + ], tempRoot, ) ).stdout, @@ -212,6 +225,9 @@ try { `ask --json should expose retrieval-only answer, got ${JSON.stringify(askJson)}`, ) } + if (!Array.isArray(askJson.sources?.[0]?.context) || askJson.sources[0].context.length === 0) { + throw new Error(`ask --json should expose neighboring context, got ${JSON.stringify(askJson)}`) + } const search = await runKb(["search", "French tax residency", "--top-k", "1"], tempRoot) assertIncludes(search.stdout, "tax.md", "search should retrieve the tax document") @@ -362,6 +378,7 @@ try { await smokeMcp(tempRoot) await smokeExampleWorkspace() + await smokeDocumentBenchmark() const destroy = await runKb(["destroy-index", "--yes"], tempRoot) assertIncludes(destroy.stdout, "removed=true", "destroy-index should remove generated storage") @@ -525,6 +542,58 @@ async function smokeExampleWorkspace() { } } +async function smokeDocumentBenchmark() { + const benchmarkSource = path.join(corePackageRoot, "examples", "document-evidence-benchmark") + const benchmarkTemp = await mkdtemp(path.join(tmpdir(), "ragmir-document-benchmark-")) + + try { + await cp(benchmarkSource, benchmarkTemp, { recursive: true }) + + const ingest = await runKb(["ingest"], benchmarkTemp) + assertIncludes(ingest.stdout, "errors=0", "document benchmark ingest should complete") + + const evaluation = parseJson( + ( + await runKb( + ["evaluate", "--golden", "golden-queries.json", "--fail-under", "1", "--json"], + benchmarkTemp, + ) + ).stdout, + "document benchmark evaluation JSON", + ) + if ( + evaluation.total !== 5 || + evaluation.embeddingProvider !== "local-hash" || + evaluation.hits !== 5 || + evaluation.misses !== 0 || + evaluation.recall !== 1 || + typeof evaluation.meanReciprocalRank !== "number" || + evaluation.meanReciprocalRank <= 0 || + typeof evaluation.ndcg !== "number" || + evaluation.ndcg <= 0 || + evaluation.passed !== true + ) { + throw new Error( + `document benchmark should hit every exact citation, got ${JSON.stringify(evaluation)}`, + ) + } + for (const testCase of evaluation.cases ?? []) { + const expectedCitation = testCase.expectedCitations?.[0] + if ( + typeof expectedCitation !== "string" || + !Array.isArray(testCase.matchedCitations) || + !testCase.matchedCitations.includes(expectedCitation) + ) { + throw new Error( + `document benchmark should match exact citations, got ${JSON.stringify(testCase)}`, + ) + } + } + } finally { + await rm(benchmarkTemp, { recursive: true, force: true }) + } +} + async function configureProject(cwd) { const configPath = path.join(cwd, ".ragmir", "config.json") const config = JSON.parse(await readFile(configPath, "utf8")) @@ -638,14 +707,27 @@ async function smokeMcp(cwd) { client.notify("notifications/initialized", {}) const tools = await client.request("tools/list", {}) + assertIncludes(JSON.stringify(tools), "ragmir_status", "MCP should expose ragmir_status") + assertIncludes( + JSON.stringify(tools), + "ragmir_route_prompt", + "MCP should expose ragmir_route_prompt", + ) assertIncludes(JSON.stringify(tools), "ragmir_search", "MCP should expose ragmir_search") + assertIncludes(JSON.stringify(tools), "ragmir_ask", "MCP should expose ragmir_ask") assertIncludes(JSON.stringify(tools), "ragmir_research", "MCP should expose ragmir_research") + assertIncludes(JSON.stringify(tools), "ragmir_audit", "MCP should expose ragmir_audit") assertIncludes(JSON.stringify(tools), "ragmir_evaluate", "MCP should expose ragmir_evaluate") assertIncludes( JSON.stringify(tools), "ragmir_usage_report", "MCP should expose ragmir_usage_report", ) + assertIncludes( + JSON.stringify(tools), + "ragmir_security_audit", + "MCP should expose ragmir_security_audit", + ) const status = await client.request("tools/call", { name: "ragmir_status", @@ -653,11 +735,45 @@ async function smokeMcp(cwd) { }) assertIncludes(mcpText(status), "chunksIndexed", "MCP status should return index metadata") + const route = await client.request("tools/call", { + name: "ragmir_route_prompt", + arguments: { prompt: "Find cited local docs about French tax residency evidence." }, + }) + const routeJson = parseJson(mcpText(route), "MCP route prompt JSON") + if (routeJson.shouldUseRagmir !== true || routeJson.tool !== "ragmir_search") { + throw new Error(`MCP route prompt should recommend Ragmir search: ${mcpText(route)}`) + } + const search = await client.request("tools/call", { name: "ragmir_search", - arguments: { query: "French tax residency", topK: 1 }, + arguments: { query: "French tax residency", topK: 1, contextRadius: 1 }, }) - assertIncludes(mcpText(search), "tax.md", "MCP search should retrieve indexed content") + const searchJson = parseJson(mcpText(search), "MCP search JSON") + if ( + !Array.isArray(searchJson) || + searchJson.length !== 1 || + !searchJson[0].citation.includes("tax.md:L") || + !Array.isArray(searchJson[0].context) || + searchJson[0].context.length < 1 + ) { + throw new Error(`MCP search should retrieve line-aware context chunks: ${mcpText(search)}`) + } + + const ask = await client.request("tools/call", { + name: "ragmir_ask", + arguments: { query: "What proves the French tax residency risk?", topK: 1, contextRadius: 1 }, + }) + const askJson = parseJson(mcpText(ask), "MCP ask JSON") + if ( + !askJson.answer?.includes(":L") || + !Array.isArray(askJson.sources) || + askJson.sources.length !== 1 || + typeof askJson.sources[0].citation !== "string" || + !askJson.sources[0].citation.includes(":L") || + !Array.isArray(askJson.sources[0].context) + ) { + throw new Error(`MCP ask should return cited retrieval context: ${mcpText(ask)}`) + } const research = await client.request("tools/call", { name: "ragmir_research", @@ -673,6 +789,12 @@ async function smokeMcp(cwd) { ) } + const audit = await client.request("tools/call", { + name: "ragmir_audit", + arguments: {}, + }) + assertIncludes(mcpText(audit), "missingFromIndex", "MCP audit should return index coverage") + await writeFile( path.join(cwd, "mcp-golden-queries.json"), `${JSON.stringify( @@ -706,6 +828,12 @@ async function smokeMcp(cwd) { if (usageJson.totalEvents < 1) { throw new Error(`MCP usage report should summarize local usage: ${mcpText(usage)}`) } + + const security = await client.request("tools/call", { + name: "ragmir_security_audit", + arguments: {}, + }) + assertIncludes(mcpText(security), "warnings", "MCP security audit should return posture data") } finally { await client.close() } From 9ab3661cbf2b30cfaa00211fec1dc661ef1aa2a4 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste THERY Date: Sat, 11 Jul 2026 20:37:08 +0700 Subject: [PATCH 02/20] feat(chat): add verified local Gemma runtime Add verified local Gemma chat profiles, persistent desktop runtime, and hardened retrieval safeguards. --- .gitignore | 1 + AGENTS.md | 63 +- CLAUDE.md | 39 +- README.md | 121 ++- SECURITY-HARDENING.md | 114 ++- docs/api-reference.md | 82 +- docs/app-sidecar-architecture.md | 49 +- docs/cli-reference.md | 104 ++- docs/configuration.md | 90 +- docs/offline-chat-preload.md | 209 ++++- docs/source-boundary.md | 25 +- docs/troubleshooting.md | 122 ++- llms.txt | 7 +- packages/ragmir-app/README.md | 31 +- .../ragmir-app/src-tauri/src/chat_runtime.rs | 829 ++++++++++++++++++ packages/ragmir-app/src-tauri/src/lib.rs | 8 + packages/ragmir-app/src/app.tsx | 745 +++++++++++++--- .../ragmir-app/src/lib/ragmir-chat-runtime.ts | 416 +++++++++ packages/ragmir-app/src/lib/ragmir-sidecar.ts | 119 +-- packages/ragmir-chat/README.md | 108 ++- packages/ragmir-chat/package.json | 8 +- packages/ragmir-chat/src/cli.ts | 225 ++--- packages/ragmir-chat/src/index.test.ts | 349 +++++--- packages/ragmir-chat/src/index.ts | 631 +++++++------ packages/ragmir-chat/src/profiles.ts | 373 ++++++++ packages/ragmir-chat/src/runtime.test.ts | 354 ++++++++ packages/ragmir-chat/src/runtime.ts | 414 +++++++++ packages/ragmir-chat/src/server.test.ts | 267 ++++++ packages/ragmir-chat/src/server.ts | 372 ++++++++ packages/ragmir-chat/src/types.ts | 245 ++++++ .../document-evidence-benchmark/.gitignore | 1 + .../.ragmir/config.json | 3 +- .../document-evidence-benchmark/README.md | 7 +- .../golden-queries.json | 6 + .../raw/contracts/pdf-control-evidence.pdf | Bin 0 -> 595 bytes .../examples/sovereign-rag-demo/.gitignore | 1 + .../sovereign-rag-demo/.ragmir/config.json | 3 +- .../examples/sovereign-rag-demo/README.md | 10 +- packages/ragmir-core/scripts/mcp-smoke.mjs | 25 + packages/ragmir-core/skills/ragmir/SKILL.md | 14 +- packages/ragmir-core/src/access-log.test.ts | 46 +- packages/ragmir-core/src/access-log.ts | 83 +- packages/ragmir-core/src/chunking.test.ts | 63 ++ packages/ragmir-core/src/chunking.ts | 43 +- packages/ragmir-core/src/cli.ts | 217 ++++- packages/ragmir-core/src/config.test.ts | 70 +- packages/ragmir-core/src/config.ts | 110 ++- packages/ragmir-core/src/defaults.ts | 7 +- packages/ragmir-core/src/destroy.test.ts | 23 +- packages/ragmir-core/src/destroy.ts | 39 +- packages/ragmir-core/src/doctor.test.ts | 24 + packages/ragmir-core/src/doctor.ts | 65 +- packages/ragmir-core/src/embeddings.test.ts | 35 +- packages/ragmir-core/src/embeddings.ts | 104 ++- packages/ragmir-core/src/evaluate.test.ts | 73 +- packages/ragmir-core/src/evaluate.ts | 58 +- packages/ragmir-core/src/files.test.ts | 14 + packages/ragmir-core/src/files.ts | 68 +- .../ragmir-core/src/index-diagnostics.test.ts | 18 +- packages/ragmir-core/src/index-diagnostics.ts | 12 +- packages/ragmir-core/src/index-policy.ts | 31 + packages/ragmir-core/src/index.ts | 6 + packages/ragmir-core/src/ingest.test.ts | 67 +- packages/ragmir-core/src/ingest.ts | 202 +++-- packages/ragmir-core/src/init.ts | 18 +- packages/ragmir-core/src/limits.test.ts | 17 + packages/ragmir-core/src/limits.ts | 27 + packages/ragmir-core/src/mcp.test.ts | 9 + packages/ragmir-core/src/mcp.ts | 108 ++- packages/ragmir-core/src/parsing.test.ts | 56 ++ packages/ragmir-core/src/parsing.ts | 180 +++- packages/ragmir-core/src/permissions.ts | 14 + packages/ragmir-core/src/query.test.ts | 156 ++++ packages/ragmir-core/src/query.ts | 292 +++++- packages/ragmir-core/src/research.test.ts | 22 + packages/ragmir-core/src/research.ts | 44 +- packages/ragmir-core/src/security.test.ts | 79 +- packages/ragmir-core/src/security.ts | 177 +++- .../ragmir-core/src/semantic-config.test.ts | 2 +- packages/ragmir-core/src/setup.test.ts | 2 +- packages/ragmir-core/src/store.ts | 175 ++-- .../ragmir-core/src/test-support/config.ts | 4 + packages/ragmir-core/src/text.test.ts | 14 +- packages/ragmir-core/src/text.ts | 16 +- packages/ragmir-core/src/types.ts | 98 +++ packages/ragmir-landing/messages/en.json | 44 +- packages/ragmir-landing/messages/fr.json | 44 +- .../src/pages/[...locale]/index.astro | 5 +- pnpm-lock.yaml | 728 ++++++++++++++- pnpm-workspace.yaml | 1 + scripts/smoke.mjs | 90 +- 91 files changed, 9023 insertions(+), 1467 deletions(-) create mode 100644 packages/ragmir-app/src-tauri/src/chat_runtime.rs create mode 100644 packages/ragmir-app/src/lib/ragmir-chat-runtime.ts create mode 100644 packages/ragmir-chat/src/profiles.ts create mode 100644 packages/ragmir-chat/src/runtime.test.ts create mode 100644 packages/ragmir-chat/src/runtime.ts create mode 100644 packages/ragmir-chat/src/server.test.ts create mode 100644 packages/ragmir-chat/src/server.ts create mode 100644 packages/ragmir-chat/src/types.ts create mode 100644 packages/ragmir-core/examples/document-evidence-benchmark/raw/contracts/pdf-control-evidence.pdf create mode 100644 packages/ragmir-core/src/index-policy.ts create mode 100644 packages/ragmir-core/src/limits.test.ts create mode 100644 packages/ragmir-core/src/limits.ts create mode 100644 packages/ragmir-core/src/permissions.ts diff --git a/.gitignore b/.gitignore index 63a17a0..284acda 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ packages/ragmir-license-webhook/dist/ !packages/ragmir-core/examples/**/.ragmir/sources.txt packages/ragmir-core/examples/**/.ragmir/storage/ packages/ragmir-core/examples/**/.ragmir/access.log +packages/ragmir-core/examples/**/.ragmir/.ragmir-access-log.salt packages/ragmir-core/examples/**/.ragmir/models/ packages/ragmir-core/examples/**/.ragmir/reports/ packages/ragmir-core/examples/**/.ragmir/audio/ diff --git a/AGENTS.md b/AGENTS.md index b0aaadd..59f4887 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ Transformers.js embeddings with remote model loading disabled by default, redaction before indexing, metadata-only access logs, bounded MCP retrieval, configurable text-extension ingestion, and `security-audit` should work from default config. +- Keep `privacyProfile` and `retrievalProfile` orthogonal. The `strict` privacy floor is applied + after environment overrides and must not be weakened by individual settings. - Keep public positioning focused on sovereign local RAG for confidential datasets and AI agents. Avoid claiming universal binary-file support; unsupported proprietary formats need extraction or dedicated parsers. @@ -159,10 +161,25 @@ warms `.ragmir/models/tts`, pass `--allow-remote-models` only for that preload, then use `--offline` for confidential narration. Remote TTS model loading must stay disabled by default. The operational guide lives in `docs/offline-tts-preload.md`. -- Keep optional local chat separate from core retrieval. `@jcode.labs/ragmir-chat` owns local - Transformers.js text generation for `rgr chat`; `rgr chat setup` is the explicit one-command - preload path for `.ragmir/models/chat`, and normal answers must keep remote model loading disabled - unless the user opts into `--allow-remote-models`. +- Keep optional local chat separate from core retrieval. `@jcode.labs/ragmir-chat` owns verified + GGUF generation through `node-llama-cpp`; `rgr chat setup` is the explicit one-command preload + path for `.ragmir/models/chat/`, with size/SHA256 verification and a local manifest. + Normal answers must never download model files and must not depend on Ollama, Python, or a hosted + inference API. +- Keep the default Ragmir Chat profile on Gemma 4 E2B (`fast`) and the optional quality profile on + Gemma 4 E4B (`quality`). Keep Qwen2.5 0.5B Q4_K_M as the explicit `lite` profile for older or + low-memory computers: use a 4,096-token runtime context, cap generation at 512 tokens, and force + thinking off. Treat the official model weights and their Apache-2.0 notices as separate + from Ragmir's MIT source. Desktop and CLI are the supported local-chat surfaces in this tranche; + do not claim Android local chat until the native LiteRT-LM path is implemented and validated. +- Keep each verified GGUF portable across desktop platforms. Let `node-llama-cpp` select its + packaged Metal, CUDA, Vulkan, or CPU backend with `gpu: "auto"`, while normal answers keep + `build: "never"` and `skipDownload: true`. Expose the actual selected backend through doctor and + app status. Do not add MLX as a Mac production backend without an isolated benchmark and full + model-integrity, streaming, cancellation, thought-filtering, and citation parity; MLX-LM must not + introduce Python into the maintained runtime. +- Gemma 4 thinking may expose only a coarse `reasoning` status. Never display, persist, log, or add + raw thought segments to conversation history. Store and reuse visible final answers only. - In `packages/ragmir-app`, keep model preloads explicit in the Tauri sidecar: setup/prepare flows may pass `--allow-remote-models` only for one-time embedding/chat/TTS preload commands, while normal chat answers and audio rendering must use offline/local modes after preload. @@ -170,6 +187,10 @@ assistant bubble before invoking native chat, show visible thinking/streaming/error states, render assistant Markdown, keep recent thread context local and explicit, and run long native CLI work through async Tauri commands or `spawn_blocking` so the webview does not freeze. +- In `packages/ragmir-app`, keep one persistent Ragmir Chat runtime per active model/profile so the + GGUF is not reloaded for every message. Retrieve only the latest user question, pass visible + structured history separately, stream response deltas over a Tauri channel, and support explicit + cancellation without routing chat through the blocking JSON command helper. - In `packages/ragmir-app`, distinguish Ragmir Chat from external coding agents. Local chat is the only fully private in-app conversation path. Codex, Claude, Kimi, OpenCode, Cline, or generic MCP modes should be presented as a handoff that generates/copies helper config and prompts for the @@ -206,6 +227,15 @@ legacy `.doc` extraction behind `legacyWordCommand` / `RAGMIR_LEGACY_WORD_COMMAND`; execute commands without a shell, require stdout text, and do not add heavy OCR/conversion dependencies or claim universal scan/image/binary support. +- Keep PDF extraction page-aware and bounded. Preserve page citations, OCR only blank pages, pass + `RAGMIR_PDF_PAGE` and `{page}` to page-capable wrappers, and retain page/character safety limits. +- Keep the index-policy fingerprint aligned with every content-transforming setting. Semantic or + stored-row schema changes require a schema bump; normal ingestion must remain file-incremental and + a no-op must not rewrite the LanceDB table. +- Keep exact flat vector search as the default. Do not reintroduce automatic ANN indexes without a + corpus benchmark that proves both recall and latency improve. +- Keep access-log query identifiers project-salted and local generated state on restrictive POSIX + permissions. Do not present the best-effort metadata log as a compliance audit trail. - Keep the repository as a simple pnpm workspace monorepo. Add Turbo only if multiple packages or apps start needing task caching/orchestration beyond `pnpm --filter`. - The Node.js and Rust versions are each pinned once, in `mise.toml` (via @@ -342,9 +372,9 @@ General principles (KISS, DRY, YAGNI, SOLID) as applied in this codebase. Match - `packages/ragmir-core/src/mcp.ts` exposes Ragmir as an MCP stdio server for agents. - `packages/ragmir-tts` is the standalone TTS package used by `rgr audio`; it uses `edge-tts` for high-quality MP3 when available and Transformers.js for offline WAV rendering. -- `packages/ragmir-chat` is the optional local chat package used by `rgr chat`; it uses - Transformers.js text generation over cited Ragmir retrieval context and must not make Ragmir Core - depend on Ollama or any hosted model API. +- `packages/ragmir-chat` is the optional local chat package used by `rgr chat`; it runs verified + Qwen2.5 or Gemma 4 GGUF models through `node-llama-cpp` over cited Ragmir retrieval context and must not make Ragmir Core + depend on Ollama, Python, or any hosted model API. - `packages/ragmir-ui` owns shared React UI primitives and Tailwind theme tokens used by Ragmir product surfaces. - `packages/ragmir-landing` owns the static Astro landing page. @@ -396,25 +426,24 @@ General principles (KISS, DRY, YAGNI, SOLID) as applied in this codebase. Match # GitNexus — Code Intelligence -This project is indexed by GitNexus as **jcode-ragmir** (2311 symbols, 4841 relationships, 190 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **jcode-ragmir** (4498 symbols, 7727 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. -- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. ## Never Do -- NEVER edit a function, class, or method without first running `impact` on it. +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. -- NEVER commit changes without running `detect_changes()` to check affected scope. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. ## Resources diff --git a/CLAUDE.md b/CLAUDE.md index aa7e5ff..2207481 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,8 @@ commit `dist/`; a clean clone has none until `pnpm build` runs. `ragmir-tts` remains a deprecated compatibility bin. Commands: `doctor`, `render`. - Chat CLI binary: **`rgr-chat`** (`packages/ragmir-chat/bin.rgr-chat` -> `packages/ragmir-chat/dist/cli.js`). `ragmir-chat` remains a deprecated compatibility bin. - Commands: `doctor`, `setup`, `answer`. + Commands: `doctor`, `setup`, `answer`, `serve`. `serve` is the internal persistent NDJSON + transport for the desktop app, not the normal interactive workflow. - Project config/state in the target repo: **`.ragmir/`** (`config.json`, `raw/`, `storage/`, `access.log`, `skills/`, reports, audio, and model caches). - Environment overrides: **`RAGMIR_*`** (e.g. `RAGMIR_EMBEDDING_PROVIDER`, `RAGMIR_CHUNK_SIZE`). @@ -114,9 +115,14 @@ synthesis in core). rendering without Python or ffmpeg, and uses `edge-tts` for high-quality MP3 only when explicitly requested. Core `rgr audio` imports it dynamically. -`packages/ragmir-chat` is a separate ESM package. It owns local Transformers.js text generation for -`rgr chat`; Ragmir Core retrieves cited passages and passes them in. Keep the core retrieval-only and -do not introduce an Ollama or hosted-model dependency for chat. +`packages/ragmir-chat` is a separate ESM package. It owns local Gemma 4 QAT GGUF generation through +`node-llama-cpp` for `rgr chat`; Ragmir Core retrieves cited passages and passes them in. The default +`fast` profile is E2B and `quality` is E4B. Keep the core retrieval-only and do not introduce Ollama, +Python, or a hosted-model dependency for chat. Never expose or retain raw Gemma thought segments. +The same GGUF runs through the packaged backend selected by `gpu: "auto"`: Metal on Apple Silicon, +CUDA or Vulkan where supported on Linux/Windows, and CPU only when that packaged backend is actually +available. Doctor and the app must report the selected backend. Keep MLX experimental until a +Mac-only benchmark justifies its separate Swift bridge and Safetensors model supply chain. `packages/ragmir-ui` is the shared Tailwind 4 + React UI layer adapted from the WorkoutGen UI/landing foundation, but with Ragmir tokens and no WorkoutGen product copy, analytics, CDN paths, or secrets. @@ -131,8 +137,10 @@ React loading state stuck. Validate by running `mise exec rust -- pnpm dev:app`, button, cancelling the native dialog, and confirming the UI leaves its loading state. Tauri model-preload trap: when editing `packages/ragmir-app/src-tauri/src/lib.rs`, keep setup -commands explicit about the network boundary. One-time model preloads may pass -`--allow-remote-models`; normal chat/audio commands must stay offline after preload. +commands explicit about the network boundary. One-time chat setup may download and verify the +selected Gemma profile; normal chat must stay offline after preload. Keep a persistent chat runtime +for model reuse, stream deltas through a dedicated channel, and keep audio commands offline after +their separate preload. For app `Prepare` flows, keep the bootstrap order from `AGENTS.md`: write direct-folder sources with `local-hash` first, preload semantic models second, then rebuild with Transformers. @@ -172,25 +180,24 @@ lives in `AGENTS.md`. The workflow publishes `@jcode.labs/ragmir-tts` and # GitNexus — Code Intelligence -This project is indexed by GitNexus as **jcode-ragmir** (2311 symbols, 4841 relationships, 190 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **jcode-ragmir** (4498 symbols, 7727 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. -- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. ## Never Do -- NEVER edit a function, class, or method without first running `impact` on it. +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. -- NEVER commit changes without running `detect_changes()` to check affected scope. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. ## Resources diff --git a/README.md b/README.md index d92300c..b575a45 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ flowchart TD end subgraph Addons["Optional local add-ons"] - Chat["rgr chat
Transformers.js cited answer"] + Chat["rgr chat
local cited answer"] TTS["rgr audio / rgr-tts
offline narration"] end @@ -138,7 +138,7 @@ This root README is the canonical product documentation for the public npm packa | --- | --- | | `@jcode.labs/ragmir` | Ragmir Core: CLI, library, MCP server, bundled agent skills, and synthetic examples. | | `@jcode.labs/ragmir-tts` | Ragmir add-on for Edge-quality MP3 and offline Transformers.js WAV rendering through `rgr audio`. | -| `@jcode.labs/ragmir-chat` | Optional local cited chat add-on for `rgr chat`, backed by Transformers.js and preloaded model files. | +| `@jcode.labs/ragmir-chat` | Optional local cited chat add-on for `rgr chat`, backed by verified Qwen2.5 or Gemma 4 GGUF models and llama.cpp. | | `@jcode.labs/ragmir-ui` | Unpublished workspace UI package adapted from the WorkoutGen design foundation for Ragmir surfaces. | | `@jcode.labs/ragmir-landing` | Unpublished Astro static landing package. Product-facing titles stay `Ragmir`. | | `@jcode.labs/ragmir-app` | Unpublished Tauri desktop/mobile shell package. Native builds are explicit app commands. Core integration uses a bounded native command around the `rgr` CLI, with packaged sidecar distribution still planned. | @@ -167,7 +167,7 @@ agent wiring, API shapes, security details, or app packaging rules: | [`docs/agent-integration.md`](./docs/agent-integration.md) | Claude Code, Codex, Kimi Code CLI, OpenCode, and Cline setup. | | [`docs/troubleshooting.md`](./docs/troubleshooting.md) | Empty indexes, weak search, strict security audit warnings, and audio preload fixes. | | [`SECURITY-HARDENING.md`](./SECURITY-HARDENING.md) | Threat model, offline operation, release verification, and higher-assurance deployment notes. | -| [`docs/offline-chat-preload.md`](./docs/offline-chat-preload.md) | Preload and verify the optional local Transformers.js chat model cache. | +| [`docs/offline-chat-preload.md`](./docs/offline-chat-preload.md) | Preload, verify, and run the optional local chat models offline. | | [`docs/offline-tts-preload.md`](./docs/offline-tts-preload.md) | Preload and verify the offline Transformers.js TTS cache. | | [`docs/fr-eu-sovereign-positioning.md`](./docs/fr-eu-sovereign-positioning.md) | Bounded FR/EU sovereignty, GDPR, AI Act, and legal-vertical positioning. | | [`docs/source-boundary.md`](./docs/source-boundary.md) | What the public MIT repository contains and what must stay outside Git. | @@ -258,8 +258,9 @@ release verification, and an external security review. use the Transformers.js WAV path with `--engine transformers --offline`; it does not require Python, ffmpeg, Piper, XTTS, or a local server. - Optional local chat uses `@jcode.labs/ragmir-chat` through `rgr chat`. Run `rgr chat setup` once to - preload a small Transformers.js text-generation model under `.ragmir/models/chat`, then answer with - `rgr chat "question" --offline`. + download and verify the default Gemma 4 E2B QAT model under `.ragmir/models/chat/fast`, then answer + locally with `rgr chat "question"`. Use `--profile lite` for the 491 MB Qwen2.5 option on older + computers, or `--profile quality` to opt into the larger E4B model. - Optional Markdown reports use the bundled `ragmir-markdown-report` skill and should stay under ignored `.ragmir/reports/` unless explicitly sanitized for sharing. @@ -490,6 +491,17 @@ Retrieve exact passages: npx rgr search "approval for offline operation" ``` +Constrain retrieval to a source family, or remove literature and mirror folders from the candidate +set, before ranking: + +```bash +npx rgr search "current patient findings" --include-path ".ragmir/raw/primary" +npx rgr research "evidence gaps" --exclude-path ".ragmir/raw/research" --exclude-path ".ragmir/raw/archive" +``` + +Path filters accept exact project-relative files or directory prefixes and can be repeated. They +also apply to `ask`, MCP search/ask/research, and individual golden evaluation queries. + Return cited retrieval context for an agent or model: ```bash @@ -502,7 +514,7 @@ Run an audit-backed multi-query research pass before a broad synthesis or implem npx rgr research "release readiness and risks" --compact ``` -Measure recall@k against a golden query file: +Measure retrieval quality and latency against a golden query file: ```bash npx rgr evaluate --golden golden-queries.json @@ -510,9 +522,11 @@ npx rgr evaluate --golden golden-queries.json Golden queries can require file-level hits with `expectedPaths` and exact citation hits with `expectedCitations` in `relative/path:Lx-Ly#chunkIndex` format. Use exact citations when the benchmark -needs to prove that Ragmir retrieved the right passage, not only the right file. Older indexes without -line metadata fall back to `relative/path#chunkIndex` until they are rebuilt. Evaluation reports -hit-rate recall, MRR, and nDCG. +needs to prove that Ragmir retrieved the right passage, not only the right file. PDF citations also +include a page, for example `brief.pdf:p2:L4-L8#3`. Older indexes without line or page metadata fall +back to `relative/path#chunkIndex` until they are rebuilt. Evaluation reports hit rate, true +Recall@K, Precision@K, MRR, bounded nDCG, and p50/p95 retrieval latency. Each query may also define +`includePaths` and `excludePaths`, which is useful for separate primary-source and literature gates. For private dogfooding, keep the real corpus and golden query file outside Git or under an ignored local path, then use a threshold that matches the evaluation phase: @@ -564,21 +578,47 @@ npx rgr ask "What evidence supports offline operation?" `rgr ask` always returns cited retrieved passages instead of a generated synthesis. You can pass those passages to any LLM or agent you trust. -### Optional Local Chat With Transformers.js +### Optional Local Chat Use this when you want a local model to answer from Ragmir citations without adding Ollama or another -model server. The core stays retrieval-only; `@jcode.labs/ragmir-chat` is the optional generator. +model server. The core stays retrieval-only; `@jcode.labs/ragmir-chat` runs verified Qwen2.5 or Gemma 4 GGUF models through +`node-llama-cpp`, with no Python or hosted inference API. ```bash npx rgr chat setup -npx rgr chat "Which evidence supports offline operation?" --offline +npx rgr chat "Which evidence supports offline operation?" --thinking standard + +# Ultra-light profile for older computers. +npx rgr chat setup --profile lite +npx rgr chat "Summarize the cited evidence." --profile lite --thinking off + +# Optional quality profile for machines with more memory. +npx rgr chat setup --profile quality +npx rgr chat "Summarize the strongest evidence." --profile quality --thinking deep ``` -`rgr chat setup` downloads the configured Transformers.js text-generation model into -`.ragmir/models/chat`. Normal `rgr chat` runs with remote model loading disabled by default and cites -the retrieved Ragmir passages as `[1]`, `[2]`, and so on. See -[`docs/offline-chat-preload.md`](./docs/offline-chat-preload.md) for air-gapped setup and model -override details. +The `lite` profile downloads Qwen2.5 0.5B Instruct Q4_K_M, about 491 MB, uses a 4,096-token context, +caps generation at 512 tokens, and always disables thinking. The default `fast` profile downloads +Google's Gemma 4 E2B QAT Q4_0 GGUF, about 3.35 GB. The optional `quality` profile uses E4B, about 5.15 +GB. Setup verifies the exact file size and SHA256 before +writing a local manifest. Normal answers never download a model. Thinking can be disabled or given +standard/deep budgets, but raw thought text is discarded and never added to the visible chat +history. Citations are validated against the retrieved source list, and important answers still need +review against those passages. See [`docs/offline-chat-preload.md`](./docs/offline-chat-preload.md) +for air-gapped setup, profiles, and model verification. + +Each GGUF is portable across desktop systems. Ragmir automatically selects Metal on Apple +Silicon and CUDA or Vulkan on supported Linux/Windows machines, using CPU only when that is the +available packaged backend. Run `npx rgr chat doctor --json` to see the actual platform, +architecture, supported backends, selected backend, and hardware-acceleration status. MLX is not a +second default: MLX-LM requires Python, while MLX Swift would require a separate Mac-only runtime and +model format that still needs an A/B benchmark against the existing Metal path. + +The model files come from the official +[Qwen2.5 0.5B Instruct](https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF), +[Gemma 4 E2B](https://huggingface.co/google/gemma-4-E2B-it-qat-q4_0-gguf) and +[Gemma 4 E4B](https://huggingface.co/google/gemma-4-E4B-it-qat-q4_0-gguf) repositories. Ragmir source +remains MIT-licensed; downloaded model weights and notices remain separate Apache-2.0 assets. ### Optional Semantic Embeddings With Transformers.js @@ -589,7 +629,8 @@ Use this when you want better semantic retrieval while keeping Ragmir core free ```json { "embeddingProvider": "transformers", - "embeddingModel": "mixedbread-ai/mxbai-embed-xsmall-v1", + "embeddingModel": "intfloat/multilingual-e5-small", + "embeddingModelRevision": "main", "embeddingModelPath": ".ragmir/models", "transformersAllowRemoteModels": false } @@ -608,9 +649,14 @@ npx rgr ask "Which passages support offline operation?" `rgr setup --semantic` is the first-run shortcut. It intentionally allows a one-time download from Hugging Face into `embeddingModelPath`, switches `.ragmir/config.json` to `embeddingProvider: "transformers"`, and leaves `transformersAllowRemoteModels` false for normal confidential indexing. -Use `rgr models pull --enable` when you want to make the same choice later. Re-run -`rgr ingest --rebuild` after changing embedding provider or model so stored vectors match the -active configuration. +Use `rgr models pull --enable` when you want to make the same choice later. Pin +`embeddingModelRevision` to an immutable model revision for reproducible deployments. Ragmir +fingerprints the complete index policy and automatically performs a safe full rebuild when the +embedding, chunking, redaction, parser, or extractor policy changes. + +Chunking stays character-bounded while preferring paragraphs, Latin or CJK sentence endings, and +structured line boundaries before whitespace. Audit duplicate candidates require identical SHA-256 +content, so common filenames such as `README.md` do not create false duplicate warnings. ## Agent Skills And MCP @@ -739,19 +785,29 @@ the target repository. Ragmir is designed for private repositories and sensitive local evidence. +`privacyProfile` and `retrievalProfile` are independent. The default `private` plus `balanced` +combination is low-friction. `strict` applies a non-bypassable privacy floor after environment +overrides, while `fast`, `balanced`, and `quality` tune latency and retrieval breadth. A strict +profile limits disclosure, but it cannot make a cloud MCP client, an unencrypted disk, or a +networked external tool confidential. + - Zero telemetry: no analytics or document content is sent to JCode Labs. - No LLM generation in core: Ragmir returns cited context for the agent/runtime you choose. - Local-hash by default: no model runtime is required for the default retrieval path. -- Transformers.js remote model loading is disabled by default. -- Optional Transformers.js model downloads require an explicit preload command or - `--allow-remote-models`; confidential runs should use already cached local model files. +- Transformers.js remote model loading is disabled by default for embeddings and offline TTS. +- Optional model downloads require an explicit preload command. Ragmir Chat verifies every selected GGUF + during setup and never downloads them during a normal answer. +- Ragmir Chat discards Gemma 4 thought segments, and the `lite` profile disables thinking. Only visible final answers may enter local history. - Redaction before indexing: common secrets and identifiers are redacted before chunks are embedded and stored. -- Metadata-only access logs: query hashes and action metadata are logged, not raw queries. +- Metadata-only access logs: project-salted HMAC query hashes and action metadata are logged, not + raw queries. Local Ragmir directories and generated sensitive files use restrictive POSIX modes. - Metadata-only usage reports: `rgr usage-report --days 7` summarizes recent local activity without exposing query text or local paths. - MCP is read-focused, non-destructive, and bounded by `mcpMaxTopK`. -- Generated local state is ignored by Git. +- Strict MCP output is compact by default and exposes project-relative paths. +- Generated local state is ignored by Git. The security audit uses Git itself when available, so + glob rules, ancestor rules, negations, and tracked-file behavior are evaluated consistently. Run: @@ -780,6 +836,13 @@ privacy tuning, or local extractors. The full configuration reference, supported-file matrix, environment overrides, and OCR/extractor rules live in [`docs/configuration.md`](./docs/configuration.md). +Inspect the active safety boundaries with `npx rgr limits`. The default per-file limit is 50 MB. +There is no hard file-count or total-corpus-byte ceiling; disk, parsing and embedding throughput, +memory, and exact-search latency are the practical constraints. PDFs are capped at 1000 pages and +25 million extracted characters, and Office/archive plus external-extractor outputs have additional +hard parsing limits. Oversized files, supported files with no extracted text, missing files, and +stale files make doctor coverage incomplete, so `ready=true` cannot hide partial ingestion. + ## Command And API Reference Ragmir ships three CLIs: @@ -793,7 +856,8 @@ Most users start with `rgr setup`, `rgr doctor`, `rgr ingest`, `rgr route-prompt Use `rgr setup --semantic` during first setup, or `rgr models pull --enable` later, when a one-time Transformers.js model download is acceptable and you want higher-quality semantic retrieval. -Run `rgr ingest --rebuild` after switching embedding provider or model. +Ragmir automatically rebuilds when its index-policy fingerprint changes. Use `rgr ingest --rebuild` +when you intentionally want to discard and recreate an otherwise compatible index. Full command table: [`docs/cli-reference.md`](./docs/cli-reference.md). @@ -837,7 +901,8 @@ core features: | Dependency | Why it remains | | --- | --- | -| `@huggingface/transformers` | Optional local semantic embeddings, offline chat, and offline TTS; remote model loading is disabled unless explicitly enabled for preload. | +| `@huggingface/transformers` | Optional local semantic embeddings and offline TTS; remote model loading is disabled unless explicitly enabled for preload. | +| `node-llama-cpp` | Local Qwen2.5 and Gemma 4 GGUF inference for Ragmir Chat, including native token streaming and cancellable generation without Ollama or Python. | | LanceDB | Local vector storage and nearest-neighbor retrieval. | | MCP SDK | MCP server for compatible agents. | | fast-glob | Safe source-file discovery. | diff --git a/SECURITY-HARDENING.md b/SECURITY-HARDENING.md index 2e9e882..41f01f1 100644 --- a/SECURITY-HARDENING.md +++ b/SECURITY-HARDENING.md @@ -17,14 +17,18 @@ built to minimize data movement, but it is not a certified high-assurance system filenames/extensions are not indexed even when they appear under a source directory. - Ingestion has a default per-file size cap through `maxFileBytes` and reports unsupported, oversized, and secret-like skipped files. -- Metadata-only access logs: access logs contain action metadata and query hashes, not raw - queries or retrieved text. +- Metadata-only access logs: access logs contain action metadata and project-salted HMAC query + hashes, not raw queries or retrieved text. +- Private local modes: Ragmir-created directories use `0700` and generated sensitive files use + `0600` on POSIX systems. `security-audit` reports permissive legacy modes and `doctor --fix` + repairs Ragmir-owned default config and directory modes. Custom external paths remain under the + operator's permission policy. - Generated local state is ignored by Git: `.ragmir/` is ignored by default. - MCP is read-focused: destructive tools are not exposed over MCP, and MCP retrieval is capped by `mcpMaxTopK`. -- Optional local chat uses `rgr chat` / `@jcode.labs/ragmir-chat`. Ragmir Core still stays - retrieval-only; the add-on runs a local Transformers.js text-generation model over retrieved - passages, with remote model loading disabled by default after explicit setup. +- Optional local chat uses `rgr chat` / `@jcode.labs/ragmir-chat`. Ragmir Core stays retrieval-only; + the add-on runs verified Gemma 4 QAT GGUF weights through `node-llama-cpp` 3.19, with explicit setup + as the only normal download path and normal answers offline. - Optional audio summaries use `rgr audio` / `@jcode.labs/ragmir-tts`. Transformers.js WAV is the default offline/confidential path and does not require Python, ffmpeg, Piper, XTTS, or a local TTS server. Remote TTS model downloads are disabled by default and must be explicitly allowed for a @@ -45,6 +49,10 @@ Ragmir does not protect against a compromised local machine, malicious dependenc in the runtime, a user with filesystem access to the same checkout, or forensic recovery from an unencrypted disk. +Ragmir also does not make generated answers true. Chat citation markers are validated against the +retrieved source list, but a real citation can still be misunderstood by the model or contain +incorrect source material. Review important conclusions against the cited passages. + ## At-Rest Encryption Native encrypted LanceDB storage is not implemented yet. For sensitive environments, put the @@ -77,11 +85,23 @@ pnpm exec rgr audit --unsupported ``` For semantic embeddings, preload the Transformers.js-compatible embedding model files inside the -offline environment under the configured `embeddingModelPath`. For chat, preload the local LLM files -under `.ragmir/models/chat` and answer with `pnpm exec rgr chat "Question" --offline`. For audio, -preload the TTS model files under `.ragmir/models/tts` and render with +offline environment under the configured `embeddingModelPath`. For audio, preload the TTS model +files under `.ragmir/models/tts` and render with `pnpm exec rgr audio --engine transformers --offline`. +For chat, run explicit setup on a connected preparation machine, then transfer the complete selected +profile directory, including its GGUF and manifest, into the same ignored path on the offline +machine: + +```bash +pnpm exec rgr chat setup --profile fast +pnpm exec rgr chat doctor --profile fast --verify +pnpm exec rgr chat "Question" --profile fast --thinking standard --offline +``` + +Do not transfer an incomplete GGUF or recreate the manifest by hand. Normal doctor checks the exact +byte size; use `--verify` after transfer to recompute the full SHA-256. + ## Zero Network Posture Default no-model config: @@ -97,7 +117,8 @@ Optional semantic config: ```json { "embeddingProvider": "transformers", - "embeddingModel": "mixedbread-ai/mxbai-embed-xsmall-v1", + "embeddingModel": "intfloat/multilingual-e5-small", + "embeddingModelRevision": "main", "embeddingModelPath": ".ragmir/models", "transformersAllowRemoteModels": false } @@ -110,6 +131,19 @@ equivalent to model semantic retrieval. Keep `transformersAllowRemoteModels` false for confidential or air-gapped work. If it is true, Transformers.js may download model files from Hugging Face during model loading. +For reproducible or reviewed deployments, pin `embeddingModelRevision` to an immutable revision. +Ragmir includes the revision and the complete content-transformation policy in its index fingerprint; +search rejects an incompatible index and ingestion rebuilds it safely. + +`privacyProfile` and `retrievalProfile` are orthogonal. The `strict` privacy floor is applied after +environment overrides, so remote model loading, disabled built-in redaction, high MCP disclosure, +and external extractors cannot weaken it silently. It still does not replace disk encryption, local +account isolation, or a trusted MCP client. + +This Transformers setting controls semantic embeddings, not Gemma chat. Chat downloads are isolated +behind `rgr chat setup [--profile fast|quality]`. Normal answers require an existing verified local +GGUF and do not resolve a remote model. + Run: ```bash @@ -161,13 +195,21 @@ transcribed first. Default ingestion guardrails: - `maxFileBytes`: 50 MB per file by default; +- no hard file-count or total-corpus-byte ceiling, with disk, memory, embedding throughput, and + exact-search latency as practical constraints; - `ingestConcurrency`: four parse/chunk workers by default; - `embeddingBatchSize`: 32 chunks per embedding batch by default; - checksum-based stale detection for supported files; +- manifest-driven file-level updates and automatic rebuild on index-policy change; +- page-aware PDF extraction, blank-page-only OCR, a 1000-page limit, and a 25-million-character + extracted-text limit; - unsupported/skipped file reporting through `rgr ingest`, `rgr audit`, and `rgr audit --unsupported`. -These are configurable, but raising limits increases local memory and parsing risk. +Run `rgr limits` for the effective values. The per-file limit is configurable, but PDF, +Office/archive, and external-extractor output bounds are hard safety blocks. Raising configurable +limits increases local memory and parsing risk. Missing, stale, empty-text, or oversized coverage +keeps `doctor.ready` false. ## Optional Audio Summaries @@ -193,18 +235,49 @@ document. ## Optional Local Chat -`rgr chat` uses `@jcode.labs/ragmir-chat` to run a local Transformers.js text-generation model over -retrieved Ragmir passages. It does not change the core security audit: Ragmir Core itself still -reports `llmGeneration=false`. +`rgr chat` uses `@jcode.labs/ragmir-chat` to run official Google Gemma 4 QAT GGUF weights over +retrieved Ragmir passages through `node-llama-cpp` 3.19. This does not change the core security audit: +Ragmir Core itself still reports `llmGeneration=false`. + +The current runtime supports desktop and CLI workflows. Android chat is deferred until its native +runtime and packaging have been implemented and verified. The desktop/CLI path does not require +Ollama, Python, or a hosted LLM API. + +Profiles: + +| Profile | Model | Download size | Manifest | +| --- | --- | ---: | --- | +| `fast` (default) | Gemma 4 E2B QAT GGUF | 3.35 GB | `.ragmir/models/chat/fast/manifest.json` | +| `quality` (opt-in) | Gemma 4 E4B QAT GGUF | 5.15 GB | `.ragmir/models/chat/quality/manifest.json` | + +The built-in model URIs and download URLs pin immutable Hugging Face revisions rather than `main`. +The manifest preserves that revision together with `schemaVersion`, provider, runtime version, +profile, model ID, official source and license URLs, `Apache-2.0` license identifier, relative +filename, exact byte size, SHA-256, and verification time. It stores no absolute project path. Confidentiality defaults: -- chat model files are stored under `.ragmir/models/chat/`; +- `rgr chat setup [--profile fast|quality]` is the only normal chat path that downloads a model; +- setup verifies the exact expected size and SHA-256 before writing the profile manifest; +- chat model files and manifests stay under ignored `.ragmir/models/chat//` directories; - `.ragmir/` is ignored by Git; -- remote model loading is disabled for normal answers; -- `rgr chat setup` is the explicit one-time preload path that may download model files; -- answers must cite retrieved context, but local LLM output still needs review against the cited - passages. +- normal doctor checks the runtime, expected manifest, file, and exact size without rehashing the + multi-gigabyte GGUF; `rgr chat doctor --verify` performs the full SHA-256 check and reports + `modelHashValid`; +- normal answers load only a ready local profile and keep network resolution off; +- `--thinking off`, `standard`, and `deep` control bounded local reasoning, but raw thought is never + displayed, returned, stored, or logged; +- only the user-visible question and final answer may enter local chat history; +- generated citation markers are checked against the retrieved source list, but cited output still + needs review against the actual passages. + +`rgr-chat serve` is the persistent strict internal stdio JSONL transport for desktop integration. +Requests enter on stdin and protocol events leave on stdout. It is not a user chat interface, must +not mix operational logs into stdout, and never exposes raw thought text. Core `rgr chat` imports the +package API directly for one-shot answers and does not require the server. + +Ragmir's tracked source remains MIT-licensed. Downloaded Gemma 4 weights are separate Apache-2.0 +assets and must not be committed to this repository. ## Optional Markdown Reports @@ -225,6 +298,11 @@ Ragmir MCP defaults: - bounded retrieval through `mcpMaxTopK`; - metadata-only access logging. +Under `privacyProfile: "strict"`, search and research are compact by default, `ask` returns compact +cited retrieval instead of full passages, status/security paths are project-relative, MCP `topK` is +capped at 5, and repository-wide code scanning is disabled. Any MCP client that receives retrieved +content remains inside the confidentiality threat boundary. + For team use, prefer one checkout per user or per role. Ragmir does not implement RBAC. ## Release Verification diff --git a/docs/api-reference.md b/docs/api-reference.md index 60d02be..e59fc20 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -130,15 +130,32 @@ import { ingest } from "@jcode.labs/ragmir" const result = await ingest({ cwd: "/path/to/workspace" }) ``` -Use `rebuild: true` after changing the embedding provider or model: +Use `rebuild: true` to intentionally discard and recreate an otherwise compatible index: ```ts await ingest({ cwd: "/path/to/workspace", rebuild: true }) ``` -`IngestResult` includes discovered/supported/skipped file counts, rebuilt/reused file counts, +Ragmir fingerprints embedding, model revision, chunking, redaction, parsing, and extractor policy. +An incompatible policy triggers a safe full rebuild automatically. Otherwise ingestion updates only +changed or removed source paths; a no-op does not create a new LanceDB table version. + +`IngestResult` includes discovered/supported/skipped file counts, `supportedBytes`, +`largestFileBytes`, rebuilt/reused file counts, unsupported-extension summaries, redaction counts, chunk count, `emptyTextFiles` for supported files -that produced no indexable text, and per-file parsing errors. +that produced no indexable text, per-file parsing errors, and `policyRebuild`. + +### `ingestionLimits(config)` + +Returns the effective per-file limit and the hard PDF, Office/archive, and external-extractor safety +bounds. `maxFiles` and `maxCorpusBytes` are `null` because Ragmir has no fixed ceiling for either. + +```ts +import { ingestionLimits, loadConfig } from "@jcode.labs/ragmir" + +const limits = ingestionLimits(await loadConfig("/path/to/workspace")) +console.log(limits.maxFileBytes, limits.maxFiles) +``` ### `audit(cwd?)` @@ -151,8 +168,10 @@ const report = await audit("/path/to/workspace") ``` Use `missingFromIndex` and `staleInIndex` to decide whether to run `ingest` or `ingest({ rebuild: -true })`. `emptyTextFiles` lists supported files that were processed but produced no indexable text; -they are not treated as missing while their checksum remains unchanged. +true })`. The report also exposes `discoveredFiles`, `supportedBytes`, and `largestFileBytes`. +`emptyTextFiles` lists supported files that were processed but produced no indexable text; they are +not treated as missing while their checksum remains unchanged, but doctor still marks coverage +incomplete. ### `search(query, options?)` @@ -166,9 +185,15 @@ const passages = await search("Who approved offline operation?", { cwd: "/path/to/workspace", topK: 8, contextRadius: 1, + includePaths: ["primary"], + excludePaths: ["research/archive"], }) ``` +`includePaths` and `excludePaths` accept exact project-relative paths or directory prefixes. Filters +are applied inside LanceDB before candidate limits and ranking, so excluded mirror or research +folders do not consume top-K candidate capacity. + Each `SearchResult` includes: | Field | Meaning | @@ -176,15 +201,21 @@ Each `SearchResult` includes: | `relativePath` | Source path relative to the Ragmir project root. | | `source` | Source category used by discovery. | | `chunkIndex` | Chunk number inside that source file. | -| `citation` | Stable citation including line span when available, for example `docs/policy.md:L4-L8#2`. | +| `citation` | Stable citation including PDF page and line span when available, for example `brief.pdf:p2:L4-L8#3`. | | `text` | Retrieved redacted chunk text. | | `distance` | Vector distance when available; `null` for lexical-only rows. | | `lineStart` / `lineEnd` | 1-based line span for the matched chunk, or `null` for legacy indexes. | +| `pageStart` / `pageEnd` | 1-based PDF page span, or `null` for non-PDF and legacy indexes. | | `context` | Neighboring chunks when `contextRadius` is set. The matched chunk remains the cited result. | Use `compactSearchResults(passages)` when an agent or MCP client needs short snippets instead of full retrieved chunks. +Retrieval uses equal-weight reciprocal-rank fusion over vector and LanceDB FTS candidates, then +deduplicates identical content and diversifies sources. `retrievalProfile` controls candidate breadth +and source density. Ragmir uses exact flat vector search by default and abstains from weak +`local-hash` matches that have no lexical evidence. + ### `routePrompt(prompt)` Classifies a user prompt and suggests whether an agent should use Ragmir local context before @@ -294,6 +325,10 @@ first-run CLI shortcut is `rgr setup --semantic`. Returns a readiness report combining setup state, index freshness, security warnings, and next steps. +`readiness` separates `operationalReady`, `indexPolicyCurrent`, `privacyCompliant`, and +`retrievalQualityVerified`. Retrieval quality remains unverified until callers run an evaluation +gate; it is not inferred from index freshness. + ```ts import { doctor } from "@jcode.labs/ragmir" @@ -308,14 +343,20 @@ if (!report.ready) { Returns local privacy posture: provider settings, redaction status, access-log behavior, generated state Git ignore coverage, MCP bounds, and warnings. +The report includes `privacyProfile`, `retrievalProfile`, model revision, and `acceptedRisks`. +Accepted risks are informational and do not suppress warnings. +On POSIX, `permissions` reports whether the config, raw directory, storage directory, and access log +exclude group/other access. `rgr doctor --fix` repairs Ragmir-owned default config and directory +modes; custom external paths remain under the operator's permission policy. + ```ts import { securityAudit } from "@jcode.labs/ragmir" const report = await securityAudit("/path/to/workspace") ``` -`accessLog.storesRawQueries` is always `false`. Ragmir's access log stores query hashes and metadata, -not raw query strings. +`accessLog.storesRawQueries` is always `false`. Ragmir's access log stores project-salted HMAC query +hashes and metadata, not raw query strings. ### `accessLogUsageReport(options?)` @@ -344,8 +385,9 @@ Returns `{ text, counts }`. ### `destroyIndex(cwd?)` -Deletes generated `.ragmir/storage` index files, or the configured storage directory when a project -uses custom paths. +Deletes generated `.ragmir/storage` index files, or a safe configured storage directory. Ragmir +rejects filesystem roots, the project root, home-directory ancestors, and paths without a valid +index manifest. ```ts import { destroyIndex } from "@jcode.labs/ragmir" @@ -427,9 +469,9 @@ MCP tools exposed by the server: | --- | --- | | `ragmir_status` | `{}` | | `ragmir_route_prompt` | `{ prompt: string }` | -| `ragmir_search` | `{ query: string, topK?: number, contextRadius?: number, compact?: boolean }` | -| `ragmir_ask` | `{ query: string, topK?: number, contextRadius?: number }` | -| `ragmir_research` | `{ query: string, topK?: number, includeCode?: boolean, compact?: boolean }` | +| `ragmir_search` | `{ query: string, topK?: number, contextRadius?: number, compact?: boolean, includePaths?: string[], excludePaths?: string[] }` | +| `ragmir_ask` | `{ query: string, topK?: number, contextRadius?: number, includePaths?: string[], excludePaths?: string[] }` | +| `ragmir_research` | `{ query: string, topK?: number, includeCode?: boolean, compact?: boolean, includePaths?: string[], excludePaths?: string[] }` | | `ragmir_audit` | `{}` | | `ragmir_evaluate` | `{ goldenPath: string, topK?: number, failUnder?: number }` | | `ragmir_usage_report` | `{ days?: number }` | @@ -438,9 +480,17 @@ MCP tools exposed by the server: `topK` is bounded by `mcpMaxTopK` from config, and `contextRadius` is capped at 3 chunks on each side. `ragmir_evaluate` also requires `goldenPath` to stay inside the MCP project root. Evaluation golden files support `expectedPaths` for file-level recall and `expectedCitations` for exact -`relative/path:Lx-Ly#chunkIndex` checks. Older indexes without line metadata fall back to -`relative/path#chunkIndex` until they are rebuilt. Evaluation output includes hit-rate recall, MRR, -and nDCG. +`relative/path:Lx-Ly#chunkIndex` checks. PDF citations may also include `:pN`. Older indexes without +line metadata fall back to `relative/path#chunkIndex` until they are rebuilt. Evaluation output +separates hit rate, Recall@K, Precision@K, MRR, bounded nDCG, and p50/p95 latency. Individual golden +queries may define `includePaths` and `excludePaths` using the same source-filter semantics. + +`ragmir_status` includes `ingestionLimits`. This lets clients disclose the current safety bounds +without inferring them from configuration defaults. + +With `privacyProfile: "strict"`, MCP returns compact search/research output by default, compact cited +retrieval for `ragmir_ask`, project-relative paths for status and security reports, and never enables +repository-wide code scanning. ## Package Manager Helpers diff --git a/docs/app-sidecar-architecture.md b/docs/app-sidecar-architecture.md index 0a0961f..20b810a 100644 --- a/docs/app-sidecar-architecture.md +++ b/docs/app-sidecar-architecture.md @@ -2,18 +2,21 @@ ## Decision -The Ragmir app embeds Ragmir Core through the existing `rgr` CLI/MCP surface, with a packaged -Node sidecar as the intended distribution path. Do not rewrite Ragmir Core as Rust bindings for v1. +The Ragmir app embeds Ragmir Core through the existing `rgr` CLI/MCP surface, with a packaged Node +sidecar as the intended distribution path. Desktop chat runs verified Qwen2.5 or Gemma 4 GGUF models through +`node-llama-cpp` 3.19 inside that Node boundary. Do not rewrite Ragmir Core or Ragmir Chat as Rust +bindings for v1. ## Rationale - Ragmir Core already owns parsing, redaction, embeddings, LanceDB storage, query, MCP, and audit behavior. - Reusing `rgr` keeps the MIT core and the app shell on the same tested implementation. -- A Rust rewrite would duplicate LanceDB and Transformers.js integration risk before product demand - is validated. +- A Rust rewrite would duplicate LanceDB, Transformers.js embedding/TTS, and native GGUF runtime + integration before product demand is validated. - The app can keep a narrow native boundary: project selection, process execution, progress/status, and local file permissions. +- Direct `node-llama-cpp` integration needs no Ollama server, Python runtime, or hosted LLM API. ## Tauri Boundary @@ -25,8 +28,8 @@ relative, or non-existent project roots before running the CLI. The future packaged sidecar path remains: -1. Build or package a platform-specific Ragmir Core sidecar binary that exposes bounded `rgr` - workflows. +1. Build or package a platform-specific Ragmir Node sidecar that exposes bounded `rgr` workflows and + includes the matching `node-llama-cpp` native runtime for supported desktop targets. 2. Add that binary to `bundle.externalBin` in `packages/ragmir-app/src-tauri/tauri.conf.json`. 3. Add `@tauri-apps/plugin-shell` on the frontend and `tauri-plugin-shell` on the Rust side only if the packaged sidecar needs the official shell-plugin path. @@ -37,6 +40,10 @@ The future packaged sidecar path remains: Do not add `externalBin` before the actual sidecar binary exists for the native target triples. Doing so would make native Tauri builds fail without adding product value. +Chat GGUF files are not bundled into the app by default. `rgr chat setup` is the explicit download +boundary and stores a verified model plus manifest under the selected project's ignored +`.ragmir/models/chat//` directory. A packaged app must preserve this per-project boundary. + Direct-download packaging, signing, and updater constraints live in [`app-distribution.md`](./app-distribution.md). Keep updater setup deferred until a real release public key, private signing key path, and HTTPS update endpoint exist. @@ -54,15 +61,41 @@ The app should start with a small allowlist: | Force rebuild | `rgr ingest --rebuild --json` | | Search | `rgr search "" --json` | | Ask context | `rgr ask "" --json` | +| Lite chat setup | `rgr chat setup --profile lite --json` | +| Fast chat setup | `rgr chat setup --profile fast --json` | +| Quality chat setup | `rgr chat setup --profile quality --json` | +| Chat readiness | `rgr chat doctor --profile fast --json` | +| Chat integrity audit | `rgr chat doctor --profile fast --verify --json` | +| Offline chat | `rgr chat "" --profile fast --thinking standard --offline --json` | | Privacy audit | `rgr security-audit --json` | | Unsupported files | `rgr audit --unsupported --json` | | Model preload | `rgr models pull --enable --json` | | Audio report | `rgr audio "" --offline --json` | The UI must pass an explicit project root for each selected knowledge base with -`ragmir --project-root "" ...` and keep generated state inside that project (`.ragmir/`) +`rgr --project-root "" ...` and keep generated state inside that project (`.ragmir/`) unless the user intentionally chooses another local folder. +Chat setup is the only allowlisted chat workflow that may use the network. The `lite` profile uses a +491 MB Qwen2.5 0.5B GGUF for older computers, the default `fast` profile downloads the 3.35 GB Gemma +4 E2B GGUF, and `quality` explicitly selects the 5.15 GB E4B GGUF. Setup must +verify the exact byte size and SHA-256 before writing +`.ragmir/models/chat//manifest.json`. Normal answers must use the verified local artifact +with network resolution disabled. + +`rgr-chat serve` is the persistent internal strict stdio JSONL transport between the desktop app and +the chat runtime. Questions and retrieved context enter on stdin and must be treated as sensitive. The +transport emits protocol events only on stdout and is not exposed as a user workflow or a general +shell. stdout may carry the final answer and citation metadata, but never raw thought. stderr may +carry bounded operational diagnostics, but never raw prompts, retrieved passages, or raw thought. + +The app may expose `off`, `standard`, and `deep` thinking controls for Gemma profiles. The `lite` +profile must force `off`. It can show phase labels such as +retrieving, reasoning, and writing, but it must never display, persist, or log raw thought. Only the +user-visible question and final answer belong in local thread history. Citation markers must be +validated against the retrieved source list, while the UI continues to warn that a valid citation +does not guarantee a true interpretation. + For audio reports, `run_ragmir_command` writes the current retrieval report text under ignored `.ragmir/audio/` first, then passes that generated text file to `rgr audio --offline --json`. @@ -77,6 +110,8 @@ that folder. It does not add OAuth, Drive API calls, or provider credentials to ## Deferred Work - Native sidecar binary build pipeline. +- Android chat runtime and packaging. Core app work may continue on Android, but Gemma chat remains + desktop/CLI-only until the native path is implemented and verified. - Progress events for long ingests. - Signed macOS/Windows packaging. - Tauri updater wiring after release signing keys and update endpoint are ready. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0bca6cf..3ecd14d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ Ragmir ships three CLIs: -- `rgr`: the main local RAG, MCP, skills, security, and audio command. +- `rgr`: the main local RAG, MCP, skills, security, chat, and audio command. - `rgr-chat`: the standalone optional local chat add-on used by `rgr chat`. - `rgr-tts`: the standalone text-to-speech renderer used by `rgr audio`. @@ -20,18 +20,19 @@ Ragmir ships three CLIs: | `rgr sources add "../apps/*/docs/**/*.md"` | Add source paths, glob patterns, or `!` exclusions to the `sources` array in `.ragmir/config.json`. | | `rgr sources list` | List active extra source entries from `.ragmir/config.json`. | | `rgr ingest` | Parse changed source files, redact, chunk, embed, and update the local LanceDB index. | -| `rgr ingest --rebuild` | Force a full re-index, required after switching embedding provider or model. | -| `rgr audit` | Check whether supported source files are missing from or stale in the index. | +| `rgr ingest --rebuild` | Force a full re-index. Policy changes already trigger a safe automatic rebuild. | +| `rgr audit` | Check whether supported source files are missing or stale, and report exact-content duplicate and archive/mirror candidates. | | `rgr audit --unsupported` | List files skipped because they are unsupported, too large, or secret-like. | +| `rgr limits` | Show active per-file and parser safety limits plus unbounded file-count and corpus-size fields. | | `rgr search ""` | Retrieve ranked passages without asking an LLM to write an answer. | | `rgr ask ""` | Return cited retrieval context for an agent or trusted model runtime. | -| `rgr chat setup` | Download the optional local Transformers.js chat model into `.ragmir/models/chat`. | -| `rgr chat "" --offline` | Answer from retrieved Ragmir passages with the local chat add-on. | -| `rgr chat doctor --json` | Inspect optional local chat readiness without generating an answer. | +| `rgr chat setup [--profile lite\|fast\|quality]` | Explicitly download and verify a local GGUF. `lite` uses Qwen2.5 0.5B (491 MB), `fast` is the default Gemma 4 E2B profile (3.35 GB), and `quality` uses E4B (5.15 GB). | +| `rgr chat "" [--profile lite\|fast\|quality] [--thinking off\|standard\|deep] --offline` | Answer from retrieved Ragmir passages with a verified local model. Raw thought is never returned or persisted; `lite` always uses thinking `off`. | +| `rgr chat doctor [--profile lite\|fast\|quality] [--verify] --json` | Check runtime, expected manifest, file, and size. Add `--verify` for a full SHA-256 pass. | | `rgr research ""` | Run audit, security, multi-query retrieval, source diagnostics, and lightweight code matching for broad agent tasks. | | `rgr route-prompt "..."` | Classify a prompt and suggest whether an agent should use Ragmir local context. | -| `rgr evaluate --golden golden-queries.json` | Measure retrieval recall against expected source paths or exact citations. | -| `rgr security-audit` | Inspect privacy posture: telemetry, providers, redaction, Git ignore, MCP. | +| `rgr evaluate --golden golden-queries.json` | Measure hit rate, Recall@K, Precision@K, MRR, nDCG, and p50/p95 latency against expected paths or citations. | +| `rgr security-audit` | Inspect privacy posture: telemetry, providers, redaction, actual Git ignore semantics, permissions, and MCP. | | `rgr usage-report` | Summarize metadata-only local access-log activity for recent private dogfooding without query text or local paths. | | `rgr status` | Print raw config paths, provider settings, and indexed chunk count. | @@ -51,6 +52,30 @@ Ragmir ships three CLIs: | `rgr destroy-index --yes` | Delete generated `.ragmir/storage` index files. | | `rgr security-audit --strict` | Fail the command when privacy warnings are present. | +## Local Chat + +| Command | Use it when | +| --- | --- | +| `rgr chat setup` | Preload and verify the default `fast` Gemma 4 E2B profile. | +| `rgr chat setup --profile lite` | Preload and verify the 491 MB Qwen2.5 profile for older computers. | +| `rgr chat setup --profile quality` | Preload and verify the larger Gemma 4 E4B quality profile. | +| `rgr chat doctor --profile fast` | Run the normal runtime, manifest, file, and size readiness check. | +| `rgr chat doctor --profile fast --verify` | Recompute the full model SHA-256 after transfer or when integrity is in doubt. | +| `rgr chat "" --profile fast --thinking standard --offline` | Generate a cited local answer with bounded hidden reasoning. | +| `rgr-chat setup --profile ` | Invoke setup directly through the standalone add-on when maintaining or testing that package. | +| `rgr-chat doctor --profile [--verify] --json` | Inspect the standalone package directly. | +| `rgr-chat serve` | Start the persistent strict stdio JSONL transport used internally by the desktop app. This is not a user chat workflow. | + +Chat doctor JSON includes the detected `platform` and `arch`, the packaged `supportedBackends`, the +`selectedBackend` used by `gpu: "auto"`, and whether hardware acceleration is active. On Apple +Silicon this should normally report Metal; supported Linux/Windows installations may report CUDA or +Vulkan. Normal answers do not build or download a missing backend. + +The `rgr chat` commands are the supported user workflow and import the package API for one-shot +answers. `rgr-chat serve` accepts persistent protocol requests on stdin and writes protocol events to +stdout for desktop integration; wrappers must not mix logs with stdout. The transport does not +expose raw thought text. + ## Audio | Command | Use it when | @@ -59,8 +84,6 @@ Ragmir ships three CLIs: | `rgr audio /tmp/preload.txt --engine transformers --allow-remote-models --model-path .ragmir/models/tts --out .ragmir/audio/preload-check.wav` | Preload the TTS model with non-sensitive text. | | `rgr audio --engine transformers --offline --out .ragmir/audio/name.wav` | Render a confidential/offline WAV. | | `rgr audio --engine edge --out .ragmir/audio/name.mp3` | Render a higher-quality online Edge MP3. | -| `rgr-chat doctor --json` | Inspect the standalone chat package. | -| `rgr-chat setup --model-path .ragmir/models/chat` | Preload a Transformers.js chat model directly through the add-on. | | `rgr-tts doctor --json` | Inspect the standalone TTS package. | | `rgr-tts render --offline --out .ragmir/audio/name.wav` | Render directly through the TTS package. | @@ -74,17 +97,23 @@ Ragmir ships three CLIs: | `--mcp-command ` | `setup`, `install-skill` | Use a repository wrapper or custom executable as the generated MCP stdio command. | | `--mcp-arg ` | `setup`, `install-skill` | Add one argument to `--mcp-command`; repeat for multiple arguments. Use `--mcp-arg=--flag` for dash-prefixed values. | | `--semantic` | `setup` | Explicitly download the configured Transformers.js embedding model once, enable `embeddingProvider: "transformers"`, and keep remote model loading disabled for normal indexing. | +| `--profile ` | `chat`, chat setup/doctor, standalone chat setup/doctor | Select Qwen2.5 0.5B `lite` (491 MB), Gemma 4 E2B `fast` (default, 3.35 GB), or Gemma 4 E4B `quality` (5.15 GB). | +| `--thinking ` | `chat` | Select no, normal bounded, or larger bounded local reasoning. `lite` normalizes every value to `off`. Raw thought is never displayed, stored, or logged. | +| `--verify` | `chat doctor`, standalone chat doctor | Recompute the full GGUF SHA-256 and expose `modelHashValid`; use after transfer or when integrity is in doubt. | +| `--model-path ` | chat setup, doctor, and answers | Override the local chat model root. Each selected profile still requires its verified manifest and GGUF. | | `--top-k ` | `search`, `ask`, `chat`, `research`, `evaluate` | Number of passages to return or keep. | | `--context-radius ` | `search`, `ask` | Include neighboring chunks around each matched passage. MCP clamps this to 3 chunks on each side. | -| `--fail-under ` | `evaluate` | Exit non-zero only when recall is below a threshold from `0` to `1`; without this option evaluation remains strict and fails on any miss. | +| `--include-path ` | `search`, `ask`, `research` | Restrict retrieval to an exact project-relative path or directory prefix. Repeat for multiple roots. | +| `--exclude-path ` | `search`, `ask`, `research` | Remove an exact project-relative path or directory prefix before ranking. Repeat for multiple roots. | +| `--fail-under ` | `evaluate` | Exit non-zero when mean Recall@K is below a threshold from `0` to `1`; without this option evaluation remains strict and fails on any miss. | | `--days ` | `usage-report` | Number of recent days to include in the metadata-only usage summary. | -| `--json` | `setup`, `doctor`, `ingest`, `search`, `ask`, `chat`, `research`, `route-prompt`, `evaluate`, `audit`, `usage-report`, `status`, `security-audit`, `audio --doctor`, `rgr-chat doctor`, `rgr-tts doctor` | Print machine-readable JSON. | +| `--json` | `setup`, `doctor`, `ingest`, `search`, `ask`, `chat`, `research`, `route-prompt`, `evaluate`, `audit`, `limits`, `usage-report`, `status`, `security-audit`, `audio --doctor`, `rgr-chat doctor`, `rgr-tts doctor` | Print machine-readable JSON. | | `--compact` | `search`, `research` | Return short snippets instead of full retrieved passages. | | `--no-code` | `research` | Skip the lightweight repository code scan. | | `--unsupported` | `audit` | List skipped file paths and reasons. | | `--strict` | `security-audit` | Exit non-zero when warnings exist. | -| `--offline` | `chat`, `audio`, `rgr-chat answer`, `rgr-tts render` | Disable remote model downloads and force the local Transformers.js path. | -| `--allow-remote-models` | `chat`, `audio`, `rgr-chat answer`, `rgr-tts render` | Explicitly allow model downloads for Transformers.js. | +| `--offline` | `chat`, `audio`, `rgr-tts render` | For chat, require the verified local GGUF. For audio, force the local Transformers.js path. | +| `--allow-remote-models` | `audio`, `rgr-tts render` | Explicitly allow a Transformers.js TTS model download. Chat downloads occur only through explicit `chat setup`. | | `--engine edge` | `audio`, `rgr-tts render` | Use online Edge TTS for MP3 output. | | `--lang ` | `audio`, `rgr-tts render` | Select the TTS language. `en`, `es`, and `fr` have default offline models; `ja`, `th`, and `zh` use Edge voices unless `--model` supplies a compatible offline model. Default `fr`. | @@ -92,6 +121,15 @@ See [`offline-chat-preload.md`](./offline-chat-preload.md) and [`offline-tts-preload.md`](./offline-tts-preload.md) before using `--offline` on a fully air-gapped machine. +`evaluate` without `--fail-under` is observational and returns success even when some expected +passages are missed. Add `--fail-under ` only when the command should act as a CI or private +dogfooding quality gate. + +Duplicate diagnostics use matching SHA-256 content, not filenames. In a Git checkout, +`security-audit` asks Git whether configured generated paths are ignored, so glob rules, ancestor +rules, negations, and tracked-file behavior are respected. Outside Git it uses a conservative local +pattern fallback. + `rgr setup` ends with an English prompt between copy markers. Paste it into an AI assistant or local chat to ask for repository-specific `sources` recommendations while excluding secrets, generated files, dependency folders, caches, and unnecessary locale noise. @@ -108,8 +146,9 @@ For scanned/image-only PDFs, add a local wrapper that prints OCR text to stdout: } ``` -Or set `RAGMIR_PDF_OCR_COMMAND` to a JSON array. Ragmir only invokes it for PDFs where embedded-text -extraction returns no text. When a supported document still yields no indexable text, +Or set `RAGMIR_PDF_OCR_COMMAND` to a JSON array. Ragmir invokes it only for pages where embedded-text +extraction returns no text. PDF wrappers also receive `RAGMIR_PDF_PAGE` and may use `{page}` in an +argument. When a supported document still yields no indexable text, `rgr ingest --json` reports the relative paths under `emptyTextFiles`. Standalone image files such as `.png`, `.jpg`, `.heic`, and `.tiff` are skipped by default. To index @@ -123,9 +162,10 @@ them directly, configure an explicit local image OCR wrapper: ``` Or set `RAGMIR_IMAGE_OCR_COMMAND` to a JSON array. Image files become supported only when this command -is configured. OCR commands are executed from the target project root without a shell, receive -`RAGMIR_PDF_PATH` or `RAGMIR_IMAGE_PATH`, replace `{input}` placeholders with the source path, and -must print UTF-8 text to stdout. Keep OCR tooling local for confidential documents. `rgr audit +is configured. OCR commands are executed from the target project root without a shell and with a +minimal environment allowlist. They receive `RAGMIR_PDF_PATH` or `RAGMIR_IMAGE_PATH`, replace +`{input}` placeholders with the source path, and must print UTF-8 text to stdout. The `strict` +privacy profile disables external extractors. Keep OCR tooling local for confidential documents. `rgr audit --unsupported` prints per-file recommendations for image, audio, video, oversized, and secret-like skipped files. @@ -168,7 +208,21 @@ citations such as `docs/policy.md:L4-L8#2` after a schema v2 rebuild. Use `expectedPaths` for file-level `recall@k`, and add `expectedCitations` when the benchmark must verify exact `relative/path:Lx-Ly#chunkIndex` citations. Older indexes without line metadata fall back to `relative/path#chunkIndex` until they are rebuilt. When citations are present, a query only counts -as a hit if the expected citation is retrieved. +as a hit if the expected citation is retrieved. A query may also set `includePaths` and +`excludePaths` to benchmark a specific evidence tier: + +```json +{ + "queries": [ + { + "query": "Which primary source supports the finding?", + "expectedPaths": [".ragmir/raw/primary/report.pdf"], + "includePaths": [".ragmir/raw/primary"], + "excludePaths": [".ragmir/raw/research"] + } + ] +} +``` Use the default strict behavior for synthetic examples and release checks: @@ -184,8 +238,12 @@ rgr --project-root /path/to/workspace evaluate --golden .ragmir/evaluations/gold ``` The JSON output includes `embeddingProvider` and `embeddingModel`. Use those fields when comparing a -default local-hash run with a private Transformers semantic run. It also includes -`meanReciprocalRank` and `ndcg` so benchmark regressions can distinguish late-but-present hits from -top-ranked evidence. +default local-hash run with a private Transformers semantic run. Per-case and aggregate output +separates hit rate, true Recall@K, Precision@K, `meanReciprocalRank`, bounded `ndcg`, and p50/p95 +latency so regressions can distinguish incomplete, late, and slow retrieval. + +`rgr usage-report` keeps the legacy overall result-count average and also reports +`averageResultCountByAction`, so ingest chunk counts no longer obscure search, ask, research, and +evaluation behavior. Fresh setup and docs use a single `.ragmir/` project folder. diff --git a/docs/configuration.md b/docs/configuration.md index b0a7600..5586347 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,13 +9,17 @@ The project config lives in `.ragmir/config.json` in the repository being indexe | Field | Default | Purpose | | --- | --- | --- | +| `privacyProfile` | `private` | Privacy floor: `strict`, `private`, `trusted`, or `custom`. | +| `retrievalProfile` | `balanced` | Retrieval latency/recall preset: `fast`, `balanced`, `quality`, or `custom`. | +| `acceptedRisks` | `[]` | Documented risk identifiers. They are reported by doctor and do not disable safeguards or warnings. | | `rawDir` | `.ragmir/raw` | Local corpus folder, indexed recursively. | | `sources` | `[]` | Extra files, directories, glob patterns, or `!` exclusions to index from the project root. | | `storageDir` | `.ragmir/storage` | LanceDB vector store location. | | `accessLogPath` | `.ragmir/access.log` | Metadata-only access log path. | | `embeddingModelPath` | `.ragmir/models` | Local cache for Transformers.js embedding files. | | `embeddingProvider` | `local-hash` | `local-hash` for fully local lexical retrieval, or `transformers` for semantic embeddings. | -| `embeddingModel` | `mixedbread-ai/mxbai-embed-xsmall-v1` | Model used when `embeddingProvider` is `transformers`. | +| `embeddingModel` | `intfloat/multilingual-e5-small` | Multilingual model used when `embeddingProvider` is `transformers`. | +| `embeddingModelRevision` | `main` | Hugging Face revision used for model loading. Pin an immutable revision for reproducible deployments. | | `transformersAllowRemoteModels` | `false` | Allows model downloads at runtime. Keep false for confidential indexing. | | `redaction.enabled` | `true` | Redacts secrets and identifiers before indexing. | | `redaction.patterns` | `[]` | Extra `{ name, pattern, flags?, replacement? }` redaction rules. | @@ -27,10 +31,56 @@ The project config lives in `.ragmir/config.json` in the repository being indexe | `maxFileBytes` | `50000000` | Per-file size cap. Larger files are skipped and reported. | | `ingestConcurrency` | `4` | Files processed in parallel during ingest. | | `embeddingBatchSize` | `32` | Chunks embedded per batch. | +| `hybridTextScanLimit` | `5000` | Maximum lexical fallback scan when a usable FTS index is unavailable. | | `includeExtensions` | `[]` | Extra UTF-8 text extensions to index. | | `pdfOcrCommand`, `imageOcrCommand`, `legacyWordCommand` | `[]` | Opt-in local extractors. | | `pdfOcrTimeoutMs`, `imageOcrTimeoutMs`, `legacyWordTimeoutMs` | `120000` | Extractor timeouts. | +## Ingestion And Corpus Limits + +Run `rgr limits` or `rgr limits --json` to inspect the effective values. Ragmir intentionally has no +hard file-count or total-corpus-byte ceiling, so JSON reports `maxFiles: null` and +`maxCorpusBytes: null`. Practical capacity depends on available disk and memory, document parsing, +embedding throughput, index size, and exact flat-vector search latency. Benchmark the target machine +and corpus instead of treating the absence of a hard ceiling as unlimited performance. + +| Boundary | Default or hard limit | Behavior | +| --- | ---: | --- | +| Source file size | 50,000,000 bytes, configurable with `maxFileBytes` | Larger files are skipped before parsing and reported by ingest, audit, and doctor. | +| PDF pages | 1000 | Parsing fails rather than processing an unbounded document. | +| PDF extracted text | 25,000,000 characters | Parsing fails when the safety bound is exceeded. | +| Office/archive text entries | 512 | Extra archive entries are not processed silently. | +| One Office XML text entry | 25,000,000 bytes | Parsing fails on an oversized entry. | +| Total Office XML text | 50,000,000 bytes | Parsing fails on excessive extracted XML text. | +| External extractor stdout or stderr | 25,000,000 bytes | The extractor is terminated and ingestion reports an error. | + +Doctor treats missing, stale, empty-text, and oversized supported coverage as incomplete. Inspect +`rgr audit --unsupported`, configure a local extractor, split or convert the source, or raise +`maxFileBytes` only after reviewing local parsing and memory risk. + +## Privacy And Retrieval Profiles + +Privacy and retrieval are independent axes. For example, `strict` plus `quality` maximizes local +retrieval quality under the strict privacy floor, while `trusted` plus `fast` favors latency in an +already trusted environment. + +| Privacy profile | Effective policy | +| --- | --- | +| `strict` | Applies after environment overrides. Remote Transformer model loading stays disabled, built-in redaction stays enabled, MCP `topK` is capped at 5, external OCR/legacy extractors are disabled, MCP output is compact by default, paths are project-relative, and research does not scan repository code outside retrieval sources. | +| `private` | Recommended default. Local models, redaction, bounded MCP retrieval, private filesystem modes, and explicit local extractors remain configurable. Security warnings block `doctor.ready`. | +| `trusted` | Allows explicitly trusted deployments to disable redaction or allow remote model loading without those choices becoming privacy warnings. The effective settings remain visible in status and security reports. | +| `custom` | Preserves field-level control without adding a profile floor. Security warnings still describe unsafe effective settings. | + +| Retrieval profile | Default `topK` | Lexical fallback scan | Behavior | +| --- | ---: | ---: | --- | +| `fast` | 5 | 2000 | Smaller candidate pools and at most one result chunk per source. | +| `balanced` | 8 | 5000 | Default compromise between latency, diversity, and recall. | +| `quality` | 12 | 10000 | Larger candidate pools, up to four chunks per source, and one adjacent context chunk by default. | +| `custom` | 8 | 5000 | Uses explicit field values and balanced candidate behavior. | + +Explicit `topK` and `hybridTextScanLimit` values override the retrieval profile defaults. The strict +privacy floor cannot be weakened by environment variables. + ## Source Paths Ragmir always indexes `rawDir`. Add other local files with `sources`: @@ -62,6 +112,7 @@ Use environment variables for machine-specific paths or CI experiments: - `RAGMIR_ACCESS_LOG_PATH` - `RAGMIR_EMBEDDING_PROVIDER` - `RAGMIR_EMBEDDING_MODEL` +- `RAGMIR_EMBEDDING_MODEL_REVISION` - `RAGMIR_EMBEDDING_MODEL_PATH` - `RAGMIR_TRANSFORMERS_ALLOW_REMOTE_MODELS` - `RAGMIR_REDACTION_ENABLED` @@ -74,6 +125,7 @@ Use environment variables for machine-specific paths or CI experiments: - `RAGMIR_MAX_FILE_BYTES` - `RAGMIR_INGEST_CONCURRENCY` - `RAGMIR_EMBEDDING_BATCH_SIZE` +- `RAGMIR_HYBRID_TEXT_SCAN_LIMIT` - `RAGMIR_INCLUDE_EXTENSIONS` - `RAGMIR_PDF_OCR_COMMAND` - `RAGMIR_PDF_OCR_TIMEOUT_MS` @@ -117,12 +169,13 @@ or conversion before indexing. `rgr audit --unsupported` prints per-file recomme ## External Extractors -Extractors are opt-in and run without a shell from the target project root. They must print UTF-8 -text to stdout. +Extractors are opt-in and run without a shell from the target project root. They receive a minimal +environment allowlist, must print UTF-8 text to stdout, and are terminated with an escalation when +their timeout expires. The `strict` privacy profile disables them. | Need | Config field | Environment path variable | | --- | --- | --- | -| Scanned/image-only PDF OCR | `pdfOcrCommand` | `RAGMIR_PDF_PATH` | +| Blank PDF page OCR | `pdfOcrCommand` | `RAGMIR_PDF_PATH`, `RAGMIR_PDF_PAGE` | | Direct image OCR | `imageOcrCommand` | `RAGMIR_IMAGE_PATH` | | Old `.doc` Word extraction | `legacyWordCommand` | `RAGMIR_LEGACY_WORD_PATH` | @@ -136,3 +189,32 @@ Example: ``` Keep extractor tooling local when documents are confidential. + +PDF extraction is page-aware. Embedded text is extracted sequentially, only blank pages use the +optional OCR command, and citations include the page, for example `brief.pdf:p2:L4-L8#3`. A PDF is +limited to 1000 pages and 25 million extracted characters. Use `{page}` in a PDF OCR argument when +the wrapper can process one page at a time. Ragmir does not reconstruct arbitrary visual columns or +tables and does not claim universal scanned-PDF support. + +Chunking is character-bounded but structure-aware. It prefers paragraph breaks, then Latin or CJK +sentence endings, then line boundaries for code, lists, and tables, before falling back to whitespace +or the hard character limit. `chunkOverlap` still applies after the selected boundary. + +## Index Policy And Incremental Ingestion + +The index manifest fingerprints the embedding provider, model and revision, chunking settings and +adapter version, redaction, extractor configuration, parser version, and index schema. A policy +change triggers a safe full rebuild; search refuses a stale or incompatible index. + +Normal ingestion hashes source files with bounded concurrency, reuses unchanged manifest entries, +deletes only removed or replaced paths, and inserts only changed chunks. A no-op ingestion leaves +the LanceDB table version unchanged. Ragmir uses exact flat vector search by default. Approximate +IVF-PQ indexing is not enabled automatically because it can reduce recall on small and medium +corpora. + +Source diagnostics report duplicate candidates only when file contents have the same SHA-256. Files +that merely share a common basename such as `README.md` or `config.json` are not duplicates. + +Retrieval diversity suppresses duplicate text and overlapping character spans from the same source +before applying the profile's per-source result limit. Distinct non-overlapping passages from one +document remain eligible, while chunk overlap cannot consume multiple top-K slots. diff --git a/docs/offline-chat-preload.md b/docs/offline-chat-preload.md index 67abd90..9a0a87e 100644 --- a/docs/offline-chat-preload.md +++ b/docs/offline-chat-preload.md @@ -1,60 +1,215 @@ -# Offline Chat Preload +# Offline Local Chat Preload -`rgr chat` uses the optional `@jcode.labs/ragmir-chat` add-on. Ragmir Core still returns retrieval -context only; the add-on takes the retrieved passages, builds a cited prompt, and runs a local -Transformers.js text-generation model. +`rgr chat` uses the optional `@jcode.labs/ragmir-chat` add-on. Ragmir Core remains retrieval-only: +it returns cited passages, while the add-on runs verified Qwen2.5 or Google Gemma 4 GGUF models +locally through `node-llama-cpp` 3.19. -The default model is `onnx-community/Qwen2.5-0.5B-Instruct` with q4 weights. It is small enough for -local experiments, but it is still a local LLM: expect slower CPU answers and validate important -outputs against the cited passages. +Ragmir Chat is available for desktop and CLI workflows. Android chat is deferred until the native +runtime and packaging path are implemented and verified. The current path requires no Ollama server, +Python runtime, or hosted LLM API. -## One-Time Setup +## Platform Acceleration -Run this from the target repository after `rgr setup` and `rgr ingest`: +Ragmir uses one verified GGUF per profile across desktop platforms. `node-llama-cpp` selects the +best compatible backend already installed for the current operating system and architecture: + +| Platform | Preferred backend | Offline package behavior | +| --- | --- | --- | +| macOS Apple Silicon | Metal | Uses the prebuilt `mac-arm64-metal` binding. | +| macOS Intel | CPU with Accelerate | Metal is not enabled by default because llama.cpp support is limited on Intel Macs. | +| Linux x64 with NVIDIA | CUDA | Uses CUDA when the matching driver and prebuilt binding are available. | +| Linux x64 with AMD or Intel GPU | Vulkan | Uses Vulkan when the driver and prebuilt binding are available. | +| Windows x64 with NVIDIA | CUDA | Uses CUDA when the matching driver and prebuilt binding are available. | +| Windows x64 with AMD or Intel GPU | Vulkan | Uses Vulkan when the driver and prebuilt binding are available. | +| Other supported machines | CPU | Uses the platform CPU binding when that binding is installed. | + +Normal answers keep `build: "never"` and `skipDownload: true`. Ragmir therefore never compiles or +downloads a different native backend while answering confidential questions. `rgr chat doctor +--json` reports the actual `platform`, `arch`, `supportedBackends`, `selectedBackend`, and +`hardwareAcceleration` values. Do not infer a fallback that doctor does not report. + +### Why MLX Is Not A Second Default On Mac + +[MLX-LM](https://github.com/ml-explore/mlx-lm) is optimized for Apple Silicon and supports Gemma 4, +but its maintained runtime is a Python package. Adding it would break Ragmir Chat's no-Python +runtime boundary and require a separate MLX Safetensors download. + +[MLX Swift LM 3.31.3](https://github.com/ml-explore/mlx-swift-lm/releases/tag/3.31.3) introduced +native Gemma 4 E2B/E4B support. It remains a future Mac-only benchmark candidate, not a +production backend in this tranche: Ragmir would need a Swift bridge, a second verified model +manifest, streaming/cancellation parity, thought-segment filtering, and citation regression tests. +The portable GGUF path already uses Metal on Apple Silicon and preserves one model supply chain for +macOS, Linux, and Windows. Upstream still tracks separate Gemma 4 gaps for speculative drafting and +larger variants in [issue #282](https://github.com/ml-explore/mlx-swift-lm/issues/282). + +## Choose A Profile + +| Profile | Model | Download size | Use it when | +| --- | --- | ---: | --- | +| `lite` | Qwen2.5 0.5B Instruct Q4_K_M GGUF | 491 MB | The computer is old, CPU-only, or memory constrained. | +| `fast` | Gemma 4 E2B QAT GGUF | 3.35 GB | You want the default, lighter local chat path. | +| `quality` | Gemma 4 E4B QAT GGUF | 5.15 GB | The computer has enough storage and memory for stronger local synthesis. | + +The sizes above are model-file download sizes, not runtime memory guarantees. Actual speed and memory +use depend on the computer, context size, and selected thinking mode. `lite` uses a 4,096-token +context, defaults to 256 visible answer tokens, caps generation at 512 tokens, and forces thinking +off. Those limits reduce memory and CPU time but also reduce synthesis quality. + +The built-in profiles pin immutable Hugging Face revisions rather than following `main`: + +- `lite` Qwen2.5: `9217f5db79a29953eb74d5343926648285ec7e67`; +- `fast` E2B: `69536a21d70340464240401ba38223d805f6a709`; +- `quality` E4B: `7edc6763a77bbca236126a361613b834c5ea0f7a`. + +## Run The Explicit Setup + +Run setup from the target repository after `rgr setup` and `rgr ingest`: ```bash rgr chat setup ``` -This downloads the configured model into `.ragmir/models/chat`. The command is explicit setup, so it -allows remote model loading for that preload. Normal answers keep remote model loading disabled. +The default command selects the `fast` profile. These two commands are equivalent: -To preload a different Transformers.js-compatible model: +```bash +rgr chat setup +rgr chat setup --profile fast +``` + +Select the larger model explicitly when local quality matters more than footprint: ```bash -rgr chat setup \ - --model onnx-community/Qwen2.5-0.5B-Instruct \ - --model-path .ragmir/models/chat +rgr chat setup --profile quality ``` -## Offline Answer +Select the ultra-light model explicitly on an older computer: + +```bash +rgr chat setup --profile lite +``` + +Setup is the only normal chat workflow that downloads a model. It stores each profile separately: + +```plain text +.ragmir/models/chat/lite/ +.ragmir/models/chat/fast/ +.ragmir/models/chat/quality/ +``` + +Each profile directory contains the GGUF and a `manifest.json` with these fields: + +| Field | Meaning | +| --- | --- | +| `schemaVersion` | Manifest schema version, currently `1`. | +| `provider` | Local provider, `node-llama-cpp`. | +| `runtimeVersion` | Pinned runtime contract, `3.19.0`. | +| `profile` | `lite`, `fast`, or `quality`. | +| `modelId`, `revision` | Model identity and immutable Hugging Face revision. | +| `modelUri`, `downloadUrl` | Pinned model source used by explicit setup. | +| `sourceUrl` | Official Hugging Face repository for the selected profile. | +| `license`, `licenseUrl` | `Apache-2.0` and the official license URL for the selected model. | +| `fileName` | GGUF path relative to the profile directory. | +| `bytes`, `sha256` | Expected exact file size and SHA-256 digest. | +| `verifiedAt` | Time at which setup completed integrity verification. | + +The manifest never stores an absolute project path. + +Setup must finish the download, verify the exact size and SHA-256 digest, and write the manifest +before it reports the profile ready. An interrupted, truncated, or mismatched file is not ready. + +## Verify Readiness -After setup, answer from the local index without downloading model files: +Check the default profile without generating an answer: + +```bash +rgr chat doctor +``` + +Check a specific profile or request machine-readable output: + +```bash +rgr chat doctor --profile lite +rgr chat doctor --profile fast +rgr chat doctor --profile quality --verify --json +``` + +Normal doctor checks the `node-llama-cpp` runtime, the expected pinned manifest, model-file existence, +and exact byte size. It does not hash a multi-gigabyte model on every readiness check. Add `--verify` +for a full SHA-256 pass; JSON output then exposes `modelHashValid`. Use the full verification after a +transfer or when file integrity is in doubt. The same report names the native compute backend that +normal generation will use. + +## Generate An Offline Answer + +After setup, normal answers use only the verified local GGUF. The explicit `--offline` flag also +documents that network access is not allowed for the answer: ```bash rgr chat "Which evidence supports offline operation?" --offline ``` -`rgr chat` first retrieves Ragmir passages with `rgr search` semantics, then asks the local model to -answer only from that context and cite sources as `[1]`, `[2]`, and so on. +Choose the amount of local reasoning with `--thinking`: + +```bash +rgr chat "Question" --profile lite --thinking off --offline +rgr chat "Question" --profile fast --thinking off --offline +rgr chat "Question" --profile fast --thinking standard --offline +rgr chat "Question" --profile quality --thinking deep --offline +``` + +- `off` skips the reasoning budget for the quickest path. +- `standard` is the normal bounded reasoning mode. +- `deep` allocates a larger bounded reasoning budget and can take longer. + +The `lite` profile always normalizes thinking to `off`, even if a caller requests another mode. + +Raw thought text is never displayed, returned, stored, or logged. Only user-visible messages, the +question and final answer, may enter local chat history. Ragmir validates citation markers against +the retrieved source list before returning the answer, but a valid citation does not prove that the +model interpreted the passage correctly. Review important claims against the cited source text. -Useful options: +Other useful options remain available: ```bash rgr chat "Question" --top-k 5 rgr chat "Question" --max-new-tokens 256 rgr chat "Question" --context-limit 6000 -rgr chat doctor --json ``` -## Air-Gapped Use +## Prepare An Air-Gapped Machine -For a fully offline machine, copy the already populated `.ragmir/models/chat` directory into the -target repository or equivalent model path, then run: +On an internet-connected preparation machine, run setup for every profile the offline machine needs: ```bash -rgr chat doctor -rgr chat "Question" --offline --model-path .ragmir/models/chat +rgr chat setup --profile lite +rgr chat setup --profile fast +rgr chat setup --profile quality ``` -Do not commit `.ragmir/models/chat`; `.ragmir/` is local generated state and should stay ignored. +Copy the complete selected profile directory, including its GGUF and `manifest.json`, into the same +ignored `.ragmir/models/chat//` path on the offline machine. Then verify it locally: + +```bash +rgr chat doctor --profile lite --verify +rgr chat "Question" --profile lite --thinking off --offline +rgr chat doctor --profile fast --verify +rgr chat "Question" --profile fast --thinking standard --offline +``` + +Do not copy a partial download or recreate the manifest by hand. Doctor must verify the transferred +file against the recorded size and SHA-256 digest. + +## Internal Transport + +`rgr-chat serve` is the persistent strict stdio JSONL transport used internally by desktop +integration. Requests enter through stdin and protocol events leave through stdout. It is not the +user chat workflow; use `rgr chat ...` for setup, diagnosis, and answers. Core `rgr chat` imports the +package API for a one-shot answer and does not require this server. Internal thought text is never +exposed as a transport event. + +## Files And Licenses + +Keep `.ragmir/models/chat/` ignored and never commit GGUF files or their local manifests. Ragmir's +tracked source remains MIT-licensed. The downloaded Gemma 4 weights are separate Apache-2.0 assets +and are not part of the Ragmir source package. The Qwen2.5 `lite` weights are also separate +Apache-2.0 assets. Every manifest records the pinned official source and license URL. diff --git a/docs/source-boundary.md b/docs/source-boundary.md index 054ad92..300a06f 100644 --- a/docs/source-boundary.md +++ b/docs/source-boundary.md @@ -22,6 +22,28 @@ The repository intentionally contains: The `private: true` flag in some `package.json` files means "not published to npm", not "private source". It does not override the repository license. +## Third-Party Model Assets + +Ragmir Chat source is part of the MIT repository. The Qwen2.5 and Gemma 4 model weights downloaded by +`rgr chat setup` are separate Apache-2.0 assets and are not part of the Ragmir source package. Each +manifest records the official Hugging Face source URL and the +license URL pinned for that model. + +Keep each selected profile under ignored local state: + +```plain text +.ragmir/models/chat/fast/ +.ragmir/models/chat/quality/ +.ragmir/models/chat/lite/ +``` + +The local directory contains the GGUF and a generated integrity manifest with the model revision, +official source and license URLs, relative filename, exact byte size, and SHA-256. Neither the model +nor its manifest should be committed. The manifest must not contain an absolute project path. + +The current local chat runtime is a desktop and CLI feature. Android support remains future work and +must not be inferred from the presence of the cross-platform app source. + ## Commercial Distribution Boundary Commercial value can exist around this open source code, but not as hidden proprietary source inside @@ -61,7 +83,8 @@ Keep the public repository limited to: Keep outside Git: - private documents and client corpora; -- `.ragmir/`, `.pid`, raw reports, audio files, vector stores, and generated local state; +- `.ragmir/`, `.pid`, raw reports, audio files, vector stores, generated model manifests, GGUF files, + and other generated local state; - API keys, webhook secrets, signing keys, certificates, and environment files; - customer names, emails, invoices, order exports, and support evidence; - internal pricing tests, pre-sales ledgers, interview notes, and GO/NO-GO records. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 69dcacd..987e55a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -56,7 +56,7 @@ OCR/transcribe them, or add a safe custom UTF-8 text extension with `includeExte ## Scanned PDFs Or Images Produce No Text -Ragmir extracts embedded PDF text by default. For scanned/image-only PDFs, configure an explicit local +Ragmir extracts embedded PDF text page by page. For blank scanned pages, configure an explicit local OCR wrapper that prints UTF-8 text to stdout: ```json @@ -66,9 +66,10 @@ OCR wrapper that prints UTF-8 text to stdout: } ``` -The command runs only when normal PDF extraction returns no text. It is executed without a shell, -receives `RAGMIR_PDF_PATH`, and may use `{input}` in its arguments for the PDF path. Keep OCR tooling -local for confidential documents. +The command runs only for pages where embedded extraction returns no text. It is executed without a +shell and with a minimal environment, receives `RAGMIR_PDF_PATH` and `RAGMIR_PDF_PAGE`, and may use +`{input}` and `{page}` in its arguments. The `strict` privacy profile disables external extractors. +Keep OCR tooling local for confidential documents. Standalone image files such as `.png`, `.jpg`, `.heic`, and `.tiff` are skipped by default. To index them directly, configure an explicit local image OCR wrapper: @@ -116,7 +117,8 @@ usually better than the default lexical/hash mode. ```json { "embeddingProvider": "transformers", - "embeddingModel": "mixedbread-ai/mxbai-embed-xsmall-v1", + "embeddingModel": "intfloat/multilingual-e5-small", + "embeddingModelRevision": "main", "embeddingModelPath": ".ragmir/models", "transformersAllowRemoteModels": false } @@ -156,9 +158,32 @@ Or let doctor perform the safe incremental update: npx rgr doctor --fix ``` -Ragmir incrementally reuses unchanged indexed rows on normal `rgr ingest`. Use `rgr ingest --rebuild` -after switching embedding provider/model, after changing chunking settings, or when you want to -discard and recreate the whole local index. +Ragmir incrementally reuses unchanged files and mutates only removed or replaced paths on normal +`rgr ingest`. A no-op does not create a new LanceDB table version. Index-policy changes trigger a +safe full rebuild automatically. Use `rgr ingest --rebuild` when you intentionally want to discard +and recreate an otherwise compatible index. + +## Doctor Reports Incomplete Coverage + +Doctor keeps `ready=false` when supported sources are missing, stale, oversized, or produced no +indexable text. Start with: + +```bash +npx rgr limits +npx rgr audit --unsupported +npx rgr ingest --json +``` + +Split or convert oversized files, configure an approved local OCR/extractor for empty scans, and +re-ingest. Ragmir has no hard file-count or total-corpus-byte ceiling, but that does not guarantee +acceptable performance: benchmark ingestion and retrieval on the target corpus and machine. + +If research notes or duplicate mirrors dominate results, constrain the evidence tier before ranking: + +```bash +npx rgr search "primary finding" --include-path ".ragmir/raw/primary" +npx rgr research "remaining gaps" --exclude-path ".ragmir/raw/research" --exclude-path ".ragmir/raw/archive" +``` ## `security-audit --strict` Fails @@ -168,8 +193,9 @@ Read the warning lines. Common causes: - generated local state is not ignored by Git. - Redaction was disabled. - Transformers.js remote model loading was enabled. +- An existing config, raw directory, index directory, or access log exposes group/other POSIX bits. -Run the safe repair command if Git ignore entries are missing: +Run the safe repair command if Git ignore entries or local modes need repair: ```bash npx rgr doctor --fix @@ -233,23 +259,83 @@ npx rgr-tts render /tmp/ragmir-tts-preload.txt \ The full workflow is documented in [`offline-tts-preload.md`](./offline-tts-preload.md). -## `rgr chat --offline` Cannot Answer +## `rgr chat doctor` Reports The Model Is Not Ready -Offline chat requires model files to already exist under `.ragmir/models/chat` or the path passed -with `--model-path`. +Run doctor for the profile you intend to use: + +```bash +npx rgr chat doctor --profile lite +npx rgr chat doctor --profile fast +npx rgr chat doctor --profile quality --verify --json +``` + +The `lite` profile requires the 491 MB Qwen2.5 0.5B GGUF. The default `fast` profile requires the +3.35 GB Gemma 4 E2B GGUF, and `quality` requires the 5.15 GB Gemma 4 E4B GGUF. Normal doctor verifies all of the following before reporting the profile +ready: + +- the `node-llama-cpp` 3.19 runtime is available; +- `.ragmir/models/chat//manifest.json` exists and selects the requested profile; +- the manifest refers to a relative GGUF path, not an absolute project path; +- the GGUF exists and has the exact recorded byte size. -For a first online setup, run: +Normal doctor intentionally avoids hashing the selected model on every readiness refresh. Add +`--verify` to recompute the full SHA-256. JSON output exposes the result as `modelHashValid`. + +If the manifest or GGUF is missing, run explicit setup: ```bash -npx rgr chat setup +npx rgr chat setup --profile lite +npx rgr chat setup --profile fast ``` -Then reuse the cached files with: +Use `--profile lite` on an older or low-memory computer and `--profile quality` only when you +intentionally want the larger model. The lite profile forces thinking off and produces shorter, +lower-quality synthesis. If size validation or a +full `--verify` SHA-256 check fails, the file is incomplete or does not match the expected artifact. +Run setup again on a trusted network rather than editing the manifest or bypassing verification. + +## `rgr chat --offline` Cannot Answer + +Normal chat answers never download a model. They require a profile that already passes doctor: + +```bash +npx rgr chat doctor --profile fast +npx rgr chat "Which evidence supports offline operation?" --profile fast --thinking standard --offline +``` + +If `rgr chat` returns no context, the model is not the first problem to solve. Run +`npx rgr doctor --fix`, then inspect retrieval directly: ```bash -npx rgr chat "Which evidence supports offline operation?" --offline +npx rgr search "Which evidence supports offline operation?" ``` -If `rgr chat` returns no context, run `npx rgr doctor --fix` and try `npx rgr search ""` to -confirm that the relevant passages are indexed. The full chat preload workflow is documented in +If the `quality` profile cannot start because the computer lacks local resources, prepare and use the +default `fast` profile. Runtime speed and memory use vary with hardware, context size, and thinking +mode; the documented 3.35 GB and 5.15 GB values are model-file download sizes. + +Ragmir Chat currently supports desktop and CLI workflows only. Android chat is future work; do not +try to fix an Android failure by installing Ollama, Python, or a hosted model API. + +## Chat Does Not Show Raw Thinking + +This is intentional. `--thinking off`, `standard`, and `deep` control bounded local reasoning, but +raw thought text is never displayed, returned, persisted, or written to logs. Only the user-visible +question and final answer may be retained in local chat history. + +`rgr-chat serve` is the persistent internal stdio JSONL transport for desktop integration. Do not +launch it as the user chat interface; use `rgr chat ...` and `rgr chat doctor`. Core `rgr chat` uses +the package API directly for one-shot answers. + +## A Cited Chat Answer Is Still Wrong + +Ragmir validates generated citation markers against the passages retrieved for that answer. This +prevents references to nonexistent entries in the supplied source list, but it does not guarantee +that Gemma interpreted a real passage correctly or that the source itself is true. + +Open the cited files, compare the claim with the cited lines or chunks, and use `rgr search` with a +more specific query when the evidence is ambiguous. Important legal, financial, medical, security, +or operational claims still require appropriate human or professional review. + +The full setup and air-gapped transfer workflow is documented in [`offline-chat-preload.md`](./offline-chat-preload.md). diff --git a/llms.txt b/llms.txt index 0bec4c2..e3940aa 100644 --- a/llms.txt +++ b/llms.txt @@ -10,8 +10,11 @@ secrets and PII before anything is embedded, and serves cited passages through a an MCP server (`rgr serve-mcp`), a TypeScript library, and portable skills for Claude Code, Codex, Kimi Code CLI, OpenCode, and Cline. It does not perform LLM answer synthesis itself; `rgr ask` returns cited passages only, and synthesis stays with the calling agent or model. Ragmir Chat -(`@jcode.labs/ragmir-chat`, npm) is the optional local Transformers.js cited-chat add-on, and Ragmir -TTS (`@jcode.labs/ragmir-tts`, npm) renders offline audio summaries. +(`@jcode.labs/ragmir-chat`, npm) is the optional desktop/CLI cited-chat add-on: it runs verified +Gemma 4 QAT GGUF profiles through `node-llama-cpp` 3.19, with E2B `fast` by default, opt-in E4B +`quality`, explicit setup, offline normal answers, and raw thought hidden and unpersisted. It requires no +Ollama, Python, or hosted LLM API. Ragmir TTS (`@jcode.labs/ragmir-tts`, npm) renders offline audio +summaries. ## Docs diff --git a/packages/ragmir-app/README.md b/packages/ragmir-app/README.md index 4f7371b..213f45e 100644 --- a/packages/ragmir-app/README.md +++ b/packages/ragmir-app/README.md @@ -46,10 +46,39 @@ surface. In local native runs, set `RAGMIR_CLI_BIN` when the `rgr` binary is not [`../../docs/app-sidecar-architecture.md`](../../docs/app-sidecar-architecture.md). The current shell consumes JSON from `rgr doctor`, `rgr status`, `rgr ingest`, -`rgr ask`, `rgr security-audit`, `rgr models pull --enable`, and offline `rgr audio` for +`rgr search`, `rgr security-audit`, `rgr models pull --enable`, and offline `rgr audio` for project status, cited retrieval, privacy posture, explicit semantic model setup, Markdown reports, and local audio report rendering. +## Local Chat Runtime + +The desktop chat path retrieves citations with `rgr search` using only the latest user question, +then sends those sources plus recent visible user/assistant messages to a persistent local +`rgr-chat serve --profile --offline` process. The default UX is the `fast` Gemma 4 +E2B profile with `standard` thinking. The `quality` Gemma 4 E4B profile and `deep` thinking are +explicit opt-ins. The `lite` Qwen2.5 0.5B profile is a 491 MB option for older computers; it uses a +smaller context and always disables thinking. + +The Tauri bridge uses newline-delimited JSON over the child process stdin/stdout and a Tauri +`Channel` for real frontend streaming. The server contract is: + +- requests: `generate`, `cancel`, and `shutdown`; +- events: `loading`, `reasoning`, `delta`, `completed`, `cancelled`, and `error`; +- every event carries the target generation `id` so the native bridge can route it to the correct + channel; +- `reasoning` exposes only active state and a token count. Thought text must never cross the bridge, + appear in the UI, or be written to local chat storage. + +The native bridge resolves the chat CLI from `RAGMIR_CHAT_CLI_BIN`, then a local workspace +`packages/ragmir-chat/dist/cli.js`, then `rgr-chat` on `PATH`. Model setup and diagnosis use the +dedicated `rgr-chat setup --profile ... --json` and `rgr-chat doctor --profile ... --json` paths so +the selected model manifest, file size, and hash readiness remain explicit. + +The doctor contract exposes the operating system, architecture, locally supported compute backends, +selected backend, and hardware-acceleration state. The app surfaces that selection in model status, +so a Mac can prove Metal is active and Linux/Windows users can distinguish CUDA, Vulkan, or CPU +without reading native logs. + Registered projects can opt into watched-folder mode from the Projects view. This is a local polling layer over incremental `rgr ingest`: it re-indexes the selected project every 5 minutes, stores the setting only in local app storage, and does not add a cloud connector or background daemon. diff --git a/packages/ragmir-app/src-tauri/src/chat_runtime.rs b/packages/ragmir-app/src-tauri/src/chat_runtime.rs new file mode 100644 index 0000000..34df449 --- /dev/null +++ b/packages/ragmir-app/src-tauri/src/chat_runtime.rs @@ -0,0 +1,829 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{HashMap, VecDeque}, + io::{BufRead, BufReader, Write}, + path::{Path, PathBuf}, + process::{Child, ChildStdin, Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tauri::{ipc::Channel, State}; + +#[cfg(test)] +use std::fs; + +const CHAT_RUNTIME_ENV: &str = "RAGMIR_CHAT_CLI_BIN"; +const CHAT_RUNTIME_COMMAND: &str = "rgr-chat"; +const CHAT_RUNTIME_STDERR_LINES: usize = 20; +const CHAT_RUNTIME_SHUTDOWN_POLLS: usize = 20; +const CHAT_RUNTIME_SHUTDOWN_POLL_MS: u64 = 25; + +type PendingChannels = Arc>>>; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChatProfile { + Lite, + Fast, + Quality, +} + +impl ChatProfile { + fn as_str(self) -> &'static str { + match self { + Self::Lite => "lite", + Self::Fast => "fast", + Self::Quality => "quality", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChatThinkingMode { + Off, + Standard, + Deep, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum ChatRole { + User, + Assistant, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatHistoryMessage { + role: ChatRole, + content: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatSource { + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + relative_path: String, + chunk_index: u32, + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + distance: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatGenerateRequest { + project_root: String, + id: String, + question: String, + history: Vec, + sources: Vec, + profile: ChatProfile, + thinking: ChatThinkingMode, + max_new_tokens: Option, + context_char_limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatCancelRequest { + id: String, + target_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatProfileRequest { + project_root: String, + profile: ChatProfile, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeGenerateMessage<'a> { + id: &'a str, + #[serde(rename = "type")] + message_type: &'static str, + question: &'a str, + history: &'a [ChatHistoryMessage], + sources: &'a [ChatSource], + thinking: ChatThinkingMode, + #[serde(skip_serializing_if = "Option::is_none")] + max_new_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + context_char_limit: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeCancelMessage<'a> { + id: &'a str, + #[serde(rename = "type")] + message_type: &'static str, + target_id: &'a str, +} + +#[derive(Debug, Serialize)] +struct RuntimeShutdownMessage { + id: String, + #[serde(rename = "type")] + message_type: &'static str, +} + +#[derive(Clone, Debug, PartialEq)] +struct RuntimeIdentity { + project_root: String, + profile: ChatProfile, +} + +#[derive(Clone)] +struct RuntimeWriter { + identity: RuntimeIdentity, + stdin: Arc>, +} + +struct RunningRuntime { + identity: RuntimeIdentity, + child: Child, + stdin: Arc>, + healthy: Arc, + reader_thread: Option>, +} + +struct ChatCliInvocation { + program: String, + args_prefix: Vec, +} + +pub struct RagmirChatRuntimeState { + process: Arc>>, + writer: Arc>>, + pending: PendingChannels, +} + +impl Default for RagmirChatRuntimeState { + fn default() -> Self { + Self { + process: Arc::new(Mutex::new(None)), + writer: Arc::new(Mutex::new(None)), + pending: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl Drop for RagmirChatRuntimeState { + fn drop(&mut self) { + if let Ok(mut process) = self.process.lock() { + if let Some(runtime) = process.as_mut() { + stop_runtime(runtime); + } + } + } +} + +#[tauri::command] +pub async fn generate_ragmir_chat( + state: State<'_, RagmirChatRuntimeState>, + request: ChatGenerateRequest, + on_event: Channel, +) -> Result<(), String> { + validate_generate_request(&request)?; + let process = state.inner().process.clone(); + let writer = state.inner().writer.clone(); + let pending = Arc::clone(&state.inner().pending); + + tauri::async_runtime::spawn_blocking(move || { + let runtime_writer = ensure_runtime(&process, &writer, &pending, &request)?; + register_pending_channel(&pending, &request.id, on_event)?; + let message = RuntimeGenerateMessage { + id: &request.id, + message_type: "generate", + question: &request.question, + history: &request.history, + sources: &request.sources, + thinking: request.thinking, + max_new_tokens: request.max_new_tokens, + context_char_limit: request.context_char_limit, + }; + if let Err(error) = write_runtime_message(&runtime_writer.stdin, &message) { + remove_pending_channel(&pending, &request.id); + return Err(error); + } + Ok(()) + }) + .await + .map_err(|error| format!("Unable to join Ragmir Chat runtime task: {error}"))? +} + +#[tauri::command] +pub async fn cancel_ragmir_chat( + state: State<'_, RagmirChatRuntimeState>, + request: ChatCancelRequest, +) -> Result<(), String> { + require_non_empty(&request.id, "Cancel request id")?; + require_non_empty(&request.target_id, "Target request id")?; + let writer = current_writer(&state.inner().writer)?; + let message = RuntimeCancelMessage { + id: &request.id, + message_type: "cancel", + target_id: &request.target_id, + }; + write_runtime_message(&writer.stdin, &message) +} + +#[tauri::command] +pub async fn shutdown_ragmir_chat(state: State<'_, RagmirChatRuntimeState>) -> Result<(), String> { + let mut process = state + .inner() + .process + .lock() + .map_err(|_| "Ragmir Chat process lock is poisoned.".to_string())?; + if let Some(runtime) = process.as_mut() { + stop_runtime(runtime); + } + *process = None; + *state + .inner() + .writer + .lock() + .map_err(|_| "Ragmir Chat writer lock is poisoned.".to_string())? = None; + fail_pending( + &state.inner().pending, + "RUNTIME_SHUTDOWN", + "Ragmir Chat runtime stopped.", + ); + Ok(()) +} + +#[tauri::command] +pub async fn setup_ragmir_chat(request: ChatProfileRequest) -> Result { + run_profile_command(request, "setup").await +} + +#[tauri::command] +pub async fn doctor_ragmir_chat(request: ChatProfileRequest) -> Result { + run_profile_command(request, "doctor").await +} + +async fn run_profile_command( + request: ChatProfileRequest, + command: &'static str, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let project_root = normalized_project_root(&request.project_root)?; + let invocation = resolve_chat_cli(); + let output = Command::new(&invocation.program) + .args(invocation.args_prefix) + .args(profile_command_args(command, request.profile)) + .current_dir(project_root) + .output() + .map_err(|error| format!("Unable to run Ragmir Chat {command}: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!( + "Ragmir Chat {command} exited with status {}.", + output.status + ) + } else { + stderr + }); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("Ragmir Chat {command} returned invalid JSON: {error}")) + }) + .await + .map_err(|error| format!("Unable to join Ragmir Chat {command} task: {error}"))? +} + +fn validate_generate_request(request: &ChatGenerateRequest) -> Result<(), String> { + require_non_empty(&request.id, "Request id")?; + require_non_empty(&request.question, "Question")?; + normalized_project_root(&request.project_root)?; + for message in &request.history { + require_non_empty(&message.content, "History content")?; + } + for source in &request.sources { + require_non_empty(&source.relative_path, "Source path")?; + require_non_empty(&source.text, "Source text")?; + } + Ok(()) +} + +fn require_non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{label} is required.")); + } + Ok(()) +} + +fn normalized_project_root(project_root: &str) -> Result { + let project_root = project_root.trim(); + if project_root.is_empty() { + return Err("Project root is required.".into()); + } + let project_root_path = PathBuf::from(project_root); + if !project_root_path.is_absolute() { + return Err("Project root must be an absolute path.".into()); + } + if !project_root_path.is_dir() { + return Err("Project root must be an existing directory.".into()); + } + Ok(project_root_path.to_string_lossy().into_owned()) +} + +fn ensure_runtime( + process: &Mutex>, + writer: &Mutex>, + pending: &PendingChannels, + request: &ChatGenerateRequest, +) -> Result { + let identity = RuntimeIdentity { + project_root: normalized_project_root(&request.project_root)?, + profile: request.profile, + }; + let mut process_guard = process + .lock() + .map_err(|_| "Ragmir Chat process lock is poisoned.".to_string())?; + + if let Some(runtime) = process_guard.as_mut() { + let exited = runtime + .child + .try_wait() + .map_err(|error| format!("Unable to inspect Ragmir Chat runtime: {error}"))? + .is_some(); + let unhealthy = !runtime.healthy.load(Ordering::Acquire); + if exited || unhealthy { + stop_runtime(runtime); + *process_guard = None; + *writer + .lock() + .map_err(|_| "Ragmir Chat writer lock is poisoned.".to_string())? = None; + } + } + + if let Some(runtime) = process_guard.as_mut() { + if runtime.identity != identity { + if has_pending_channels(pending)? { + return Err( + "Cancel the active local answer before changing workspace or model profile." + .into(), + ); + } + stop_runtime(runtime); + *process_guard = None; + *writer + .lock() + .map_err(|_| "Ragmir Chat writer lock is poisoned.".to_string())? = None; + } + } + + if process_guard.is_none() { + let runtime = spawn_runtime(identity.clone(), Arc::clone(pending))?; + let runtime_writer = RuntimeWriter { + identity: identity.clone(), + stdin: Arc::clone(&runtime.stdin), + }; + *writer + .lock() + .map_err(|_| "Ragmir Chat writer lock is poisoned.".to_string())? = + Some(runtime_writer); + *process_guard = Some(runtime); + } + + let runtime_writer = writer + .lock() + .map_err(|_| "Ragmir Chat writer lock is poisoned.".to_string())? + .clone() + .ok_or_else(|| "Ragmir Chat runtime writer is unavailable.".to_string())?; + if runtime_writer.identity != identity { + return Err("Ragmir Chat runtime identity changed unexpectedly.".into()); + } + Ok(runtime_writer) +} + +fn spawn_runtime( + identity: RuntimeIdentity, + pending: PendingChannels, +) -> Result { + let invocation = resolve_chat_cli(); + let mut child = Command::new(&invocation.program) + .args(invocation.args_prefix) + .args(runtime_server_args(identity.profile)) + .current_dir(&identity.project_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("Unable to start persistent Ragmir Chat runtime: {error}"))?; + + let stdin = + Arc::new(Mutex::new(child.stdin.take().ok_or_else(|| { + "Ragmir Chat runtime stdin is unavailable.".to_string() + })?)); + let stdout = child + .stdout + .take() + .ok_or_else(|| "Ragmir Chat runtime stdout is unavailable.".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "Ragmir Chat runtime stderr is unavailable.".to_string())?; + let stderr_lines = Arc::new(Mutex::new(VecDeque::new())); + let healthy = Arc::new(AtomicBool::new(true)); + + spawn_stderr_reader(stderr, Arc::clone(&stderr_lines)); + let reader_thread = spawn_stdout_reader(stdout, pending, stderr_lines, Arc::clone(&healthy)); + + Ok(RunningRuntime { + identity, + child, + stdin, + healthy, + reader_thread: Some(reader_thread), + }) +} + +fn spawn_stdout_reader( + stdout: impl std::io::Read + Send + 'static, + pending: PendingChannels, + stderr_lines: Arc>>, + healthy: Arc, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let line = match line { + Ok(line) => line, + Err(error) => { + healthy.store(false, Ordering::Release); + fail_pending( + &pending, + "RUNTIME_IO", + &format!("Unable to read Ragmir Chat runtime output: {error}"), + ); + return; + } + }; + if line.trim().is_empty() { + continue; + } + let event: Value = match serde_json::from_str(&line) { + Ok(event) => event, + Err(_) => { + healthy.store(false, Ordering::Release); + fail_pending( + &pending, + "RUNTIME_PROTOCOL", + "Ragmir Chat runtime emitted invalid NDJSON.", + ); + return; + } + }; + route_runtime_event(&pending, event); + } + + healthy.store(false, Ordering::Release); + let detail = stderr_summary(&stderr_lines); + let message = if detail.is_empty() { + "Ragmir Chat runtime closed before completing the active answer.".to_string() + } else { + format!("Ragmir Chat runtime closed: {detail}") + }; + fail_pending(&pending, "RUNTIME_EXITED", &message); + }) +} + +fn spawn_stderr_reader( + stderr: impl std::io::Read + Send + 'static, + lines: Arc>>, +) { + thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if let Ok(mut stored_lines) = lines.lock() { + stored_lines.push_back(line); + while stored_lines.len() > CHAT_RUNTIME_STDERR_LINES { + stored_lines.pop_front(); + } + } + } + }); +} + +fn route_runtime_event(pending: &PendingChannels, event: Value) { + let Some(id) = event.get("id").and_then(Value::as_str) else { + fail_pending( + pending, + "RUNTIME_PROTOCOL", + "Ragmir Chat runtime emitted an event without an id.", + ); + return; + }; + let terminal = matches!( + event.get("event").and_then(Value::as_str), + Some("completed" | "cancelled" | "error") + ); + let Ok(mut channels) = pending.lock() else { + return; + }; + if terminal { + if let Some(channel) = channels.remove(id) { + let _ = channel.send(event); + } + } else if let Some(channel) = channels.get(id) { + let _ = channel.send(event); + } +} + +fn register_pending_channel( + pending: &PendingChannels, + request_id: &str, + channel: Channel, +) -> Result<(), String> { + let mut channels = pending + .lock() + .map_err(|_| "Ragmir Chat pending channel lock is poisoned.".to_string())?; + if channels.contains_key(request_id) { + return Err("A Ragmir Chat request with this id is already active.".into()); + } + if !channels.is_empty() { + return Err("Ragmir Chat is already generating an answer.".into()); + } + channels.insert(request_id.into(), channel); + Ok(()) +} + +fn remove_pending_channel(pending: &PendingChannels, request_id: &str) { + if let Ok(mut channels) = pending.lock() { + channels.remove(request_id); + } +} + +fn has_pending_channels(pending: &PendingChannels) -> Result { + pending + .lock() + .map(|channels| !channels.is_empty()) + .map_err(|_| "Ragmir Chat pending channel lock is poisoned.".to_string()) +} + +fn fail_pending(pending: &PendingChannels, code: &str, message: &str) { + let Ok(mut channels) = pending.lock() else { + return; + }; + for (id, channel) in channels.drain() { + let _ = channel.send(json!({ + "id": id, + "event": "error", + "code": code, + "message": message, + })); + } +} + +fn current_writer(writer: &Mutex>) -> Result { + writer + .lock() + .map_err(|_| "Ragmir Chat writer lock is poisoned.".to_string())? + .clone() + .ok_or_else(|| "No persistent Ragmir Chat runtime is active.".to_string()) +} + +fn write_runtime_message( + stdin: &Arc>, + message: &T, +) -> Result<(), String> { + let mut stdin = stdin + .lock() + .map_err(|_| "Ragmir Chat stdin lock is poisoned.".to_string())?; + serde_json::to_writer(&mut *stdin, message) + .map_err(|error| format!("Unable to serialize Ragmir Chat request: {error}"))?; + stdin + .write_all(b"\n") + .and_then(|_| stdin.flush()) + .map_err(|error| format!("Unable to write to Ragmir Chat runtime: {error}")) +} + +fn stop_runtime(runtime: &mut RunningRuntime) { + let _ = write_runtime_message( + &runtime.stdin, + &RuntimeShutdownMessage { + id: runtime_control_id("shutdown"), + message_type: "shutdown", + }, + ); + let mut exited = false; + for _ in 0..CHAT_RUNTIME_SHUTDOWN_POLLS { + if matches!(runtime.child.try_wait(), Ok(Some(_))) { + exited = true; + break; + } + thread::sleep(Duration::from_millis(CHAT_RUNTIME_SHUTDOWN_POLL_MS)); + } + if !exited { + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } + if let Some(reader_thread) = runtime.reader_thread.take() { + let _ = reader_thread.join(); + } +} + +fn runtime_control_id(action: &str) -> String { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("app-{action}-{nonce}") +} + +fn stderr_summary(lines: &Arc>>) -> String { + lines + .lock() + .map(|lines| lines.iter().cloned().collect::>().join(" ")) + .unwrap_or_default() + .trim() + .to_string() +} + +fn resolve_chat_cli() -> ChatCliInvocation { + if let Ok(cli_bin) = std::env::var(CHAT_RUNTIME_ENV) { + return invocation_from_path(cli_bin); + } + if let Some(local_cli) = find_local_workspace_chat_cli() { + return invocation_from_path(local_cli.to_string_lossy().into_owned()); + } + ChatCliInvocation { + program: CHAT_RUNTIME_COMMAND.into(), + args_prefix: Vec::new(), + } +} + +fn invocation_from_path(cli_bin: String) -> ChatCliInvocation { + let cli_path = PathBuf::from(&cli_bin); + if cli_path + .extension() + .and_then(|extension| extension.to_str()) + == Some("js") + { + return ChatCliInvocation { + program: std::env::var("RAGMIR_NODE_BIN").unwrap_or_else(|_| "node".into()), + args_prefix: vec![cli_bin], + }; + } + ChatCliInvocation { + program: cli_bin, + args_prefix: Vec::new(), + } +} + +fn find_local_workspace_chat_cli() -> Option { + let current_dir = std::env::current_dir().ok()?; + find_local_workspace_chat_cli_from(¤t_dir) +} + +fn find_local_workspace_chat_cli_from(start: &Path) -> Option { + for ancestor in start.ancestors() { + let candidate = ancestor + .join("packages") + .join("ragmir-chat") + .join("dist") + .join("cli.js"); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn runtime_server_args(profile: ChatProfile) -> Vec<&'static str> { + vec!["serve", "--profile", profile.as_str(), "--offline"] +} + +fn profile_command_args(command: &'static str, profile: ChatProfile) -> Vec<&'static str> { + vec![command, "--profile", profile.as_str(), "--json"] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_message_keeps_history_sources_and_thinking_structured() { + let history = vec![ChatHistoryMessage { + role: ChatRole::User, + content: "Earlier visible question".into(), + }]; + let sources = vec![ChatSource { + source: Some("docs/guide.md".into()), + relative_path: "docs/guide.md".into(), + chunk_index: 2, + text: "Visible cited passage".into(), + distance: Some(0.12), + }]; + let message = RuntimeGenerateMessage { + id: "request-1", + message_type: "generate", + question: "Latest question only", + history: &history, + sources: &sources, + thinking: ChatThinkingMode::Standard, + max_new_tokens: None, + context_char_limit: None, + }; + + let value = serde_json::to_value(message).expect("generate request"); + + assert_eq!(value["type"], "generate"); + assert_eq!(value["question"], "Latest question only"); + assert_eq!(value["history"][0]["content"], "Earlier visible question"); + assert_eq!(value["sources"][0]["relativePath"], "docs/guide.md"); + assert_eq!(value["thinking"], "standard"); + assert!(value.get("projectRoot").is_none()); + assert!(value.get("profile").is_none()); + } + + #[test] + fn cancel_message_targets_the_active_generation() { + let value = serde_json::to_value(RuntimeCancelMessage { + id: "cancel-1", + message_type: "cancel", + target_id: "request-1", + }) + .expect("cancel request"); + + assert_eq!(value["type"], "cancel"); + assert_eq!(value["targetId"], "request-1"); + } + + #[test] + fn server_args_are_offline_and_profile_specific() { + assert_eq!( + runtime_server_args(ChatProfile::Lite), + ["serve", "--profile", "lite", "--offline"] + ); + assert_eq!( + runtime_server_args(ChatProfile::Fast), + ["serve", "--profile", "fast", "--offline"] + ); + assert_eq!( + runtime_server_args(ChatProfile::Quality), + ["serve", "--profile", "quality", "--offline"] + ); + } + + #[test] + fn setup_and_doctor_args_select_one_profile() { + assert_eq!( + profile_command_args("setup", ChatProfile::Lite), + ["setup", "--profile", "lite", "--json"] + ); + assert_eq!( + profile_command_args("setup", ChatProfile::Fast), + ["setup", "--profile", "fast", "--json"] + ); + assert_eq!( + profile_command_args("doctor", ChatProfile::Quality), + ["doctor", "--profile", "quality", "--json"] + ); + } + + #[test] + fn finds_local_workspace_chat_cli_from_nested_tauri_directory() { + let repo_root = unique_test_dir("workspace-chat-cli"); + let cli_path = repo_root + .join("packages") + .join("ragmir-chat") + .join("dist") + .join("cli.js"); + fs::create_dir_all(cli_path.parent().expect("cli parent")).expect("cli parent dir"); + fs::write(&cli_path, "#!/usr/bin/env node\n").expect("cli file"); + let nested_tauri_dir = repo_root + .join("packages") + .join("ragmir-app") + .join("src-tauri"); + fs::create_dir_all(&nested_tauri_dir).expect("nested tauri dir"); + + assert_eq!( + find_local_workspace_chat_cli_from(&nested_tauri_dir), + Some(cli_path) + ); + + fs::remove_dir_all(repo_root).expect("cleanup"); + } + + fn unique_test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("ragmir-app-{label}-{nonce}")); + fs::create_dir_all(&dir).expect("test dir"); + dir + } +} diff --git a/packages/ragmir-app/src-tauri/src/lib.rs b/packages/ragmir-app/src-tauri/src/lib.rs index f697d17..5bdb815 100644 --- a/packages/ragmir-app/src-tauri/src/lib.rs +++ b/packages/ragmir-app/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod chat_runtime; + use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ @@ -385,8 +387,14 @@ fn push_top_k(args: &mut Vec, top_k: Option) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(chat_runtime::RagmirChatRuntimeState::default()) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ + chat_runtime::generate_ragmir_chat, + chat_runtime::cancel_ragmir_chat, + chat_runtime::shutdown_ragmir_chat, + chat_runtime::setup_ragmir_chat, + chat_runtime::doctor_ragmir_chat, run_ragmir_command, read_ragmir_config, write_ragmir_config diff --git a/packages/ragmir-app/src/app.tsx b/packages/ragmir-app/src/app.tsx index 58720db..547e200 100644 --- a/packages/ragmir-app/src/app.tsx +++ b/packages/ragmir-app/src/app.tsx @@ -32,6 +32,7 @@ import { ArrowUp, BookOpenCheck, CheckCircle2, + CircleStop, Cloud, Copy, Download, @@ -80,10 +81,24 @@ import { upsertProject, } from "./lib/project-registry.js" import { - type AudioDoctorReport, - type AudioRenderResult, + type ChatCitationStatus, + type ChatComputeBackend, type ChatDoctorReport, + type ChatHistoryMessage, + type ChatProfile, type ChatResult, + type ChatRuntimeEvent, + type ChatStopReason, + type ChatThinkingMode, + cancelRagmirChat, + doctorRagmirChat, + generateRagmirChat, + setupRagmirChat, + shutdownRagmirChat, +} from "./lib/ragmir-chat-runtime.js" +import { + type AudioDoctorReport, + type AudioRenderResult, type DoctorReport, type IngestResult, type RagmirConfigFile, @@ -91,12 +106,10 @@ import { runAudioDoctor, runAudioPreload, runAudioSummary, - runChat, - runChatDoctor, - runChatSetup, runDoctor, runIngest, runModelsPull, + runSearch, runSecurityAudit, runStatus, type SecurityAuditReport, @@ -108,7 +121,7 @@ type View = "chat" | "config" | "privacy" | "license" type SetupStepId = "initialize" | "semantic" | "chat" | "tts" | "index" | "verify" type SetupStepStatus = "idle" | "running" | "done" | "error" type ChatMessageRole = "user" | "assistant" -type ChatMessageStatus = "done" | "thinking" | "streaming" | "error" +type ChatMessageStatus = "done" | "loading" | "reasoning" | "streaming" | "cancelled" | "error" type ChatMessageAudioStatus = "rendering" | "ready" | "playing" | "paused" | "error" type ChatRuntime = "local" | "codex" | "claude" | "other-agent" type ChatSource = ChatResult["sources"][number] @@ -121,15 +134,14 @@ const AUTO_INGEST_POLL_MS = 30_000 const AUTO_INGEST_INTERVAL_MS = 5 * 60 * 1000 const CHAT_TOP_K = 6 const CHAT_HISTORY_MESSAGE_LIMIT = 8 -const CHAT_HISTORY_CHAR_LIMIT = 2400 const CHAT_HISTORY_MESSAGE_CHAR_LIMIT = 420 const CHAT_AUDIO_SOURCE_LIMIT = 3 -const ASSISTANT_STREAM_CHUNK_SIZE = 5 -const ASSISTANT_STREAM_DELAY_MS = 24 const EMPTY_WORKSPACE_MESSAGE = "Add a project to create a private Ragmir workspace." const CHAT_THREADS_STORAGE_KEY = "ragmir.chatThreads.v1" const ACTIVE_CHAT_ID_STORAGE_KEY = "ragmir.activeChatId.v1" const CHAT_RUNTIME_STORAGE_KEY = "ragmir.chatRuntime.v1" +const CHAT_PROFILE_STORAGE_KEY = "ragmir.chatProfile.v1" +const CHAT_THINKING_STORAGE_KEY = "ragmir.chatThinking.v1" const Markdown = lazy(() => import("react-markdown")) @@ -255,7 +267,7 @@ const SETUP_STEP_DEFINITIONS: Array> = [ { id: "chat", label: "Prepare local chat", - detail: "Preload the Transformers chat model for offline answers.", + detail: "Download and verify the selected local GGUF profile once.", }, { id: "tts", @@ -270,7 +282,7 @@ const SETUP_STEP_DEFINITIONS: Array> = [ { id: "verify", label: "Verify privacy and readiness", - detail: "Check config, index, chat, and local privacy posture.", + detail: "Check config, index, local chat runtime, and privacy posture.", }, ] @@ -317,11 +329,15 @@ export function App(): React.JSX.Element { const [chatThreads, setChatThreads] = useState(() => loadChatThreads()) const [activeChatId, setActiveChatId] = useState(() => loadActiveChatId()) const [chatRuntime, setChatRuntime] = useState(() => loadChatRuntime()) + const [chatProfile, setChatProfile] = useState(() => loadChatProfile()) + const [chatThinking, setChatThinking] = useState(() => loadChatThinking()) const [projectRoot, setProjectRoot] = useState("") const [googleDriveRoot, setGoogleDriveRoot] = useState("") const [dropStatus, setDropStatus] = useState("Add a project folder or drop it here.") const [runtimeMessage, setRuntimeMessage] = useState(EMPTY_WORKSPACE_MESSAGE) const [isRunning, setIsRunning] = useState(false) + const [isChatGenerating, setIsChatGenerating] = useState(false) + const [isCancellingChat, setIsCancellingChat] = useState(false) const [isChoosingFolder, setIsChoosingFolder] = useState(false) const [setupSteps, setSetupSteps] = useState(() => createSetupSteps()) const [question, setQuestion] = useState("") @@ -342,6 +358,11 @@ export function App(): React.JSX.Element { const isRunningRef = useRef(isRunning) const audioPlayerRef = useRef(null) const playingAudioTargetRef = useRef<{ chatId: string; messageId: string } | null>(null) + const activeChatRequestRef = useRef(null) + const chatCancelRequestedRef = useRef(false) + const chatCancelSentRef = useRef(false) + const chatRuntimeAcknowledgedRef = useRef(false) + const chatRuntimeActiveRef = useRef(false) const autoIngestRunnerRef = useRef<(project: RagmirProject) => Promise>(async () => {}) const activeProject = projects.find((project) => project.id === activeProjectId) ?? null const activeProjectChats = activeProject @@ -380,6 +401,21 @@ export function App(): React.JSX.Element { saveChatRuntime(chatRuntime) }, [chatRuntime]) + useEffect(() => { + saveChatProfile(chatProfile) + setChatDoctorReport(null) + }, [chatProfile]) + + useEffect(() => { + if (chatProfile === "lite") { + setChatThinking("off") + } + }, [chatProfile]) + + useEffect(() => { + saveChatThinking(chatThinking) + }, [chatThinking]) + useEffect(() => { isRunningRef.current = isRunning }, [isRunning]) @@ -621,7 +657,7 @@ export function App(): React.JSX.Element { const [report, status, chatDoctor, audioDoctor] = await Promise.all([ runDoctor(project.projectRoot), runStatus(project.projectRoot), - runChatDoctor(project.projectRoot), + doctorRagmirChat(project.projectRoot, chatProfile), runAudioDoctor(project.projectRoot), ]) updateProjectFromDoctor(project, report) @@ -652,10 +688,11 @@ export function App(): React.JSX.Element { }) await runSetupStep("chat", async () => { - const setupResult = await runChatSetup(project.projectRoot) - const doctor = await runChatDoctor(project.projectRoot) + await shutdownRagmirChat() + const setupResult = await setupRagmirChat(project.projectRoot, chatProfile) + const doctor = await doctorRagmirChat(project.projectRoot, chatProfile) setChatDoctorReport(doctor) - setRuntimeMessage(`Local chat model ready: ${setupResult.model}.`) + setRuntimeMessage(`${chatProfileLabel(setupResult.profile)} ready: ${setupResult.model}.`) }) await runSetupStep("tts", async () => { @@ -674,7 +711,7 @@ export function App(): React.JSX.Element { const [report, status, chatDoctor, audioDoctor, security] = await Promise.all([ runDoctor(project.projectRoot), runStatus(project.projectRoot), - runChatDoctor(project.projectRoot), + doctorRagmirChat(project.projectRoot, chatProfile), runAudioDoctor(project.projectRoot), runSecurityAudit(project.projectRoot), ]) @@ -765,10 +802,11 @@ export function App(): React.JSX.Element { } await runProjectCommand("Preparing local chat model", activeProject, async () => { - const setupResult = await runChatSetup(activeProject.projectRoot) - const doctor = await runChatDoctor(activeProject.projectRoot) + await shutdownRagmirChat() + const setupResult = await setupRagmirChat(activeProject.projectRoot, chatProfile) + const doctor = await doctorRagmirChat(activeProject.projectRoot, chatProfile) setChatDoctorReport(doctor) - setRuntimeMessage(`Local chat model ready: ${setupResult.model}.`) + setRuntimeMessage(`${chatProfileLabel(setupResult.profile)} ready: ${setupResult.model}.`) }) } @@ -916,7 +954,7 @@ export function App(): React.JSX.Element { const targetThread = activeChat ?? createChatThread(activeProject.id, trimmedQuestion) const userMessage = createChatMessage("user", trimmedQuestion, undefined, { status: "done" }) const assistantMessage = createChatMessage("assistant", "", undefined, { - status: "thinking", + status: "loading", statusLabel: "Searching local context", }) const initialThread: ChatThread = { @@ -928,76 +966,156 @@ export function App(): React.JSX.Element { messages: [...targetThread.messages, userMessage, assistantMessage], updatedAt: assistantMessage.createdAt, } - const contextualQuestion = buildContextualChatQuestion(targetThread.messages, trimmedQuestion) + const requestId = createLocalId("generation") setActiveChatId(initialThread.id) setQuestion("") setChatThreads((currentThreads) => upsertChatThread(currentThreads, initialThread)) setView("chat") setIsRunning(true) + setIsChatGenerating(true) + setIsCancellingChat(false) + activeChatRequestRef.current = requestId + chatCancelRequestedRef.current = false + chatCancelSentRef.current = false + chatRuntimeAcknowledgedRef.current = false + chatRuntimeActiveRef.current = false setRuntimeMessage("Searching local Ragmir context...") + let streamedContent = "" try { - const rawResult = await runChat(activeProject.projectRoot, contextualQuestion, CHAT_TOP_K) - const result = normalizeChatResultForDisplay(rawResult, trimmedQuestion) - setRuntimeMessage("Writing the answer...") - await streamAssistantMessage(initialThread.id, assistantMessage.id, result) - setChatResult(result) - setRuntimeMessage( - result.emptyContext - ? "No relevant local context found. Add or index documents, then ask again." - : `Answered from ${result.sources.length} cited source${result.sources.length === 1 ? "" : "s"} with recent chat context.`, + const search = await runSearch(activeProject.projectRoot, trimmedQuestion, CHAT_TOP_K) + if (chatCancelRequestedRef.current) { + setChatThreads((currentThreads) => + updateChatMessage(currentThreads, initialThread.id, assistantMessage.id, { + content: "Local answer cancelled before generation started.", + status: "cancelled", + statusLabel: "Cancelled", + }), + ) + setRuntimeMessage("Local answer cancelled.") + return + } + + chatRuntimeActiveRef.current = true + const terminal = await generateRagmirChat( + { + projectRoot: activeProject.projectRoot, + id: requestId, + question: trimmedQuestion, + history: chatHistoryMessages(targetThread.messages), + sources: search.results, + profile: chatProfile, + thinking: chatThinking, + }, + (runtimeEvent) => { + chatRuntimeAcknowledgedRef.current = true + if ( + chatCancelRequestedRef.current && + !chatCancelSentRef.current && + runtimeEvent.event !== "completed" && + runtimeEvent.event !== "cancelled" && + runtimeEvent.event !== "error" + ) { + chatCancelSentRef.current = true + void cancelRagmirChat(requestId).catch((error: unknown) => { + chatCancelSentRef.current = false + setRuntimeMessage( + error instanceof Error ? error.message : "Unable to cancel local chat.", + ) + }) + } + streamedContent = applyChatRuntimeEvent(runtimeEvent, streamedContent, (patch) => + setChatThreads((currentThreads) => + updateChatMessage(currentThreads, initialThread.id, assistantMessage.id, patch), + ), + ) + }, + ) + + if (chatCancelRequestedRef.current && terminal.event === "completed") { + setChatThreads((currentThreads) => + updateChatMessage(currentThreads, initialThread.id, assistantMessage.id, { + content: streamedContent.trim() || "Local answer cancelled.", + status: "cancelled", + statusLabel: "Cancelled", + }), + ) + setRuntimeMessage("Local answer cancelled.") + return + } + if (terminal.event === "cancelled") { + const partialAnswer = terminal.partialAnswer.trim() || streamedContent.trim() + setChatThreads((currentThreads) => + updateChatMessage(currentThreads, initialThread.id, assistantMessage.id, { + content: partialAnswer || "Local answer cancelled.", + status: "cancelled", + statusLabel: "Cancelled", + }), + ) + setRuntimeMessage("Local answer cancelled. Partial answer kept locally.") + return + } + if (terminal.event === "error") { + throw new Error(terminal.message) + } + + const result = normalizeChatResultForDisplay(terminal.result, trimmedQuestion) + setChatThreads((currentThreads) => + updateChatMessage(currentThreads, initialThread.id, assistantMessage.id, { + content: result.answer, + result, + status: "done", + statusLabel: null, + }), ) + setChatResult(result) + setRuntimeMessage(chatCompletionMessage(result)) } catch (error) { const message = error instanceof Error ? error.message : "Local chat failed." setChatThreads((currentThreads) => updateChatMessage(currentThreads, initialThread.id, assistantMessage.id, { - content: `I could not complete the local answer.\n\n${message}`, + content: streamedContent.trim() + ? `${streamedContent.trim()}\n\nLocal generation stopped: ${message}` + : `I could not complete the local answer.\n\n${message}`, status: "error", statusLabel: "Failed", }), ) setRuntimeMessage(message) } finally { + if (activeChatRequestRef.current === requestId) { + activeChatRequestRef.current = null + } + chatCancelRequestedRef.current = false + chatCancelSentRef.current = false + chatRuntimeAcknowledgedRef.current = false + chatRuntimeActiveRef.current = false + setIsCancellingChat(false) + setIsChatGenerating(false) setIsRunning(false) } } - async function streamAssistantMessage( - chatId: string, - messageId: string, - result: ChatResult, - ): Promise { - setChatThreads((currentThreads) => - updateChatMessage(currentThreads, chatId, messageId, { - content: "", - status: "streaming", - statusLabel: "Writing answer", - }), - ) - - const chunks = chunkMarkdownForStream(result.answer) - let streamedContent = "" - for (const chunk of chunks) { - streamedContent += chunk - setChatThreads((currentThreads) => - updateChatMessage(currentThreads, chatId, messageId, { - content: streamedContent, - status: "streaming", - statusLabel: "Writing answer", - }), - ) - await sleep(ASSISTANT_STREAM_DELAY_MS) + async function handleCancelChat(): Promise { + const requestId = activeChatRequestRef.current + if (!requestId || !isRunning) { + return + } + chatCancelRequestedRef.current = true + setIsCancellingChat(true) + setRuntimeMessage("Cancelling the local answer...") + if (!chatRuntimeActiveRef.current || !chatRuntimeAcknowledgedRef.current) { + return + } + chatCancelSentRef.current = true + try { + await cancelRagmirChat(requestId) + } catch (error) { + chatCancelSentRef.current = false + setIsCancellingChat(false) + setRuntimeMessage(error instanceof Error ? error.message : "Unable to cancel local chat.") } - - setChatThreads((currentThreads) => - updateChatMessage(currentThreads, chatId, messageId, { - content: result.answer, - result, - status: "done", - statusLabel: null, - }), - ) } function handleExportMarkdown(): void { @@ -1272,13 +1390,18 @@ export function App(): React.JSX.Element { activeProject={activeProject} audioRenderingMessageId={audioRenderingMessageId} audioDoctorReport={audioDoctorReport} + chatProfile={chatProfile} chatRuntime={chatRuntime} chatResult={activeChatResult} chatDoctorReport={chatDoctorReport} + chatThinking={chatThinking} dropStatus={dropStatus} googleDriveRoot={googleDriveRoot} isChoosingFolder={isChoosingFolder} + isCancellingChat={isCancellingChat} + isChatGenerating={isChatGenerating} isRunning={isRunning} + onCancelChat={handleCancelChat} onChooseFolder={() => void handleChooseFolder()} onChooseGoogleDriveFolder={() => void handleChooseFolder("google-drive")} onDrop={handleDrop} @@ -1300,6 +1423,8 @@ export function App(): React.JSX.Element { onAskSubmit={handleAskSubmit} onQuestionChange={setQuestion} onChatRuntimeChange={setChatRuntime} + onChatProfileChange={setChatProfile} + onChatThinkingChange={setChatThinking} onViewChange={setView} playingAudioMessageId={playingAudioMessageId} projectRoot={projectRoot} @@ -1712,13 +1837,18 @@ interface ProjectChatViewProps { activeProject: RagmirProject | null audioRenderingMessageId: string | null audioDoctorReport: AudioDoctorReport | null + chatProfile: ChatProfile chatRuntime: ChatRuntime chatResult: ChatResult | null chatDoctorReport: ChatDoctorReport | null + chatThinking: ChatThinkingMode dropStatus: string googleDriveRoot: string isChoosingFolder: boolean + isCancellingChat: boolean + isChatGenerating: boolean isRunning: boolean + onCancelChat: () => Promise onChooseFolder: () => void onChooseGoogleDriveFolder: () => void onDrop: (event: DragEvent) => void @@ -1739,7 +1869,9 @@ interface ProjectChatViewProps { onToggleMessageAudio: (chatId: string, messageId: string) => Promise onAskSubmit: (event: FormEvent) => Promise onQuestionChange: (question: string) => void + onChatProfileChange: (profile: ChatProfile) => void onChatRuntimeChange: (runtime: ChatRuntime) => void + onChatThinkingChange: (thinking: ChatThinkingMode) => void onViewChange: (view: View) => void playingAudioMessageId: string | null projectRoot: string @@ -1754,13 +1886,18 @@ function ProjectChatView({ activeProject, audioRenderingMessageId, audioDoctorReport, + chatProfile, chatRuntime, chatResult, chatDoctorReport, + chatThinking, dropStatus, googleDriveRoot, isChoosingFolder, + isCancellingChat, + isChatGenerating, isRunning, + onCancelChat, onChooseFolder, onChooseGoogleDriveFolder, onDrop, @@ -1781,7 +1918,9 @@ function ProjectChatView({ onToggleMessageAudio, onAskSubmit, onQuestionChange, + onChatProfileChange, onChatRuntimeChange, + onChatThinkingChange, onViewChange, playingAudioMessageId, projectRoot, @@ -1958,9 +2097,18 @@ function ProjectChatView({
+