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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## v0.6.8

### Critical bug fixes

- **scavenger.rs:91 atomic write**: Replaced `std::fs::write` (non-atomic) with `heal::atomic_write` (tmp + fsync + rename). Process crash mid-write no longer corrupts scavenger-modified files.
- **scavenger.rs:80 size guard**: Added 10MB file size check before `read_to_string`. Prevents OOM on multi-GB files in user home directories.
- **ux.rs:403 wrong table name**: `SELECT COUNT(DISTINCT file_id) FROM file_phrases` failed silently (no such table). Fixed to `SELECT COUNT(*) FROM file_map`. Status command now reports correct file count.
- **heal.rs:63 hardcoded cargo test**: Heal pipeline now detects project type (Cargo.toml → cargo, pyproject.toml → pytest, package.json → npm test, go.mod → go test). Non-Rust projects no longer get edits reverted due to non-existent test runners.
- **antidecision.rs extract_sed_target rewrite**: Fixed function that captured the sed `s/old/new/` pattern instead of the file path. Added `looks_like_sed_pattern` to filter sed-shaped words. Antidecision now records the correct file for sed operations.

### High priority

- **antidecision.rs grammar-free keywords**: Replaced 25-item hardcoded keyword ban (violated grammar-free principles) with structural heuristics: skip all-lowercase identifiers < 5 chars, prefer uppercase/underscore/length ≥ 6.
- **routes.rs normalize_url host-only check**: `is_anthropic_host` now checks only the URL host for "anthropic" substring, not arbitrary path substrings. Fixes false positives like `/v1/anthropic-proxy/chat`.
- **init.rs uninstall Pi detection**: Replaced `pi --version` exec (could hang) with PATH scan. Cleaner removal logic.
- **README.md Claude Code limitation**: Documented that Anthropic `/v1/messages` requests get a 501.

### Code quality

- **config.rs:122 default mode**: Changed default from `Reactive` to `Strict` (matches CONFIG.md and README).
- **mcp.rs:53 SQL PRAGMA corruption**: ` synchronous=NORMAL;` → `PRAGMA synchronous=NORMAL;` (silent no-op fixed).
- **ux.rs:402 SQL PRAGMA corruption**: ` synchronous=;` → `PRAGMA synchronous=NORMAL;` (silent no-op fixed).
- **scavenger.rs:60 SQL PRAGMA corruption**: ` wal_checkpoint();` → `PRAGMA wal_checkpoint(PASSIVE);` (silent no-op fixed).
- **reindex.rs:118 bitwise OR**: `content.contains("x") | content.contains("y")` → `||` (correct logical OR, no clippy warning).
- **daemon.rs MAX_FILE_SIZE made public**: Moved shared constant so proxy.rs uses the same value. Removed magic number 10_000_000 from proxy.rs.
- **antidecision.rs dead code removed**: Deleted `format_annotation`, `annotate_tool_result` (33 lines of dead code, never called). Marked `query_anti_decisions`, `BUILTIN_PRIORS`, `LOADED_WORKDIRS` with `#[allow(dead_code)]` since they're test-only.

### Tests

- Added 10 new unit tests covering all fixed bugs.
- Total: 267 tests passing, 0 failures.

## v0.6.6
## v0.6.6

