Skip to content

Caveman L1 integration: port markdown compressor to Rust - #117

Merged
getappz merged 9 commits into
masterfrom
worktree-caveman-l1-integration
Jul 9, 2026
Merged

Caveman L1 integration: port markdown compressor to Rust#117
getappz merged 9 commits into
masterfrom
worktree-caveman-l1-integration

Conversation

@getappz

@getappz getappz commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • Port the Python caveman-compress markdown/prose LLM-compressor into a new Rust workspace crate crates/caveman (agentflare-caveman), mirroring how ponytail was similarly ported ("L1 integration"): frontmatter/sensitive-path/structural-validation/LLM-invocation/orchestration all as small, independently-tested modules.
  • Add agentflare caveman compress CLI subcommand — agentflare caveman compress <file> for generic compression, or --spec-file <path> --backup sibling <source> <target> for a caller-supplied compression spec (used by short-skill).
  • Fix two bugs found in the ported Python version along the way: a frontmatter regex that didn't handle CRLF line endings, and inconsistent UTF-8 handling (only pinned on the subprocess path, not on plain file I/O).
  • Fix two further bugs found during implementation review (both independently re-verified in the final whole-branch review): a stdin/stdout pipe-deadlock risk when invoking the claude CLI, and an integer-underflow panic in the CLI's percentage display when an LLM response is larger than its input.
  • Migrate both existing Python callers of the old duplicated compression logic (~/.claude/skills/short-skill/resolve.py, the caveman-compress plugin's own cli.py) to shell out to the new Rust command instead — the old Python compress.py/validate.py are deleted. These two edits are outside this repository (user-owned Claude Code skill/plugin files) and aren't part of this diff, but are the reason this crate exists.

Test plan

  • cargo build --workspace
  • cargo test --workspace (fully green — 30 new tests in crates/caveman plus a real end-to-end CLI integration test that drives the actual compiled binary against a stubbed claude executable on PATH)

Summary by CodeRabbit

  • New Features
    • Added a caveman compress command to compress Markdown with in-place editing or writing to a separate target.
    • Added an optional spec file for custom compression instructions.
    • Added configurable in-place backups, with sibling or out-of-tree backup locations.
  • Bug Fixes
    • Improved preservation of YAML frontmatter (including CRLF/LF), and added safeguards for empty, too-large, sensitive, or unchanged inputs.
    • Added validation and retry-based fixes to better preserve headings, fenced code blocks, links, and inline code.
  • Tests
    • Added a CLI integration test using a stubbed claude executable.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f7ec9c8b-bd84-4297-85a2-3efb01f9fe1a

📥 Commits

Reviewing files that changed from the base of the PR and between e61dd4b and 99162eb.

📒 Files selected for processing (2)
  • crates/caveman/src/compress.rs
  • crates/caveman/src/validate.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/caveman/src/validate.rs
  • crates/caveman/src/compress.rs

📝 Walkthrough

Walkthrough

This PR adds a new agentflare-caveman crate for markdown compression, including LLM-backed compression, frontmatter and validation handling, backup/rollback behavior, and a new caveman compress CLI subcommand with integration tests.

Changes

Caveman Compression Feature

Layer / File(s) Summary
Crate scaffolding and workspace wiring
Cargo.toml, crates/caveman/Cargo.toml, crates/caveman/src/lib.rs
Adds crates/caveman to the workspace, declares the crate metadata and dependencies, and exposes the crate modules and public API re-exports.
Error types
crates/caveman/src/error.rs
Defines CavemanError variants for file checks, backup handling, LLM failures, empty/identical output, and validation failure reporting.
LLM trait and backends
crates/caveman/src/llm.rs
Adds Llm, RealLlm, and test-only FakeLlm, with Anthropic API and claude --print execution paths.
Prompt building
crates/caveman/src/prompt.rs
Adds Prompt with generic and custom compression prompts, plus fix prompts from validation errors.
Frontmatter and sensitivity checks
crates/caveman/src/frontmatter.rs, crates/caveman/src/sensitive.rs
Adds YAML frontmatter splitting and sensitive-path detection based on filenames, directories, and token matching.
Content validation
crates/caveman/src/validate.rs
Implements comparison of headings, fenced code blocks, URLs, and inline code between original and compressed text.
Compress orchestration
crates/caveman/src/compress.rs
Implements compression workflow, backup path handling, retry/repair logic, and the BackupMode and Report types.
CLI integration and tests
src/cli/caveman.rs, src/cli/mod.rs, tests/caveman_cli.rs
Adds the caveman subcommand, wires it into CLI dispatch, and adds an integration test using a stubbed claude binary.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • getappz/agentflare#60: Both PRs touch the top-level CLI dispatch wiring in src/cli/mod.rs to add or change subcommand handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: porting the caveman markdown compressor to Rust as an L1 integration.
Description check ✅ Passed The PR description covers the required Summary and Test plan sections, but it omits the Notes for reviewers section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-caveman-l1-integration

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/caveman/src/compress.rs`:
- Around line 65-73: The retry path in compress handling is re-adding
frontmatter even though build_fix_prompt already returns the full fixed
compressed file, which can duplicate frontmatter and break markdown. Update the
logic around the compressed_body handling in compress.rs so the fix output is
used as-is, or adjust build_fix_prompt to return body-only content to match
build_compress_prompt; keep the behavior consistent in the compression and retry
flow.
- Around line 87-90: The `<source> <target>` handling in `compress.rs` can
destroy an existing target when validation fails because `std::fs::write`
replaces the file before the later cleanup removes it. Update the logic around
the target write path in the compression flow to use a temporary file or to back
up and restore any pre-existing target before swapping it in, and ensure the
failure cleanup only removes the newly created artifact rather than the original
target contents.

In `@crates/caveman/src/llm.rs`:
- Around line 50-80: The call_via_cli path can hang forever because
wait_with_output() has no deadline; add the same 120s timeout used by the HTTP
LLM path and make sure the spawned Command child is terminated if it expires.
Update call_via_cli in llm.rs to monitor the claude process, kill it on timeout,
and return a CavemanError::Llm that clearly reports the timeout for the claude
--print invocation.

In `@crates/caveman/src/validate.rs`:
- Around line 93-97: The heading validation in validate compares only the
lengths of extract_headings(orig) and extract_headings(comp), so renamed or
re-leveled headings can still pass. Update the validation logic in validate.rs
to compare the full heading tuples returned by extract_headings, not just the
counts, and keep the error reporting aligned with this stricter check. If you
adjust the mismatch message, also update the heading_count_mismatch_is_an_error
test to match the new text.

In `@src/cli/caveman.rs`:
- Around line 6-9: The `caveman` command docs and implementation are out of
sync: `--spec-file` is documented to default to a sibling backup, but the
`backup_mode` logic still falls back to `BackupMode::OutOfTree` when `--backup`
is omitted. Update the `backup_mode` match in `caveman` so it checks whether
`spec_file` is set and selects `BackupMode::Sibling` by default for that path,
while preserving the existing out-of-tree default for the non-`spec_file` flow.
Also keep the doc comment aligned with the actual behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e207e672-ec07-46d8-b138-7bdac4bdc6bb

📥 Commits

Reviewing files that changed from the base of the PR and between f93b545 and e61dd4b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • Cargo.toml
  • crates/caveman/Cargo.toml
  • crates/caveman/src/compress.rs
  • crates/caveman/src/error.rs
  • crates/caveman/src/frontmatter.rs
  • crates/caveman/src/lib.rs
  • crates/caveman/src/llm.rs
  • crates/caveman/src/prompt.rs
  • crates/caveman/src/sensitive.rs
  • crates/caveman/src/validate.rs
  • src/cli/caveman.rs
  • src/cli/mod.rs
  • tests/caveman_cli.rs

Comment on lines +65 to +73
let compressed_body = llm.call(&prompt.build_compress_prompt(&body))?;
if compressed_body.trim().is_empty() {
return Err(CavemanError::EmptyResponse);
}
if compressed_body.trim() == body.trim() {
return Err(CavemanError::IdenticalOutput);
}

let mut compressed = format!("{frontmatter_text}{compressed_body}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep run --pattern 'fn build_fix_prompt($$$) { $$$ }' --lang rust crates/caveman/src/prompt.rs || \
  rg -nP -C3 '\bbuild_fix_prompt\b' crates/caveman/src/prompt.rs

Repository: getappz/agentflare

Length of output: 860


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- crates/caveman/src/prompt.rs ---'
sed -n '1,140p' crates/caveman/src/prompt.rs

echo
echo '--- crates/caveman/src/compress.rs (relevant section) ---'
sed -n '1,180p' crates/caveman/src/compress.rs

Repository: getappz/agentflare

Length of output: 10101


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' crates/caveman/src/validate.rs

Repository: getappz/agentflare

Length of output: 6392


Avoid re-prepending frontmatter in the retry path. build_fix_prompt already returns the fixed compressed file, but this code wraps that output in frontmatter_text again. For files with frontmatter, that can duplicate it and produce malformed markdown. Return the fix output as-is, or make the retry prompt body-only to match the initial compression path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/caveman/src/compress.rs` around lines 65 - 73, The retry path in
compress handling is re-adding frontmatter even though build_fix_prompt already
returns the full fixed compressed file, which can duplicate frontmatter and
break markdown. Update the logic around the compressed_body handling in
compress.rs so the fix output is used as-is, or adjust build_fix_prompt to
return body-only content to match build_compress_prompt; keep the behavior
consistent in the compression and retry flow.

Comment thread crates/caveman/src/compress.rs
Comment thread crates/caveman/src/llm.rs
Comment on lines +50 to +80
fn call_via_cli(prompt: &str) -> Result<String, CavemanError> {
let claude_bin = which::which("claude")
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "claude".to_string());
let mut child = Command::new(&claude_bin)
.arg("--print")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CavemanError::Llm(format!("spawn '{claude_bin}' failed: {e}")))?;
// Write stdin on a separate thread, concurrently with wait_with_output()
// draining stdout/stderr below — writing the whole prompt first and only
// then waiting would deadlock if the child fills its stdout/stderr pipe
// buffer before finishing reading stdin (both sides then block forever).
let mut stdin = child.stdin.take().expect("stdin was piped");
let prompt_owned = prompt.to_string();
let writer = std::thread::spawn(move || stdin.write_all(prompt_owned.as_bytes()));
let output = child
.wait_with_output()
.map_err(|e| CavemanError::Llm(format!("'{claude_bin}' failed: {e}")))?;
let write_result = writer
.join()
.map_err(|_| CavemanError::Llm(format!("stdin writer thread for '{claude_bin}' panicked")))?;
write_result.map_err(|e| CavemanError::Llm(format!("write to '{claude_bin}' stdin failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CavemanError::Llm(format!("Claude call failed:\n{stderr}")));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

sed -n '1,180p' crates/caveman/src/llm.rs

Repository: getappz/agentflare

Length of output: 4730


🏁 Script executed:

rg -n "call_via_cli|wait_with_output|timeout|cancel|ctrl_c|kill\(" crates -g '!target'

Repository: getappz/agentflare

Length of output: 10027


Add a timeout for the claude subprocess. wait_with_output() can block forever if claude --print hangs, since this path has no watchdog or cancellation. Mirror the HTTP path’s 120s deadline and kill the child on expiry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/caveman/src/llm.rs` around lines 50 - 80, The call_via_cli path can
hang forever because wait_with_output() has no deadline; add the same 120s
timeout used by the HTTP LLM path and make sure the spawned Command child is
terminated if it expires. Update call_via_cli in llm.rs to monitor the claude
process, kill it on timeout, and return a CavemanError::Llm that clearly reports
the timeout for the claude --print invocation.

Comment thread crates/caveman/src/validate.rs
Comment thread src/cli/caveman.rs
Comment on lines +6 to +9
/// Compress a markdown file. With no --spec-file, uses caveman's own
/// generic compression prompt and backs up out-of-tree. With
/// --spec-file, uses the given spec text as the compression prompt
/// (used by short-skill) and defaults to a sibling backup.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Doc comment promises a sibling-backup default for --spec-file that the code doesn't implement.

The doc comment says: --spec-file, uses the given spec text ... and defaults to a sibling backup. But the backup_mode match (lines 42-49) never inspects spec_file — when --backup is omitted it always resolves to BackupMode::OutOfTree, regardless of whether spec_file is set. This is a functional mismatch between documented behavior and the implementation; a caller invoking --spec-file without an explicit --backup gets OutOfTree instead of the promised Sibling.

🐛 Proposed fix to make backup default depend on spec_file
                 let backup_mode = match backup.as_deref() {
                     Some("sibling") => caveman::BackupMode::Sibling,
-                    Some("out-of-tree") | None => caveman::BackupMode::OutOfTree,
+                    Some("out-of-tree") => caveman::BackupMode::OutOfTree,
+                    None => if spec_file.is_some() {
+                        caveman::BackupMode::Sibling
+                    } else {
+                        caveman::BackupMode::OutOfTree
+                    },
                     Some(other) => {
                         eprintln!("--backup must be 'sibling' or 'out-of-tree', got '{other}'");
                         std::process::exit(1);
                     }
                 };

Also applies to: 42-49

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/caveman.rs` around lines 6 - 9, The `caveman` command docs and
implementation are out of sync: `--spec-file` is documented to default to a
sibling backup, but the `backup_mode` logic still falls back to
`BackupMode::OutOfTree` when `--backup` is omitted. Update the `backup_mode`
match in `caveman` so it checks whether `spec_file` is set and selects
`BackupMode::Sibling` by default for that path, while preserving the existing
out-of-tree default for the non-`spec_file` flow. Also keep the doc comment
aligned with the actual behavior.

…ath collisions

- validate(): compare heading (level, text) tuples, not just h1.len() vs
  h2.len() — a same-count-but-reworded heading previously passed silently,
  even though the compress/fix prompts both promise exact heading
  preservation.
- backup_path_for(OutOfTree): hash the full canonicalized parent path
  instead of just its last component — two files with the same name under
  differently-located but identically-named parent dirs (e.g.
  project-a/docs/README.md and project-b/docs/README.md) previously
  collided on the same backup path.

Both covered by new tests; full workspace test suite passes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant