v0.1.17 — Memorix P11 upgrade (session objectives, task CRUD, parallel recall). Copilot Responses API, model failover, tool tiers, smart compression.
- Rust 1.85+ (edition 2024)
- macOS or Linux (Windows supported but not primary)
just(optional, for Justfile shortcuts)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo install just # optionalcargo build # debug
cargo build --release # optimized
cargo build --release --bin qcrVia Justfile:
just build
just releasecargo test --workspace # all tests
cargo test -p qcr-core --lib # core unit tests only
cargo test -p qcr-cli --test headless_test # E2E headless tests
cargo test -p qcr-cli --test file_creation_test
cargo test -p qcr-cli --test acp_integration # ACP ndjson stdio protocol testsTest count baseline: 1948+ tests (core + cli + ACP integration + doc tests).
E2E tests spin up a wiremock mock LLM server. No real API key is needed — the binary receives env vars pointing to localhost. Tests run fully offline.
cargo test -p qcr-cli 2>&1 | grep "test result"OPENAI_API_BASE=https://aiproxies.win/v1 \
OPENAI_API_KEY=<key> \
QCR_MODEL=gemini-2.5-flash \
QCR_PROVIDER=openai \
QCR_APPROVAL_MODE=never \
./target/debug/qcr --print "Say hello"qcr writes structured trace logs to ~/.qcr/qcr.log when the
QCR_LOG environment variable is set. Logs are written to a file rather than
stderr so they do not pollute the TUI.
# Capture warnings and errors (recommended for provider debugging)
QCR_LOG=warn qcr --provider copilot --model claude-sonnet-4.6 "your prompt"
# Capture all debug output from the core library
QCR_LOG=qcr_core=debug qcr --provider copilot "your prompt"
# Follow the log in another terminal
tail -f ~/.qcr/qcr.logThe filter syntax follows tracing-subscriber EnvFilter:
| Filter | Captures |
|---|---|
warn |
Warnings and errors from all modules |
qcr_core=debug |
All debug output from qcr-core |
qcr_core::llm=debug |
LLM request/response bodies only |
qcr_core::llm::copilot=debug |
Copilot provider specifically |
off |
Nothing (default when QCR_LOG is unset) |
On a 400 error from a provider, the log includes the full serialised request body and the raw response body — useful for diagnosing wire-format issues.
cargo clippy --workspace -- -D warnings
cargo fmt --all --checkVia Justfile:
just lint
just fmtcargo bench --no-run -p qcr-core # compile-check only (fast)
cargo bench -p qcr-core # run all benchmarks
cargo bench -p qcr-core --bench sse_parsing # run one target
# Quick smoke-test with short sample size
cargo bench -p qcr-core --bench sse_parsing -- --sample-size 10 --warm-up-time 1 --measurement-time 2Nine bench targets in crates/qcr-core/benches/:
sse_parsing, session_serialization, glob_search, skill_loading, i18n_lookup,
agent_loop, tool_execution, config_loading, provider_pipeline.
See docs/benchmarks.md for the full guide: writing new benchmarks,
shared helpers (MockGenerator, CountingAllocator), regression gating with
scripts/bench_compare.py, and the initial baseline results.
qcr/
├── crates/
│ ├── qcr-core/ # library crate — all business logic
│ │ ├── src/
│ │ │ ├── lib.rs
│ │ │ ├── config/ # layered config loading
│ │ │ ├── conversation/ # agent loop, approval, compression
│ │ │ ├── error.rs # error hierarchy
│ │ │ ├── i18n/ # locale support
│ │ │ ├── llm/ # providers, SSE parser, types
│ │ │ ├── mcp/ # MCP client manager
│ │ │ ├── memory/ # QCR.md loader + AGENTS.md loader + steering files
│ │ │ ├── memorix_client/ # memorix HTTP client (P11: sessions, tasks, context)
│ │ │ ├── plugins/ # plugin system (commands, agents, hooks, MCP)
│ │ │ ├── prompts/ # built-in system prompt template + assembler + embedded skill/template files
│ │ │ ├── services/ # fs, git, shell, undo stack
│ │ │ ├── session/ # session persistence
│ │ │ ├── skills/ # skill manager
│ │ │ ├── subagent/ # sub-agent spawning
│ │ │ ├── telemetry/ # usage telemetry
│ │ │ ├── tools/ # trait + registry + built-ins
│ │ │ └── util/ # logging
│ │ └── benches/ # criterion benchmarks
│ │ ├── common/ # shared helpers (MockGenerator, CountingAllocator, fixtures)
│ │ ├── agent_loop.rs
│ │ ├── config_loading.rs
│ │ ├── glob_search.rs
│ │ ├── i18n_lookup.rs
│ │ ├── provider_pipeline.rs
│ │ ├── session_serialization.rs
│ │ ├── skill_loading.rs
│ │ ├── sse_parsing.rs
│ │ └── tool_execution.rs
│ └── qcr-cli/ # binary crate — TUI + CLI
│ └── src/
│ ├── main.rs
│ ├── app.rs # TUI app state + event loop
│ ├── cli.rs # clap argument structs
│ ├── keybindings.rs # customisable key bindings (load from ~/.claude/keybindings.json)
│ ├── acp/ # ACP (Agent Client Protocol) mode for IDE integration
│ │ ├── mod.rs # run_acp_server() entry point
│ │ ├── agent.rs # Qwen Code RustAgent — implements SDK Agent trait
│ │ ├── bootstrap.rs # build_registry, build_loop_config, build_system_prompt_for_acp
│ │ ├── convert.rs # SessionUpdate → SDK SessionUpdate conversion
│ │ ├── session.rs # per-session state (AcpSession)
│ │ └── transport.rs # ndjson frame reader/writer
│ └── commands/ # slash commands: /clear /help /model /plan /compact /undo /spec /title /sessions
├── examples/
│ └── web-ui/ # React + assistant-ui chat UI (connects to qcr serve AG-UI)
├── .github/workflows/
│ ├── ci.yml # build + test + lint matrix
│ ├── bench.yml # benchmark regression gate (PR + main baseline upload)
│ └── release.yml # cross-platform binary releases on tag
├── .kiro/specs/ # project spec and task backlog
├── docs/ # this documentation
├── scripts/
│ └── bench_compare.py # criterion JSON regression comparison tool
├── tasks/ # session todo files
├── Cargo.toml # workspace manifest
└── Justfile
- Create
crates/qcr-core/src/tools/builtin/<name>.rs:
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::error::ToolError;
use crate::tools::traits::{Tool, ToolOutput};
pub struct MyTool;
#[async_trait]
impl Tool for MyTool {
fn name(&self) -> &str { "MyTool" }
fn description(&self) -> &str { "Does something useful." }
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"input": { "type": "string", "description": "The input." }
},
"required": ["input"]
})
}
fn requires_approval(&self) -> bool { false }
fn is_read_only(&self) -> bool { true }
async fn execute(&self, params: Value) -> Result<ToolOutput, ToolError> {
let input = params["input"].as_str()
.ok_or_else(|| ToolError::InvalidParams {
tool: "MyTool".into(),
message: "missing 'input'".into(),
})?;
Ok(ToolOutput::ok(format!("Result: {input}")))
}
}- Add
pub mod my_tool;tocrates/qcr-core/src/tools/builtin/mod.rs.
Built-in tools in crates/qcr-core/src/tools/builtin/:
| File | Tool | Notes |
|---|---|---|
read_file.rs |
ReadFileTool |
line-range support |
write_file.rs |
WriteFileTool |
pushes to UndoStack before write |
edit.rs / edit_file.rs |
EditFileTool |
find-and-replace, unified diff, pushes to UndoStack |
shell.rs |
ShellTool |
timeout, working dir override |
glob.rs |
GlobTool |
gitignore-aware |
grep.rs |
GrepTool |
regex, case-insensitive flag |
ls.rs |
ListDirectoryTool |
type + size metadata |
web_fetch.rs |
WebFetchTool |
HTTP → stripped plain text |
web_search.rs |
WebSearchTool |
DuckDuckGo HTML parse |
lsp.rs |
LspTool |
one-shot JSON-RPC over stdio |
skill.rs |
SkillTool |
loads .md via SkillManager (built-ins + disk + plugins) |
memory.rs |
MemoryTool |
read/write/append QCR.md |
task.rs |
TaskTool |
sub-agent, optional plugin agent |
todo_write.rs |
TodoWriteTool |
session task list → ~/.qcr/todos/ |
undo.rs |
UndoEditTool |
pop UndoStack, restore file, show diff |
oracle.rs |
OracleTool |
second-opinion single-turn LLM call |
format_file.rs |
FormatFileTool |
rustfmt/prettier/ruff/gofmt/shfmt/taplo/stylua |
find_and_edit.rs |
FindAndEditTool |
multi-file regex replace, dry-run mode |
mermaid.rs |
MermaidTool |
validate + write .md/.mmd, optional mmdc render |
ask_user.rs |
AskUserQuestionTool |
multi-choice question → user answer via TUI channel |
config_tool.rs |
ConfigTool |
runtime config get/set/list with persistence |
- Register in
register_builtin_tools():
let _ = registry.register(Arc::new(my_tool::MyTool));-
Add tests inside the module under
#[cfg(test)]. -
Run
cargo test -p qcr-core --lib tools::builtin::my_toolto verify.
Built-in skills are compiled into the binary via include_str! and are always
available without any .qcr/skills/ directory. User-provided skills with
the same name always take precedence (loaded from disk first; built-ins use
insert_if_absent).
- Create the skill body in
crates/qcr-core/src/prompts/:
---
name: my-skill
description: Short description shown in skill listings.
tags: [tag1, tag2]
---
# My Skill
Instruction content here...- Expose a constant in
crates/qcr-core/src/prompts/mod.rs:
pub const BUILTIN_MY_SKILL: &str = include_str!("my-skill.md");- Register in
SkillManager::register_builtin_skills()incrates/qcr-core/src/skills/mod.rs:
self.insert_if_absent(Skill {
name: "my-skill".to_string(),
description: "Short description.".to_string(),
instructions: crate::prompts::BUILTIN_MY_SKILL.to_string(),
tags: vec!["tag1".to_string(), "tag2".to_string()],
source_path: PathBuf::from("<built-in>"),
});-
Add tests in
skills/mod.rsunder#[cfg(test)]:my_skill_registered_by_default—mgr.get("my-skill").is_some()my_skill_not_overwritten_when_user_skill_exists— user skill winsmy_skill_listed_in_available— appears inmgr.list()
-
Add a constant test in
prompts/mod.rs:
#[test]
fn builtin_my_skill_not_empty() {
assert!(!BUILTIN_MY_SKILL.is_empty());
}- Wire is automatic —
register_builtin_skills()is already called inapp.rsafterload_from_dir()in all three entry points (TUI, headless, ACP session).
Built-in skills shipped with qcr:
| Skill name | File | Description |
|---|---|---|
spec-driven-development |
prompts/skill.md |
Requirements → Design → Tasks → E2E Verify pipeline for complex features. Five phases including unconditional Phase 5 verification gate. |
agent-browser |
skills/agent-browser/SKILL.md |
Browser automation via agent-browser CLI — navigate, click, fill, screenshot. |
dogfood |
skills/dogfood/SKILL.md |
Systematic QA / exploratory testing. Produces structured bug reports with screenshots and repro steps. |
e2e-fix |
skills/e2e-fix/SKILL.md |
Run a user flow, find bugs, fix them in code, re-run to verify. Loops until all issues resolved. |
electron |
skills/electron/SKILL.md |
Automate Electron desktop apps via Chrome DevTools Protocol. |
slack |
skills/slack/SKILL.md |
Interact with Slack workspaces using browser automation. |
vercel-sandbox |
skills/vercel-sandbox/SKILL.md |
Run agent-browser + Chrome inside Vercel Sandbox microVMs. |
User-provided or plugin skills with the same name always take precedence — built-ins are inserted with
insert_if_absentand are never overwritten.
When the user's request involves multiple independent features or is clearly too large for a single spec, the system prompt instructs the agent to:
- Decompose — create one
.kiro/specs/<feature-slug>/spec per feature via the spec-driven-development skill. - Track — maintain a master
TodoWritelisting all specs in dependency order. - Execute one at a time — never interleave tasks from different specs.
- Verify before continuing — after all Phase 4 tasks are done for a spec, run Phase 5:
- Web/UI project: load
GetSkill("e2e-fix"), run the verification loop, fix all bugs until 0 remain. - Library/CLI: run the full test suite; write extra integration tests if coverage is thin.
- Web/UI project: load
- Gate is unconditional — the verify-before-continue gate applies regardless of auto-continue state.
Detection heuristics (any one triggers decomposition):
- 2+ distinct named features in the request
- Scope spans 5+ unrelated files or 3+ distinct concerns
- Estimated task count exceeds 15
requires_approval() -> bool: returntruefor any tool that writes files, runs shell commands, or makes network requests that mutate state.is_read_only() -> bool: returntrueonly for purely read operations (no side effects).- Always return
Ok(ToolOutput::error(...))for user-facing errors (bad params, file not found). UseErr(ToolError::...)only for programmer errors or truly unexpected failures. - Keep
execute()focused. Delegate I/O totokio::fs/tokio::processdirectly; no need for an intermediate service layer for simple tools.
- Create
crates/qcr-core/src/llm/<provider>.rsimplementingContentGenerator:
use async_trait::async_trait;
use crate::llm::traits::ContentGenerator;
use crate::llm::types::{GenerateContentRequest, GenerateContentResponse, StreamEventStream};
use crate::error::LlmError;
pub struct MyProvider { /* fields */ }
#[async_trait]
impl ContentGenerator for MyProvider {
async fn generate(&self, req: GenerateContentRequest)
-> Result<GenerateContentResponse, LlmError> { todo!() }
async fn stream(&self, req: GenerateContentRequest)
-> Result<StreamEventStream, LlmError> { todo!() }
fn model_name(&self) -> &str { "my-model" }
}-
Wire into
crates/qcr-core/src/llm/factory.rscreate_generator()match. -
Add provider name → constructor mapping and API key env var resolution.
When serialising conversation history to the OpenAI chat completions wire format,
always emit "content" on every assistant message, even when the turn contains
only tool calls and no text. Omitting the field causes a 400 Bad Request from
Copilot, Azure, and many OpenAI-compatible proxies.
// Correct — content: null when no text, string when there is text
serde_json::json!({
"role": "assistant",
"content": if assistant.text.is_empty() {
serde_json::Value::Null
} else {
serde_json::Value::String(assistant.text.clone())
},
// tool_calls appended separately if non-empty
})
// Wrong — omitting content entirely causes 400 Bad Request
serde_json::json!({
"role": "assistant",
// "content" field absent ← 400
})This rule applies to openai.rs, copilot.rs, and azure.rs (fixed in v0.1.2).
Add a regression test for any new provider that handles tool calls:
#[test]
fn assistant_tool_call_only_message_has_null_content() {
let msg = Message::Assistant(AssistantMessage {
text: "".to_string(),
thinking: None,
tool_calls: vec![ToolCall { id: "c1".into(), name: "ReadFile".into(),
arguments: serde_json::json!({"path": "a.rs"}) }],
});
let json = message_to_json(&msg);
assert_eq!(json["content"], serde_json::Value::Null);
}Global config lives at ~/.qcr/settings.json. Project config at .qcr/settings.json. Both are Qwen Code RustConfig JSON (see docs/configuration.md).
Quick example:
{
"model": { "model_id": "gemini-2.5-flash", "provider": "openai" },
"providers": {
"openai": {
"name": "openai",
"api_key": "sk-...",
"base_url": "https://aiproxies.win/v1"
}
},
"approval_mode": "suggest"
}GitHub Actions runs on every push and PR:
- test job:
ubuntu-latest+macos-latestmatrix →cargo build+cargo test --workspace - lint job:
cargo fmt --all --check+cargo clippy --workspace -- -D warnings
See .github/workflows/ci.yml.
Tag a version to trigger cross-platform binary builds:
git tag v0.2.0
git push origin v0.2.0Builds 6 targets (linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64/aarch64) and creates a GitHub Release with compressed archives.
See .github/workflows/release.yml.
<type>(<scope>): <short description>
feat(tools): add WebFetchTool with HTML stripping
fix(llm): drain SSE pending buffer before reading next chunk
test(e2e): add wiremock headless tests
docs: add architecture overview
Types: feat, fix, test, docs, refactor, perf, chore.