### Compression Ceiling Breakthrough
Expand Down
20 changes: 10 additions & 10 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 @@ -15,7 +15,7 @@ members = [
resolver = "2"

[workspace.package]
version = "0.6.6"
version = "0.6.8"
edition = "2021"
description = "Grammar-free code intelligence daemon, CLI, MCP server, and API proxy."
license = "MIT"
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,14 @@ your upstream from agent configs, or you can set `RELIARY_UPSTREAM_URL` as a fal

## Troubleshooting

### "Claude Code returns 501 Not Implemented"

The proxy currently supports OpenAI-compatible chat completions only. Anthropic
`/v1/messages` requests receive a 501 with a clear error. To use Claude Code
through the proxy today, route it to an OpenAI-compatible proxy (LiteLLM,
OpenRouter, or a self-hosted OpenAI-compatible endpoint) via
`RELIARY_UPSTREAM_URL`, or run the agent without the proxy.

### "Proxy is not compressing anything"

```bash
Expand Down
153 changes: 81 additions & 72 deletions crates/reliary-agent/src/antidecision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@ type CounterMap = FxHashMap<String, (usize, usize)>;
pub static ANTI_DB: once_cell::sync::Lazy<Mutex<FxHashMap<String, CounterMap>>> =
once_cell::sync::Lazy::new(|| Mutex::new(FxHashMap::default()));

#[allow(dead_code)]
static LOADED_WORKDIRS: once_cell::sync::Lazy<Mutex<FxHashSet<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(FxHashSet::default()));

#[allow(dead_code)]
const BUILTIN_PRIORS: &[&str] = &[
"unwrap", "legacy", "hack", "todo", "TODO", "FIXME",
"debug_", "temp", "old_text", "clone",
];

#[allow(dead_code)]
fn builtin_prior_count(identifier: &str) -> usize {
if BUILTIN_PRIORS.contains(&identifier) { 1 } else { 0 }
}
Expand All @@ -65,19 +68,22 @@ fn extract_primary_identifier(text: &str, file: &str) -> String {
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
// Grammar-free: filter only by structural properties (length, alpha-leading, case mix)
// and exclude the file stem. We do NOT use a hardcoded keyword list — common words
// are filtered by structural heuristics instead.
let identifiers: Vec<&str> = text.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|s| s.len() >= 3 && s.len() <= 40)
.filter(|s| s.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_'))
.filter(|s| *s != "edit" && *s != "apply" && *s != "write" && *s != "bash"
&& *s != "file" && *s != "sed" && *s != "old" && *s != "new"
&& *s != "text" && *s != "content" && *s != "from" && *s != "with"
&& *s != "replaced" && *s != "applied" && *s != "successfully"
&& *s != "wrote" && *s != "bytes" && *s != "error" && *s != "failed"
&& *s != "result" && *s != "stdout" && *s != "stderr" && *s != "exit"
&& *s != file_stem)
.filter(|s| *s != file_stem)
// Skip identifiers that are all lowercase AND < 5 chars — these are
// overwhelmingly common English words ("the", "and", "for"). This is
// a structural heuristic, not a keyword list.
.filter(|s| !(s.chars().all(|c| c.is_ascii_lowercase()) && s.len() < 5))
.collect();
if let Some(id) = identifiers.iter().find(|id| {
id.chars().any(|c| c.is_uppercase()) || id.contains('_')
// Prefer identifiers that look function-y: mixed case or contain underscores
// OR are at least 6 chars (longer words are more likely project-specific).
id.chars().any(|c| c.is_uppercase()) || id.contains('_') || id.len() >= 6
}) {
return id.to_string();
}
Expand All @@ -92,15 +98,19 @@ fn is_interesting_ident(s: &str) -> bool {
}

fn extract_sed_target(content: &str) -> Option<(String, String)> {
// Grammar-free sed pattern extraction: look for "sed -i 's/pattern/replacement/' filepath"
// Grammar-free sed pattern extraction. Skip flag words (-i, -e, -E, etc.) and
// s/.../.../ pattern words; the remaining word containing a path separator
// or file extension is the file path.
let content_lower = content.to_lowercase();
if !content_lower.contains("sed") { return None; }

let file = content.split_whitespace()
.filter(|w| !w.starts_with('-') && !w.starts_with('\'') && !w.starts_with('"'))
.filter(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
.filter(|w| !w.starts_with('-')) // skip flags
.filter(|w| !w.starts_with('\'') && !w.starts_with('"')) // skip quoted patterns
.filter(|w| !looks_like_sed_pattern(w)) // skip s/foo/bar/ patterns
.filter(|w| w.contains('/') || w.contains('\\') || w.contains('.'))
.map(|w| w.trim_matches(|c: char| c == '\'' || c == '"' || c == ';').to_string())
.find(|w| !w.is_empty())?;
.find(|w| !w.is_empty() && w.len() >= 3)?;

if file.is_empty() { return None; }

Expand All @@ -109,6 +119,23 @@ fn extract_sed_target(content: &str) -> Option<(String, String)> {
Some((file, if success { "success".to_string() } else { "fail".to_string() }))
}

// A word is a sed pattern if it starts with 's' (or 'y') followed by a
// non-alphanumeric delimiter (commonly /, |, #, @, etc.) — the s/old/new/ form.
fn looks_like_sed_pattern(w: &str) -> bool {
looks_like_sed_pattern_inner(w)
}

fn looks_like_sed_pattern_inner(w: &str) -> bool {
let bytes = w.as_bytes();
if bytes.len() < 5 { return false; }
let first = bytes[0];
if first != b's' && first != b'y' { return false; }
let delim = bytes[1];
if delim.is_ascii_alphanumeric() { return false; }
// Must contain at least 2 more delimiter bytes to qualify as s/old/new/ shape.
w.bytes().filter(|&b| b == delim).count() >= 2
}

pub fn extract_tool_call(msg: &Value) -> Option<(String, String, String, bool)> {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
if role != "tool" && role != "toolResult" { return None; }
Expand Down Expand Up @@ -155,6 +182,7 @@ pub fn record(workdir: &str, file: &str, identifier: &str, operation: &str, succ
}
}

#[allow(dead_code)]
pub fn load_persisted(workdir: &str) {
let db_path = format!("{}/.reliary/chronicle.sqlite", workdir.trim_end_matches('/'));
let events = match crate::chronicle::init(&db_path) {
Expand All @@ -178,6 +206,7 @@ pub fn load_persisted(workdir: &str) {
}
}

#[allow(dead_code)]
pub fn query_anti_decisions(workdir: &str, file: &str) -> Vec<(String, f64, usize, usize)> {
{
if let Ok(mut loaded) = LOADED_WORKDIRS.lock() {
Expand Down Expand Up @@ -209,30 +238,6 @@ pub fn query_anti_decisions(workdir: &str, file: &str) -> Vec<(String, f64, usiz
Vec::new()
}

#[allow(dead_code)]
pub fn format_annotation(identifiers: &[(String, f64, usize, usize)], max_tokens: usize) -> String {
if identifiers.is_empty() { return String::new(); }
let mut parts: Vec<String> = Vec::new();
for (id, _risk, fails, _succs) in identifiers.iter().take(max_tokens.min(5)) {
if *fails >= 2 {
parts.push(format!("-{}", id));
}
}
if parts.is_empty() { String::new() }
else { parts.join(" ") }
}

#[allow(dead_code)]
pub fn annotate_tool_result(text: &str, workdir: &str, file_name: &str) -> Option<String> {
let bad = query_anti_decisions(workdir, file_name);
if bad.is_empty() { return None; }
let annotation = format_annotation(&bad, 3);
if annotation.is_empty() { return None; }
let has_match = bad.iter().any(|(id, _, _, _)| text.contains(id));
if !has_match { return None; }
Some(annotation)
}

#[allow(dead_code)]
fn extract_identifiers(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric() && c != '_')
Expand Down Expand Up @@ -270,48 +275,12 @@ mod tests {
assert!(anti[0].2 >= 2);
}

#[test]
fn test_annotation_basic() {
let wd = clean_test_wd();
record(&wd, "src/auth.rs", "unwrap", "edit", false);
record(&wd, "src/auth.rs", "unwrap", "edit", false);
record(&wd, "src/auth.rs", "unwrap", "edit", false);

let annotation = annotate_tool_result(
"File src/auth.rs uses unwrap extensively", &wd, "src/auth.rs"
);
assert!(annotation.is_some());
assert!(annotation.unwrap().contains("-unwrap"));
}

#[test]
fn test_gating() {
let wd = clean_test_wd();
record(&wd, "src/auth.rs", "unwrap", "edit", false);
record(&wd, "src/auth.rs", "unwrap", "edit", false);

let annotation = annotate_tool_result(
"File src/auth.rs uses question_mark everywhere", &wd, "src/auth.rs"
);
assert!(annotation.is_none(), "should gate when identifier not mentioned: {:?}", annotation);
}

#[test]
fn test_builtin_priors() {
let anti = query_anti_decisions("/tmp/nonexistent", "src/unknown.rs");
assert!(anti.is_empty());
}

#[test]
fn test_format_annotation() {
let entries = vec![
("unwrap".to_string(), 0.85, 5, 0usize),
("legacy".to_string(), 0.70, 3, 1usize),
];
let ann = format_annotation(&entries, 3);
assert_eq!(ann, "-unwrap -legacy");
}

#[test]
fn test_extract_identifiers() {
let ids = extract_identifiers("edit src/auth.rs: changed unwrap to ?");
Expand Down Expand Up @@ -395,4 +364,44 @@ mod tests {
assert!((unwrap_risk.unwrap() - 1.0).abs() < 0.01, "unwrap should have risk 1.0 (3/3 failures incl built-in prior)");
assert!(!qm_present, "question_mark should NOT be in anti-decisions (0 failures)");
}

#[test]
fn test_looks_like_sed_pattern() {
// Standard s/old/new/ form
assert!(looks_like_sed_pattern_inner("s/old/new/"));
assert!(looks_like_sed_pattern_inner("s/foo/bar/g"));
// Alternative delimiters
assert!(looks_like_sed_pattern_inner("s|old|new|"));
assert!(looks_like_sed_pattern_inner("s#old#new#"));
// NOT sed patterns
assert!(!looks_like_sed_pattern_inner("src/main.rs"));
assert!(!looks_like_sed_pattern_inner("/tmp/file.txt"));
assert!(!looks_like_sed_pattern_inner("hello"));
assert!(!looks_like_sed_pattern_inner("ab"));
assert!(!looks_like_sed_pattern_inner("sfoo")); // missing delimiter
}

#[test]
fn test_extract_sed_target_finds_real_path() {
let result = extract_sed_target("sed -i s/old/new/ /tmp/file.txt");
assert!(result.is_some());
let (file, _) = result.unwrap();
assert_eq!(file, "/tmp/file.txt", "should find file path, not sed pattern");

let result2 = extract_sed_target("sed -i -e 's/x/y/' src/main.rs");
assert!(result2.is_some());
let (file2, _) = result2.unwrap();
assert_eq!(file2, "src/main.rs");
}

#[test]
fn test_extract_primary_identifier_no_keyword_list() {
// Should now return function-like identifiers based on structural properties
// (uppercase, underscore, length ≥ 6) without using a hardcoded keyword list.
let id = extract_primary_identifier("cargo test --quiet", "test_file.rs");
// Common short lowercase words should be skipped by length filter
// but "cargo" is 5 chars and all lowercase — should be skipped
// The function should return a more specific identifier
assert!(!id.is_empty());
}
}
Loading
Loading