Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,53 @@ follow [SemVer](https://semver.org/spec/v2.0.0.html) with the
caveat that pre-1.0 minor bumps may include breaking changes
(documented in the release notes).

## v0.8.0 — proactive recall, selectable embedding model, full MCP control

The release that makes the brain **proactive** and gives the agent (and user)
full control over it from inside Claude Code / Codex.

ADDED
* **Proactive recall (mid-work).** New `PreToolUse` / `PostToolUse` Bash hooks
surface a relevant memory *while the agent works*, not just on prompt:
- after a **failed** Bash command, surface a matching `failure_pattern` /
`command` fix;
- **before** a risky Bash command, warn from a matching `failure_pattern`;
- on a **repeated** failing command (loop), lower the score floor and bypass
the throttle so help arrives sooner.
Retrieval is lexical-FTS-only (no embedding-model load), gated by a high
score floor (0.45; loop 0.35), capped at one capsule, with per-session
dedupe + a refractory throttle. Token cost stays ~0 (silent when nothing
qualifies). Wired into both Claude Code (`.claude/settings.json`) and Codex
(`.codex/hooks.json`) with a `matcher: "Bash"`; opt out with
`kimetsu plugin install --no-proactive` (or `proactive:false` over MCP).
Per-session state lives in `<repo>/.kimetsu/proactive/<session_id>.json`
and is garbage-collected after 7 days.
* **Selectable embedding model.** New `[embedder]` table in `project.toml`
(precedence: `KIMETSU_BRAIN_EMBEDDER` env > config > default). Inspect and
change it with `kimetsu brain model list` / `kimetsu brain model set <id>`
(the latter writes the config and re-embeds the corpus with the new model).
Curated built-ins: `bge-small-en-v1.5` (384d, default), `bge-m3` (1024d),
`jina-v2-base-code` (768d).
* **Full MCP control surface.** New tools so an agent can manage the brain
without leaving Claude Code / Codex: `kimetsu_brain_model_list` /
`kimetsu_brain_model_set` (re-embeds in-process with the new model),
`kimetsu_brain_reindex`, `kimetsu_brain_memory_search` (FTS over memory
text), `kimetsu_brain_conflict_resolve`, `kimetsu_brain_prune`, and
`kimetsu_brain_config_show`. `kimetsu_brain_memory_list` and
`..._memory_proposals` gained `limit`/`offset` pagination.

CHANGED
* `reindex` can now run against an explicit embedder
(`reindex_all_with_embedder`), so a model switch re-embeds with the chosen
model regardless of the process's cached default embedder.

FIXED
* **Test isolation.** Tests created project roots under the system temp dir;
on a machine whose `$HOME` is itself a git repo, `ProjectPaths::discover`
climbed to `$HOME` and made parallel tests share one `brain.db` +
`project.lock`. Each test root now gets its own git boundary, so plain
`cargo test` is hermetic.

## v0.7.2 — remove kimetsu-harbor-rs; first crates.io publish of the v0.7 line

Maintenance + distribution release, layered on top of the v0.7.1 security
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ resolver = "3"
# everything below except the per-crate `description`, `keywords`,
# and `categories` which live in each `[package]` block since
# crates.io enforces them per crate.
version = "0.7.3"
version = "0.8.0"
edition = "2024"
# Rust ecosystem dual-license (matches tokio, serde, fastembed-rs,
# etc.). UNLICENSED would block crates.io entirely.
Expand Down
4 changes: 2 additions & 2 deletions crates/kimetsu-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ embeddings = ["kimetsu-brain/embeddings"]

[dependencies]
blake3.workspace = true
kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.3" }
kimetsu-core = { path = "../kimetsu-core", version = "0.7.3" }
kimetsu-brain = { path = "../kimetsu-brain", version = "0.8.0" }
kimetsu-core = { path = "../kimetsu-core", version = "0.8.0" }
regex.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
Expand Down
3 changes: 3 additions & 0 deletions crates/kimetsu-agent/src/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ mod tests {
fn temp_project() -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("kimetsu-agent-loop-test-{}", new_id()));
fs::create_dir_all(&root).expect("create temp root");
// Isolate from any enclosing git repo so discover() resolves
// here, not a shared ancestor (see git_init_boundary).
kimetsu_core::paths::git_init_boundary(&root);
root
}
}
65 changes: 38 additions & 27 deletions crates/kimetsu-agent/src/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,10 @@ fn run_task_mode(
) -> KimetsuResult<BenchTaskResult> {
let repo = fixture_root.join(format!("{}-{}", task.id, mode.as_str()));
write_fixture_repo(&repo, task)?;
// Make each fixture its own git boundary so its brain initializes
// inside the fixture, never climbing to an enclosing repo (e.g. a
// dev's $HOME repo) and leaking fixture memories there.
kimetsu_core::paths::git_init_boundary(&repo);
project::init_project(&repo, false)?;
if let Some(model_config) = model_config {
configure_fixture_model(&repo, model_config, remaining_cost_usd)?;
Expand Down Expand Up @@ -1881,35 +1885,42 @@ mod tests {

#[test]
fn benchmark_reports_warm_memory_reuse() {
let repo = std::env::temp_dir().join(format!("kimetsu-bench-report-{}", new_id()));
fs::create_dir_all(&repo).expect("create repo");

let report = run_benchmark(BenchOptions {
repo: repo.clone(),
keep_fixtures: false,
model_backed: false,
limit: None,
max_cost_usd: 1.0,
})
.expect("run benchmark");

assert_eq!(report.task_count, 16);
assert!(report.report_path.exists());
assert!(report.results_path.exists());
let warm = report
.summaries
.iter()
.find(|summary| summary.mode == "brain_on_warm")
.expect("warm summary");
assert!(warm.accepted_memories_used >= report.task_count as u32);
assert!(!warm.stage_profiles.is_empty());
assert!(
warm.stage_profiles
// Isolate from the developer's real user brain
// (`~/.kimetsu/brain.db`): the multi-task run opens many
// connections, and sharing the one real file serializes them
// into SQLITE_BUSY. Fixture repos are already git-isolated (see
// run_task_mode), so this only scopes the user brain.
kimetsu_brain::user_brain::with_user_brain_disabled(|| {
let repo = std::env::temp_dir().join(format!("kimetsu-bench-report-{}", new_id()));
fs::create_dir_all(&repo).expect("create repo");

let report = run_benchmark(BenchOptions {
repo: repo.clone(),
keep_fixtures: false,
model_backed: false,
limit: None,
max_cost_usd: 1.0,
})
.expect("run benchmark");

assert_eq!(report.task_count, 16);
assert!(report.report_path.exists());
assert!(report.results_path.exists());
let warm = report
.summaries
.iter()
.any(|profile| profile.stage == "repo_scan")
);
.find(|summary| summary.mode == "brain_on_warm")
.expect("warm summary");
assert!(warm.accepted_memories_used >= report.task_count as u32);
assert!(!warm.stage_profiles.is_empty());
assert!(
warm.stage_profiles
.iter()
.any(|profile| profile.stage == "repo_scan")
);

fs::remove_dir_all(repo).expect("cleanup");
fs::remove_dir_all(repo).expect("cleanup");
});
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/kimetsu-agent/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,8 @@ mod tests {
fn dry_run_pipeline_writes_trace_patch_plan_and_report() {
let root = std::env::temp_dir().join(format!("kimetsu-pipeline-test-{}", new_id()));
fs::create_dir_all(root.join("src")).expect("create src");
// Isolate from any enclosing git repo (see git_init_boundary).
kimetsu_core::paths::git_init_boundary(&root);
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
Expand Down
2 changes: 2 additions & 0 deletions crates/kimetsu-agent/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,8 @@ mod tests {
fn temp_project() -> PathBuf {
let root = std::env::temp_dir().join(format!("kimetsu-tools-test-{}", new_id()));
fs::create_dir_all(&root).expect("create temp root");
// Isolate from any enclosing git repo (see git_init_boundary).
kimetsu_core::paths::git_init_boundary(&root);
root
}
}
2 changes: 1 addition & 1 deletion crates/kimetsu-brain/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ blake3.workspace = true
# supports BGE-M3 + Jina-v2-base-code alongside BGE-small.
fastembed = { version = "5", optional = true }
ignore.workspace = true
kimetsu-core = { path = "../kimetsu-core", version = "0.7.3" }
kimetsu-core = { path = "../kimetsu-core", version = "0.8.0" }
# v0.4.5: regex backs the secret-redaction patterns in
# `kimetsu_brain::redact`. The crate is already a workspace pin
# elsewhere; we just opt this crate into it now.
Expand Down
69 changes: 68 additions & 1 deletion crates/kimetsu-brain/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ pub struct ContextRequest {
/// of these strings receive an additional 1.3× multiplier after the
/// tag boost (e.g. `["semantic_operator", "anti_pattern"]` for bench).
pub prefer_roles: Vec<String>,
/// v0.8: hard kind filter applied BEFORE scoring + capping. When
/// non-empty, only candidates whose capsule `kind` is in this list
/// survive — so a higher-ranked repo file or off-kind memory can't
/// consume a (often single) slot. Used by the proactive engine to
/// restrict recall to actionable kinds (failure_pattern, command,
/// convention). Empty (default) keeps all kinds, prior behaviour.
pub kinds: Vec<String>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -185,6 +192,21 @@ pub fn retrieve_context_with_embedder(
candidates.extend(repo_file_candidates(conn, repo_root, &request.query, 30)?);
candidates.extend(manifest_candidates(conn, repo_root, &request.query)?);

// v0.8: proactive kind filter — restrict to actionable kinds BEFORE
// scoring + capping so a higher-ranked repo file or off-kind memory
// can't take the proactive slot and get filtered out afterwards.
// Memory capsules carry the generic `kind: "memory"` and encode the
// real memory kind in the summary prefix ("scope:kind - text"), so
// match against that for memories.
if !request.kinds.is_empty() {
candidates.retain(|c| {
request
.kinds
.iter()
.any(|k| capsule_matches_kind(&c.capsule, k))
});
}

normalize_and_score(&mut candidates, weights_for_stage(weights, &request.stage));

// v0.6: apply tag boost (1.4×) and role-preference boost (1.3×) after
Expand Down Expand Up @@ -1025,7 +1047,24 @@ const CLASS_HINTS: &[(&[&str], &[&str])] = &[
(&["rename", "move file", "mv "], &["move_file"]),
];

fn fts_query(query: &str) -> Option<String> {
/// v0.8: does a capsule satisfy a requested (memory) kind? Repo/manifest
/// capsules match only by their literal `kind`; memory capsules
/// (`kind == "memory"`) match against the real kind embedded in their
/// `"scope:kind - text"` summary prefix.
fn capsule_matches_kind(capsule: &ContextCapsule, wanted: &str) -> bool {
if capsule.kind == wanted {
return true;
}
if capsule.kind == "memory"
&& let Some((prefix, _)) = capsule.summary.split_once(" - ")
&& let Some((_scope, mkind)) = prefix.split_once(':')
{
return mkind == wanted;
}
false
}

pub(crate) fn fts_query(query: &str) -> Option<String> {
let tokens = query_tokens(query);
if tokens.is_empty() {
return None;
Expand Down Expand Up @@ -1149,6 +1188,34 @@ fn one_line(text: &str) -> String {
mod tests {
use super::*;

fn capsule(kind: &str, summary: &str) -> ContextCapsule {
ContextCapsule {
id: "c".into(),
kind: kind.into(),
summary: summary.into(),
token_estimate: 1,
expansion_handle: "memory:x".into(),
provenance: vec![],
confidence: 1.0,
freshness: 1.0,
relevance: 1.0,
scope_weight: 1.0,
score: 1.0,
}
}

#[test]
fn capsule_matches_kind_reads_memory_summary_prefix() {
// Memory capsule: real kind lives in the "scope:kind - text" prefix.
let mem = capsule("memory", "project:failure_pattern - linker not found");
assert!(capsule_matches_kind(&mem, "failure_pattern"));
assert!(!capsule_matches_kind(&mem, "command"));
// Non-memory capsules match only by literal kind, never via prefix.
let repo = capsule("repo_file", "src/lib.rs:command - run build");
assert!(capsule_matches_kind(&repo, "repo_file"));
assert!(!capsule_matches_kind(&repo, "command"));
}

/// MP-17e: zero-use rows are neutral (no data); use_count >= 1 starts
/// blending toward the full multiplier (Bayesian smoothing).
#[test]
Expand Down
Loading
Loading