From 14928381ecd023d8bcc86cbc55c85218f1167a16 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 5 Jul 2026 08:28:38 -0400 Subject: [PATCH 1/2] feat: complete rust-native memory lifecycle --- .github/workflows/ci.yml | 41 + .github/workflows/release.yml | 32 + Cargo.lock | 95 +- Cargo.toml | 5 +- README.md | 106 ++- bindings/python/Cargo.toml | 28 - bindings/python/README.md | 24 - bindings/python/build.rs | 3 - bindings/python/pyproject.toml | 23 - .../python/python/tree_ring_memory/.gitkeep | 1 - bindings/python/src/lib.rs | 840 ------------------ .../src/agent_awareness.rs | 3 + .../tree-ring-memory-cli/src/integrations.rs | 220 +++++ crates/tree-ring-memory-cli/src/main.rs | 372 +++++++- .../tree-ring-memory-cli/src/tui/actions.rs | 66 +- crates/tree-ring-memory-cli/src/tui/app.rs | 228 ++++- crates/tree-ring-memory-cli/src/tui/input.rs | 25 +- crates/tree-ring-memory-cli/src/tui/render.rs | 73 +- crates/tree-ring-memory-core/Cargo.toml | 1 + crates/tree-ring-memory-core/src/audit.rs | 2 +- crates/tree-ring-memory-core/src/dox.rs | 466 ++++++++++ crates/tree-ring-memory-core/src/lib.rs | 4 + crates/tree-ring-memory-core/src/models.rs | 4 +- crates/tree-ring-memory-core/src/revolve.rs | 420 +++++++++ docs/architecture/rust-core-roadmap.md | 36 +- docs/architecture/rust-core-status.md | 43 +- docs/feature/README.md | 8 + docs/superpowers/README.md | 19 + install.sh | 66 +- scripts/package-release.sh | 34 + skills/tree-ring-memory/SKILL.md | 18 +- 31 files changed, 2171 insertions(+), 1135 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml delete mode 100644 bindings/python/Cargo.toml delete mode 100644 bindings/python/README.md delete mode 100644 bindings/python/build.rs delete mode 100644 bindings/python/pyproject.toml delete mode 100644 bindings/python/python/tree_ring_memory/.gitkeep delete mode 100644 bindings/python/src/lib.rs create mode 100644 crates/tree-ring-memory-cli/src/integrations.rs create mode 100644 crates/tree-ring-memory-core/src/dox.rs create mode 100644 crates/tree-ring-memory-core/src/revolve.rs create mode 100644 docs/feature/README.md create mode 100644 docs/superpowers/README.md create mode 100755 scripts/package-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..80d5f61 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + +jobs: + rust: + name: Rust checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cargo fmt + run: cargo fmt --all -- --check + + - name: Cargo test + run: cargo test --workspace --locked + + - name: Installer syntax + run: sh -n install.sh + + - name: Installer help + run: sh install.sh --help + + - name: CLI smoke + run: | + cargo run -p tree-ring-memory-cli -- --help + cargo run -p tree-ring-memory-cli -- welcome --no-animation + cargo run -p tree-ring-memory-cli -- integrations scan --source-root . + cargo run -p tree-ring-memory-cli -- dox sync --source-root . --dry-run + + - name: Performance smoke + run: cargo run --release -p tree-ring-memory-sqlite --example performance_smoke -- 1000 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..456d78e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release Artifacts + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + package: + name: Package ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build package + run: sh scripts/package-release.sh + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: tree-ring-memory-${{ matrix.os }} + path: | + dist/*.tar.gz + dist/*.sha256 diff --git a/Cargo.lock b/Cargo.lock index e8a6029..b4c0f62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1139,69 +1139,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pyo3" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" -dependencies = [ - "cfg-if", - "indoc", - "libc", - "memoffset", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", - "unindent", -] - -[[package]] -name = "pyo3-build-config" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" -dependencies = [ - "once_cell", - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" -dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn 2.0.118", -] - [[package]] name = "quote" version = "1.0.46" @@ -1601,12 +1538,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - [[package]] name = "tempfile" version = "3.27.0" @@ -1759,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tree-ring-memory-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "clap", "ratatui", @@ -1772,33 +1703,21 @@ dependencies = [ [[package]] name = "tree-ring-memory-core" -version = "0.10.0" +version = "0.11.0" dependencies = [ "chrono", "once_cell", "regex", "serde", "serde_json", + "tempfile", "thiserror 1.0.69", "uuid", ] -[[package]] -name = "tree-ring-memory-python" -version = "0.10.0" -dependencies = [ - "pyo3", - "pyo3-build-config", - "serde", - "serde_json", - "tempfile", - "tree-ring-memory-core", - "tree-ring-memory-sqlite", -] - [[package]] name = "tree-ring-memory-sqlite" -version = "0.10.0" +version = "0.11.0" dependencies = [ "rusqlite", "serde_json", @@ -1847,12 +1766,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 3b58fc0..6cb6945 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,12 +3,11 @@ members = [ "crates/tree-ring-memory-core", "crates/tree-ring-memory-sqlite", "crates/tree-ring-memory-cli", - "bindings/python", ] resolver = "2" [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2021" license = "MIT" authors = ["TerminallyLazy"] @@ -25,6 +24,4 @@ serde_json = "1" tempfile = "3" thiserror = "1" uuid = { version = "1", features = ["v4"] } -pyo3 = { version = "0.22", features = ["abi3-py311"] } -pyo3-build-config = { version = "0.22" } ratatui = "0.30.2" diff --git a/README.md b/README.md index daf3e65..40013be 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,17 @@ framework-agnostic and does not replace either protocol. Tree Ring Memory is in protocol-preview status. -- v0.1 provides a local Python reference library with SQLite storage and no required cloud services. -- v0.2 moved durable behavior into a Rust core while preserving Python compatibility. -- v0.3 makes the public Python facade Rust-first when the optional PyO3 native module is installed. -- v0.4 adds Rust-owned JSONL import/export with privacy-preserving defaults across the CLI and native Python binding. +- v0.1 provided the initial local reference implementation with SQLite storage and no required cloud services. +- v0.2 moved durable behavior into a Rust core. +- v0.3 explored host bindings during the Rust migration. +- v0.4 adds Rust-owned JSONL import/export with privacy-preserving defaults across the CLI. - v0.5 adds Rust-owned audit checks for stale, sensitive, low-confidence, supersession, and contradiction candidates. - v0.6 adds Rust-owned deterministic consolidation with idempotent summary records and cautious sensitive-memory handling. - v0.7 makes the public facade Rust-native only and adds Rust-owned maintenance for expiry, secret redaction, and FTS repair. -- v0.8 removes Python-owned runtime behavior; Python is now a thin native-binding surface only. -- v0.9 removes tracked Python source, tests, and smoke scripts from the canonical repo; the optional CPython extension is Rust-built. +- v0.8 removes Python-owned runtime behavior. +- v0.9 removes tracked Python source, tests, smoke scripts, and the optional CPython extension from the canonical repo. - v0.10 adds a one-line installer plus Rust-native terminal onboarding with ASCII tree rings. +- v0.11 makes the repo fully Rust-native, wires TUI export/consolidation actions, adds DOX/Revolve sync adapters, and adds agent-framework discovery. - The Rust CLI also includes a Ratatui operator console behind `tree-ring tui`. The Rust workspace currently includes: @@ -33,12 +34,11 @@ The Rust workspace currently includes: - `crates/tree-ring-memory-core`: models, validation, sensitivity checks, and recall scoring. - `crates/tree-ring-memory-sqlite`: schema-compatible SQLite/FTS storage and recall filtering. - `crates/tree-ring-memory-cli`: native `tree-ring` CLI. -- `bindings/python`: optional Rust PyO3 crate for CPython extension builds. The public runtime is Rust-native. The Rust CLI and Rust crates own storage, recall, import/export, audit, consolidation, maintenance, and terminal UI behavior. There is no tracked root Python package, Python wrapper layer, pytest -suite, or Python smoke script. +suite, Python smoke script, PyO3 crate, or CPython extension. ## Install @@ -78,6 +78,7 @@ sh install.sh --help sh install.sh --project --init sh install.sh --global --install-dir "$HOME/.local" sh install.sh --no-animation +sh install.sh --archive-url https://example/tree-ring-memory-0.11.0-macos-arm64.tar.gz --archive-sha256 ``` After install, rerun onboarding anytime: @@ -87,6 +88,10 @@ tree-ring welcome tree-ring welcome --init ``` +By default, the installer uses `cargo install` from the Git repository or a +local `--source` checkout. Release builds can use `--archive-url` plus +`--archive-sha256` to install a prebuilt `tree-ring` binary archive. + Open the terminal console after a global install: ```bash @@ -129,6 +134,9 @@ tree-ring audit --audit-type sensitive tree-ring consolidate --period-type manual --dry-run tree-ring maintain tree-ring maintain --apply-expired --repair-fts +tree-ring dox sync --source-root . --dry-run +tree-ring revolve sync --source-root revolve --dry-run +tree-ring integrations scan --source-root . ``` The CLI stores memory in `.tree-ring/` by default. @@ -162,6 +170,35 @@ Outcome mapping: This is not a replacement for Revolve records. Use source refs that point back to real evaluations, checkpoints, PRs, issues, logs, or run artifacts. +## Source Adapters + +Tree Ring Memory includes Rust-native source adapters. They produce concise, +source-linked memory events and then persist through the same SQLite store as +manual memories. + +```bash +tree-ring dox sync --source-root . --dry-run +tree-ring dox sync --source-root . --project example-service + +tree-ring revolve sync --source-root revolve --dry-run +tree-ring revolve sync --source-root revolve --project example-service +``` + +DOX sync discovers `AGENTS.md` files, stores summaries and source refs, and +keeps the source files authoritative. Revolve sync imports promoted outcomes as +heartwood, rejected outcomes as scars, deferred hypotheses as seeds, and +observed results as outer-ring evidence. + +Framework discovery is read-only: + +```bash +tree-ring integrations scan --source-root . +``` + +It looks for local markers for DOX, Revolve, Codex, Claude Code, Agent Zero/A0, +Goose, OpenCode, Hermes, and Pi, then suggests next steps without editing those +tools' configuration. + `tree-ring export` writes newline-delimited JSON. The first line is a `tree_ring_memory_export` header with schema and plugin version metadata; each remaining line is a `memory_event` envelope. The command excludes sensitive and @@ -184,19 +221,6 @@ report, including on a missing root. It can apply eligible temporary-memory expiry, redact secret-like memories, and rebuild SQLite FTS only when explicitly asked through `--apply-expired`, `--apply-secret-redactions`, or `--repair-fts`. -## Optional CPython Extension - -`bindings/python` is a Rust PyO3 crate for building a CPython extension from -the same Rust runtime. It is an optional host-adapter artifact, not the -canonical API and not a Python implementation. - -```bash -cargo build -p tree-ring-memory-python --features extension-module -``` - -For package builds, run maturin from `bindings/python`. The repository does not -ship a root Python package or Python wrapper modules. - ## Terminal Console Preview The Rust CLI includes a framework-agnostic Ratatui console for humans and agent @@ -228,7 +252,8 @@ Useful keys and commands: visibility. - Slash commands include `/rings`, `/search `, `/remember `, `/forget`, `/redact`, `/promote`, `/scar`, `/seed`, `/supersede `, - `/consolidate`, `/export`, `/sync`, `/stream`, and `/watch`. + `/consolidate`, `/export `, `/sync`, `/integrations`, `/stream`, and + `/watch`. Destructive or authority-changing operations are confirmation-gated. Sensitive details stay hidden by default, and secret-like memory is blocked before @@ -254,8 +279,11 @@ cargo run -p tree-ring-memory-cli -- import --help cargo run -p tree-ring-memory-cli -- audit --help cargo run -p tree-ring-memory-cli -- consolidate --help cargo run -p tree-ring-memory-cli -- maintain --help -cargo build -p tree-ring-memory-python --features extension-module +cargo run -p tree-ring-memory-cli -- dox sync --help +cargo run -p tree-ring-memory-cli -- revolve sync --help +cargo run -p tree-ring-memory-cli -- integrations scan --help cargo run --release -p tree-ring-memory-sqlite --example performance_smoke -- 1000 +sh scripts/package-release.sh ``` The Rust CLI writes the canonical SQLite/raw JSON shape. The performance smoke @@ -263,32 +291,20 @@ asserts nonempty recalls, emits a `METRICS_JSON=` line, and uses conservative local thresholds of at least 500 inserts/sec and max recall latency of 250 ms for the synthetic workload. +`scripts/package-release.sh` builds the Rust CLI in release mode, creates a +platform tarball under `dist/`, and writes a SHA-256 checksum file. Tag pushes +run the release artifact workflow for Linux and macOS. + ## Design Docs -- `docs/superpowers/specs/2026-07-04-tree-ring-memory-framework-design.md` -- `docs/feature/tree-ring-memory-framework/diverge/options-raw.md` - `docs/architecture/rust-core-roadmap.md` - `docs/architecture/rust-core-status.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-core-v0-2-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-core-v0-2-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-python-bindings-v0-3-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-python-bindings-v0-3-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-ratatui-tui-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-ratatui-tui-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-import-export-v0-4-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-import-export-v0-4-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-audit-v0-5-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-audit-v0-5-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-consolidation-v0-6-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-consolidation-v0-6-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-maintenance-v0-7-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-maintenance-v0-7-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-only-v0-8-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-only-v0-8-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-rust-only-repo-v0-9-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-rust-only-repo-v0-9-implementation-plan.md` -- `docs/superpowers/specs/2026-07-05-tree-ring-memory-installer-onboarding-v0-10-design.md` -- `docs/superpowers/plans/2026-07-05-tree-ring-memory-installer-onboarding-v0-10-implementation-plan.md` +- `docs/integrations/agent-skill.md` +- `docs/protocol/memory-event.md` + +Historical migration and planning documents are retained under `docs/superpowers/` +and `docs/feature/`. Some of those records describe earlier Python prototype or +binding options that have since been superseded by the Rust-native runtime. ## Agent Workflow Integration diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml deleted file mode 100644 index 2aacd3d..0000000 --- a/bindings/python/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "tree-ring-memory-python" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true - -[lib] -name = "_tree_ring_memory_native" -crate-type = ["cdylib", "rlib"] - -[dependencies] -pyo3.workspace = true -serde.workspace = true -serde_json.workspace = true -tree-ring-memory-core = { path = "../../crates/tree-ring-memory-core" } -tree-ring-memory-sqlite = { path = "../../crates/tree-ring-memory-sqlite" } - -[dev-dependencies] -tempfile.workspace = true - -[build-dependencies] -pyo3-build-config.workspace = true - -[features] -default = [] -extension-module = ["pyo3/extension-module"] diff --git a/bindings/python/README.md b/bindings/python/README.md deleted file mode 100644 index ed5c8f4..0000000 --- a/bindings/python/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Tree Ring Memory CPython Extension - -This directory is an optional Rust PyO3 crate. It builds only the CPython -extension module: - -```text -tree_ring_memory._tree_ring_memory_native -``` - -It does not depend on a root Python package or Python wrapper modules. The -canonical Tree Ring Memory runtime is the Rust CLI and Rust crates. - -Development build: - -```bash -maturin develop -``` - -Repository checks do not require maturin. The extension target can be checked -from the repo root with: - -```bash -cargo build -p tree-ring-memory-python --features extension-module -``` diff --git a/bindings/python/build.rs b/bindings/python/build.rs deleted file mode 100644 index dace4a9..0000000 --- a/bindings/python/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - pyo3_build_config::add_extension_module_link_args(); -} diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml deleted file mode 100644 index b10fd1e..0000000 --- a/bindings/python/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["maturin>=1.7,<2"] -build-backend = "maturin" - -[project] -name = "tree-ring-memory-native" -version = "0.10.0" -description = "Native Rust bindings for Tree Ring Memory." -requires-python = ">=3.11" -license = { file = "../../LICENSE" } -authors = [ - { name = "TerminallyLazy" } -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Programming Language :: Python :: 3", - "Programming Language :: Rust" -] - -[tool.maturin] -module-name = "tree_ring_memory._tree_ring_memory_native" -python-source = "python" -features = ["extension-module"] diff --git a/bindings/python/python/tree_ring_memory/.gitkeep b/bindings/python/python/tree_ring_memory/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/bindings/python/python/tree_ring_memory/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs deleted file mode 100644 index a9189b9..0000000 --- a/bindings/python/src/lib.rs +++ /dev/null @@ -1,840 +0,0 @@ -use pyo3::exceptions::{PyRuntimeError, PyValueError}; -use pyo3::prelude::*; -use serde::Deserialize; -use std::path::PathBuf; -use tree_ring_memory_core::sensitivity::SensitivityGuard; -use tree_ring_memory_core::{ - ConsolidationPeriod, ConsolidationRequest, MaintenanceRequest, MemoryEvent, MemoryLink, - MemoryReview, MemorySource, -}; -use tree_ring_memory_sqlite::{MemoryRetriever, SQLiteMemoryStore}; - -#[pyclass(name = "TreeRingMemoryNative", unsendable)] -pub struct PyTreeRingMemoryNative { - root: PathBuf, - store: SQLiteMemoryStore, -} - -#[pymethods] -impl PyTreeRingMemoryNative { - #[new] - pub fn new(root: String) -> PyResult { - Self::open(root) - } - - #[staticmethod] - pub fn open(root: String) -> PyResult { - let root = PathBuf::from(root); - let db_path = root.join("memory.sqlite"); - let store = SQLiteMemoryStore::open(&db_path).map_err(to_py_runtime_error)?; - Ok(Self { root, store }) - } - - #[getter] - pub fn root(&self) -> String { - self.root.display().to_string() - } - - #[pyo3(signature = (summary, event_type, ring=None, scope=None, project=None, tags=None))] - pub fn remember_json( - &mut self, - summary: String, - event_type: String, - ring: Option, - scope: Option, - project: Option, - tags: Option>, - ) -> PyResult { - let detected_sensitivity = detect_memory_input_sensitivity( - &summary, - &event_type, - ring.as_deref(), - scope.as_deref(), - project.as_deref(), - tags.as_deref(), - )?; - let mut event = MemoryEvent::new(summary, event_type).map_err(to_py_value_error)?; - if let Some(ring) = ring { - event.ring = ring; - } - if let Some(scope) = scope { - event.scope = scope; - } - event.project = project; - event.tags = tags.unwrap_or_default(); - if detected_sensitivity != "normal" { - event.sensitivity = detected_sensitivity; - } - self.put_event(event) - } - - pub fn remember_event_json(&mut self, request_json: &str) -> PyResult { - let request: RememberRequest = - serde_json::from_str(request_json).map_err(to_py_value_error)?; - let mut event = - MemoryEvent::new(request.summary, request.event_type).map_err(to_py_value_error)?; - event.scope = request.scope.unwrap_or_else(|| "global".to_string()); - event.ring = request.ring.unwrap_or_else(|| "cambium".to_string()); - event.project = request.project; - event.agent_profile = request.agent_profile; - event.details = request.details.unwrap_or_default(); - event.source = request.source.unwrap_or_default(); - event.tags = request.tags.unwrap_or_default(); - if let Some(salience) = request.salience { - event.salience = salience; - } - if let Some(confidence) = request.confidence { - event.confidence = confidence; - } - if let Some(sensitivity) = request.sensitivity { - event.sensitivity = sensitivity; - } - if let Some(retention) = request.retention { - event.retention = retention; - } - event.expires_at = request.expires_at; - event.supersedes = request.supersedes.unwrap_or_default(); - event.links = request.links.unwrap_or_default(); - event.review = request.review.unwrap_or_default(); - self.put_event(event) - } - - pub fn put_event_json(&mut self, event_json: &str) -> PyResult { - let event: MemoryEvent = serde_json::from_str(event_json).map_err(to_py_value_error)?; - self.put_event(event) - } - - #[pyo3(signature = (query, project=None, limit=8, include_sensitive=false))] - pub fn recall_json( - &self, - query: String, - project: Option, - limit: usize, - include_sensitive: bool, - ) -> PyResult { - let results = MemoryRetriever::new(&self.store) - .recall( - &query, - project.as_deref(), - None, - None, - None, - None, - include_sensitive, - false, - limit, - false, - ) - .map_err(to_py_runtime_error)?; - let payload: Vec<_> = results - .into_iter() - .map(|result| { - serde_json::json!({ - "memory": result.memory, - "score": result.score, - "ranking": result.ranking, - }) - }) - .collect(); - serde_json::to_string(&payload).map_err(to_py_runtime_error) - } - - pub fn recall_query_json(&self, request_json: &str) -> PyResult { - let request: RecallRequest = - serde_json::from_str(request_json).map_err(to_py_value_error)?; - let results = MemoryRetriever::new(&self.store) - .recall( - &request.query, - request.project.as_deref(), - request.agent_profile.as_deref(), - request.scope.as_deref(), - request.rings.as_deref(), - request.event_types.as_deref(), - request.include_sensitive.unwrap_or(false), - request.include_superseded.unwrap_or(false), - request.limit.unwrap_or(8), - request.explain_ranking.unwrap_or(false), - ) - .map_err(to_py_runtime_error)?; - let payload: Vec<_> = results - .into_iter() - .map(|result| { - serde_json::json!({ - "memory": result.memory, - "score": result.score, - "ranking": result.ranking, - }) - }) - .collect(); - serde_json::to_string(&payload).map_err(to_py_runtime_error) - } - - pub fn forget(&mut self, memory_id: String, mode: String, reason: String) -> PyResult<()> { - if reason.trim().is_empty() { - return Err(PyValueError::new_err("forget reason is required")); - } - match mode.as_str() { - "delete" => self.store.delete(&memory_id).map_err(to_py_runtime_error), - "redact" => self.store.redact(&memory_id).map_err(to_py_runtime_error), - other => Err(PyValueError::new_err(format!( - "unsupported forget mode: {other}" - ))), - } - } - - #[pyo3(signature = (include_sensitive=false, include_superseded=false))] - pub fn export_jsonl( - &self, - include_sensitive: bool, - include_superseded: bool, - ) -> PyResult { - let (jsonl, _report) = self - .store - .export_jsonl(include_sensitive, include_superseded) - .map_err(to_py_runtime_error)?; - Ok(jsonl) - } - - #[pyo3(signature = (data, dry_run=false, replace_existing=false))] - pub fn import_jsonl( - &mut self, - data: &str, - dry_run: bool, - replace_existing: bool, - ) -> PyResult { - let report = self - .store - .import_jsonl(data, dry_run, replace_existing) - .map_err(to_py_runtime_error)?; - serde_json::to_string(&serde_json::json!({ - "valid_count": report.valid_count, - "inserted_count": report.inserted_count, - "replaced_count": report.replaced_count, - "skipped_duplicate_count": report.skipped_duplicate_count, - "dry_run": report.dry_run, - })) - .map_err(to_py_runtime_error) - } - - #[pyo3(signature = (audit_type="all"))] - pub fn audit_json(&self, audit_type: &str) -> PyResult { - let report = self.store.audit(audit_type).map_err(to_py_runtime_error)?; - serde_json::to_string(&report).map_err(to_py_runtime_error) - } - - #[pyo3(signature = (period_type="daily", period_key=None, project=None, dry_run=false, force=false))] - pub fn consolidate_json( - &mut self, - period_type: &str, - period_key: Option, - project: Option, - dry_run: bool, - force: bool, - ) -> PyResult { - let request = ConsolidationRequest { - period_type: ConsolidationPeriod::parse(period_type).map_err(to_py_value_error)?, - period_key, - project, - dry_run, - force, - }; - let report = self - .store - .consolidate(&request) - .map_err(to_py_runtime_error)?; - serde_json::to_string(&report).map_err(to_py_runtime_error) - } - - #[pyo3(signature = (project=None, include_superseded=false, apply_expired=false, apply_secret_redactions=false, repair_fts=false))] - pub fn maintain_json( - &mut self, - project: Option, - include_superseded: bool, - apply_expired: bool, - apply_secret_redactions: bool, - repair_fts: bool, - ) -> PyResult { - let request = MaintenanceRequest { - dry_run: !(apply_expired || apply_secret_redactions || repair_fts), - apply_expired, - apply_secret_redactions, - repair_fts, - include_superseded, - project, - }; - let report = self.store.maintain(&request).map_err(to_py_runtime_error)?; - serde_json::to_string(&report).map_err(to_py_runtime_error) - } -} - -impl PyTreeRingMemoryNative { - fn put_event(&mut self, mut event: MemoryEvent) -> PyResult { - let detected_sensitivity = SensitivityGuard::default() - .detect_memory_event_sensitivity(&event) - .map_err(to_py_value_error)?; - if event.sensitivity == "normal" && detected_sensitivity != "normal" { - event.sensitivity = detected_sensitivity; - } - event.validate().map_err(to_py_value_error)?; - self.store.put(&event).map_err(to_py_runtime_error)?; - for superseded_id in &event.supersedes { - self.store - .supersede(superseded_id, &event.id) - .map_err(to_py_runtime_error)?; - } - serde_json::to_string(&event).map_err(to_py_runtime_error) - } -} - -#[derive(Debug, Deserialize)] -struct RememberRequest { - summary: String, - event_type: String, - #[serde(default)] - scope: Option, - #[serde(default)] - ring: Option, - #[serde(default)] - project: Option, - #[serde(default)] - agent_profile: Option, - #[serde(default)] - details: Option, - #[serde(default)] - source: Option, - #[serde(default)] - tags: Option>, - #[serde(default)] - salience: Option, - #[serde(default)] - confidence: Option, - #[serde(default)] - sensitivity: Option, - #[serde(default)] - retention: Option, - #[serde(default)] - expires_at: Option, - #[serde(default)] - supersedes: Option>, - #[serde(default)] - links: Option>, - #[serde(default)] - review: Option, -} - -#[derive(Debug, Deserialize)] -struct RecallRequest { - query: String, - #[serde(default)] - project: Option, - #[serde(default)] - agent_profile: Option, - #[serde(default)] - scope: Option, - #[serde(default)] - rings: Option>, - #[serde(default)] - event_types: Option>, - #[serde(default)] - include_sensitive: Option, - #[serde(default)] - include_superseded: Option, - #[serde(default)] - limit: Option, - #[serde(default)] - explain_ranking: Option, -} - -#[pyfunction] -pub fn native_version() -> &'static str { - env!("CARGO_PKG_VERSION") -} - -#[pymodule] -fn _tree_ring_memory_native(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - m.add_function(wrap_pyfunction!(native_version, m)?)?; - Ok(()) -} - -fn detect_memory_input_sensitivity( - summary: &str, - event_type: &str, - ring: Option<&str>, - scope: Option<&str>, - project: Option<&str>, - tags: Option<&[String]>, -) -> PyResult { - let guard = SensitivityGuard::default(); - let values = [summary, event_type] - .into_iter() - .chain(ring) - .chain(scope) - .chain(project) - .chain(tags.into_iter().flatten().map(String::as_str)); - guard - .detect_text_sensitivity(values) - .map_err(to_py_value_error) -} - -fn to_py_value_error(error: impl ToString) -> PyErr { - PyValueError::new_err(error.to_string()) -} - -fn to_py_runtime_error(error: impl ToString) -> PyErr { - PyRuntimeError::new_err(error.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn native_binding_store_round_trips_json() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - - let event_json = memory - .remember_json( - "Native binding remembers through Rust.".to_string(), - "lesson".to_string(), - None, - None, - Some("bindings".to_string()), - Some(vec!["native".to_string()]), - ) - .unwrap(); - let event: MemoryEvent = serde_json::from_str(&event_json).unwrap(); - let recalled_json = memory - .recall_json( - "binding remembers".to_string(), - Some("bindings".to_string()), - 8, - false, - ) - .unwrap(); - - assert!(event.id.starts_with("mem_")); - assert!(recalled_json.contains(&event.id)); - } - - #[test] - fn native_binding_remember_event_json_preserves_full_facade_contract() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let request = serde_json::json!({ - "summary": "Rust owns the full remember contract.", - "details": "Details should be stored natively.", - "event_type": "decision", - "scope": "project", - "ring": "heartwood", - "project": "migration", - "agent_profile": "default", - "source": {"type": "file", "ref": "README.md", "quote": ""}, - "tags": ["rust", "native"], - "salience": 0.8, - "confidence": 0.9, - "retention": "durable", - "links": [{"type": "file", "target": "README.md"}], - "review": {"needs_review": true, "review_reason": "native parity"} - }); - - let event_json = memory - .remember_event_json(&serde_json::to_string(&request).unwrap()) - .unwrap(); - let event: MemoryEvent = serde_json::from_str(&event_json).unwrap(); - - assert_eq!(event.summary, "Rust owns the full remember contract."); - assert_eq!(event.details, "Details should be stored natively."); - assert_eq!(event.scope, "project"); - assert_eq!(event.ring, "heartwood"); - assert_eq!(event.project.as_deref(), Some("migration")); - assert_eq!(event.agent_profile.as_deref(), Some("default")); - assert_eq!(event.source.ref_, "README.md"); - assert_eq!(event.tags, vec!["rust".to_string(), "native".to_string()]); - assert_eq!(event.salience, 0.8); - assert_eq!(event.confidence, 0.9); - assert_eq!(event.retention, "durable"); - assert_eq!(event.links[0].target, "README.md"); - assert!(event.review.needs_review); - } - - #[test] - fn native_binding_remember_event_json_supersedes_prior_memory() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let old_json = memory - .remember_event_json( - &serde_json::json!({ - "summary": "Old migration decision.", - "event_type": "decision", - "project": "migration" - }) - .to_string(), - ) - .unwrap(); - let old: MemoryEvent = serde_json::from_str(&old_json).unwrap(); - let new_json = memory - .remember_event_json( - &serde_json::json!({ - "summary": "New migration decision.", - "event_type": "decision", - "project": "migration", - "supersedes": [old.id] - }) - .to_string(), - ) - .unwrap(); - let new_event: MemoryEvent = serde_json::from_str(&new_json).unwrap(); - let old_after = memory.store.get(&old.id).unwrap().unwrap(); - - assert_eq!( - old_after.superseded_by.as_deref(), - Some(new_event.id.as_str()) - ); - assert!(memory - .recall_query_json( - &serde_json::json!({ - "query": "migration decision", - "project": "migration", - "include_superseded": false - }) - .to_string() - ) - .unwrap() - .contains(&new_event.id)); - } - - #[test] - fn native_binding_recall_query_json_applies_filters_and_ranking() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let target_json = memory - .remember_event_json( - &serde_json::json!({ - "summary": "Filtered native recall keeps durable Rust migration decisions.", - "event_type": "decision", - "scope": "project", - "ring": "heartwood", - "project": "migration", - "agent_profile": "default", - "tags": ["rust"] - }) - .to_string(), - ) - .unwrap(); - let target: MemoryEvent = serde_json::from_str(&target_json).unwrap(); - memory - .remember_event_json( - &serde_json::json!({ - "summary": "Filtered native recall should skip other projects.", - "event_type": "lesson", - "scope": "project", - "ring": "outer", - "project": "other", - "agent_profile": "default", - "tags": ["rust"] - }) - .to_string(), - ) - .unwrap(); - - let payload: serde_json::Value = serde_json::from_str( - &memory - .recall_query_json( - &serde_json::json!({ - "query": "durable Rust migration decision", - "project": "migration", - "agent_profile": "default", - "scope": "project", - "rings": ["heartwood"], - "event_types": ["decision"], - "limit": 8, - "explain_ranking": true - }) - .to_string(), - ) - .unwrap(), - ) - .unwrap(); - - let results = payload.as_array().unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0]["memory"]["id"], target.id); - assert!(results[0]["ranking"]["source_authority"].is_number()); - } - - #[test] - fn native_binding_rejects_blank_forget_reason() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - - let error = memory - .forget( - "mem_missing".to_string(), - "delete".to_string(), - " ".to_string(), - ) - .unwrap_err(); - - assert!(error.to_string().contains("forget reason is required")); - } - - #[test] - fn native_binding_blocks_secret_full_event_json() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let mut event = MemoryEvent::new("Store via full JSON.", "lesson").unwrap(); - event.source.ref_ = "sk-proj-abcdefghijklmnopqrstuvwxyz1234567890".to_string(); - let payload = serde_json::to_string(&event).unwrap(); - - let error = memory.put_event_json(&payload).unwrap_err(); - - assert!(error.to_string().contains("secret-like memory is blocked")); - } - - #[test] - fn native_binding_maintain_json_plans_and_applies_expired_memory() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let event_json = memory - .remember_event_json( - &serde_json::json!({ - "summary": "Temporary native maintenance memory.", - "event_type": "lesson", - "retention": "ephemeral", - "expires_at": "2000-01-01T00:00:00Z" - }) - .to_string(), - ) - .unwrap(); - let event: MemoryEvent = serde_json::from_str(&event_json).unwrap(); - - let dry_run: serde_json::Value = serde_json::from_str( - &memory - .maintain_json(None, false, false, false, false) - .unwrap(), - ) - .unwrap(); - assert_eq!(dry_run["status"], "planned"); - assert_eq!(dry_run["planned_action_count"], 1); - assert_eq!(dry_run["dry_run"], true); - assert!(memory.store.get(&event.id).unwrap().is_some()); - - let applied: serde_json::Value = serde_json::from_str( - &memory - .maintain_json(None, false, true, false, false) - .unwrap(), - ) - .unwrap(); - assert_eq!(applied["status"], "applied"); - assert_eq!(applied["applied_action_count"], 1); - assert_eq!(applied["dry_run"], false); - assert!(memory.store.get(&event.id).unwrap().is_none()); - } - - #[test] - fn native_binding_blocks_secret_in_full_event_review_metadata() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let mut event = MemoryEvent::new("Store via full JSON.", "lesson").unwrap(); - event.review.review_reason = Some("PASSWORD=do-not-store-this".to_string()); - let payload = serde_json::to_string(&event).unwrap(); - - let error = memory.put_event_json(&payload).unwrap_err(); - - assert!(error.to_string().contains("secret-like memory is blocked")); - } - - #[test] - fn native_binding_classifies_sensitive_full_event_metadata() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let mut event = MemoryEvent::new("Store sensitive metadata.", "lesson").unwrap(); - event.details = "private diagnosis in details".to_string(); - let payload = serde_json::to_string(&event).unwrap(); - - let stored_json = memory.put_event_json(&payload).unwrap(); - let stored: MemoryEvent = serde_json::from_str(&stored_json).unwrap(); - let hidden = memory - .recall_json("sensitive metadata".to_string(), None, 8, false) - .unwrap(); - let visible = memory - .recall_json("sensitive metadata".to_string(), None, 8, true) - .unwrap(); - - assert_eq!(stored.sensitivity, "health"); - assert_eq!(hidden, "[]"); - assert!(visible.contains(&stored.id)); - } - - #[test] - fn native_binding_exports_and_imports_jsonl() { - pyo3::prepare_freethreaded_python(); - let source_dir = tempdir().unwrap(); - let target_dir = tempdir().unwrap(); - let mut source = PyTreeRingMemoryNative::open( - source_dir.path().join(".tree-ring").display().to_string(), - ) - .unwrap(); - let mut target = PyTreeRingMemoryNative::open( - target_dir.path().join(".tree-ring").display().to_string(), - ) - .unwrap(); - let event_json = source - .remember_event_json( - &serde_json::json!({ - "summary": "Native JSONL import export preserves memory.", - "event_type": "lesson", - "project": "bindings" - }) - .to_string(), - ) - .unwrap(); - let event: MemoryEvent = serde_json::from_str(&event_json).unwrap(); - - let jsonl = source.export_jsonl(false, false).unwrap(); - let dry_run_report: serde_json::Value = - serde_json::from_str(&target.import_jsonl(&jsonl, true, false).unwrap()).unwrap(); - let import_report: serde_json::Value = - serde_json::from_str(&target.import_jsonl(&jsonl, false, false).unwrap()).unwrap(); - - assert!(jsonl.contains("tree_ring_memory_export")); - assert!(jsonl.contains(&event.id)); - assert_eq!(dry_run_report["valid_count"], 1); - assert_eq!(dry_run_report["inserted_count"], 0); - assert_eq!(dry_run_report["dry_run"], true); - assert_eq!(import_report["inserted_count"], 1); - assert!(target - .recall_json( - "JSONL preserves memory".to_string(), - Some("bindings".to_string()), - 8, - false - ) - .unwrap() - .contains(&event.id)); - } - - #[test] - fn native_binding_exposes_audit_json() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let event_json = memory - .remember_event_json( - &serde_json::json!({ - "summary": "Private durable memory should be audited.", - "event_type": "lesson", - "sensitivity": "health", - "retention": "durable" - }) - .to_string(), - ) - .unwrap(); - let event: MemoryEvent = serde_json::from_str(&event_json).unwrap(); - - let report: serde_json::Value = - serde_json::from_str(&memory.audit_json("sensitive").unwrap()).unwrap(); - - assert_eq!(report["audit_type"], "sensitive"); - assert_eq!(report["memory_count"], 1); - assert!(report["finding_count"].as_u64().unwrap() >= 1); - assert_eq!(report["findings"][0]["memory_id"], event.id); - } - - #[test] - fn native_binding_exposes_consolidation_json() { - pyo3::prepare_freethreaded_python(); - let dir = tempdir().unwrap(); - let mut memory = - PyTreeRingMemoryNative::open(dir.path().join(".tree-ring").display().to_string()) - .unwrap(); - let event_json = memory - .remember_event_json( - &serde_json::json!({ - "summary": "Consolidate native Python surface.", - "event_type": "decision", - "project": "bindings", - "salience": 0.8 - }) - .to_string(), - ) - .unwrap(); - let event: MemoryEvent = serde_json::from_str(&event_json).unwrap(); - - let dry_run: serde_json::Value = serde_json::from_str( - &memory - .consolidate_json( - "manual", - Some("manual-native-test".to_string()), - Some("bindings".to_string()), - true, - false, - ) - .unwrap(), - ) - .unwrap(); - let created: serde_json::Value = serde_json::from_str( - &memory - .consolidate_json( - "manual", - Some("manual-native-test".to_string()), - Some("bindings".to_string()), - false, - false, - ) - .unwrap(), - ) - .unwrap(); - let unchanged: serde_json::Value = serde_json::from_str( - &memory - .consolidate_json( - "manual", - Some("manual-native-test".to_string()), - Some("bindings".to_string()), - false, - false, - ) - .unwrap(), - ) - .unwrap(); - - assert_eq!(dry_run["status"], "dry_run"); - assert_eq!(dry_run["candidate_count"], 1); - assert_eq!(dry_run["source_memory_ids"][0], event.id); - assert_eq!(created["status"], "created"); - assert_eq!(created["candidate_count"], 1); - assert_eq!(unchanged["status"], "unchanged"); - } -} diff --git a/crates/tree-ring-memory-cli/src/agent_awareness.rs b/crates/tree-ring-memory-cli/src/agent_awareness.rs index b7ff5d9..bfe3fd0 100644 --- a/crates/tree-ring-memory-cli/src/agent_awareness.rs +++ b/crates/tree-ring-memory-cli/src/agent_awareness.rs @@ -27,6 +27,9 @@ tree-ring evidence "Aggressive caching caused stale multi-chat state." --outcome tree-ring forget mem_example --mode redact --reason "remove sensitive detail" tree-ring consolidate --period-type manual --dry-run tree-ring maintain +tree-ring dox sync --source-root . --dry-run +tree-ring revolve sync --source-root revolve --dry-run +tree-ring integrations scan --source-root . tree-ring tui ``` diff --git a/crates/tree-ring-memory-cli/src/integrations.rs b/crates/tree-ring-memory-cli/src/integrations.rs new file mode 100644 index 0000000..85a98b6 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/integrations.rs @@ -0,0 +1,220 @@ +use serde::Serialize; +use std::env; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct IntegrationScanReport { + pub root: PathBuf, + pub detected_count: usize, + pub integrations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct AgentIntegration { + pub id: &'static str, + pub name: &'static str, + pub status: IntegrationStatus, + pub confidence: f64, + pub markers: Vec, + pub next_step: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IntegrationStatus { + Detected, + Available, +} + +pub fn scan_integrations(root: &Path) -> IntegrationScanReport { + let home = env::var_os("HOME").map(PathBuf::from); + let mut integrations = vec![ + detect( + "dox", + "DOX / AGENTS.md", + root, + home.as_deref(), + &["AGENTS.md"], + &[], + "Run `tree-ring dox sync --source-root . --dry-run`, then sync when the preview looks right.", + ), + detect( + "revolve", + "Revolve", + root, + home.as_deref(), + &["revolve", ".revolve"], + &[], + "Run `tree-ring revolve sync --source-root revolve --dry-run` to import promoted lessons, scars, and seeds.", + ), + detect( + "codex", + "Codex", + root, + home.as_deref(), + &[".codex", "AGENTS.md"], + &[".codex"], + "Reference `.tree-ring/SKILL.md` and `.tree-ring/CLI.md` from project guidance.", + ), + detect( + "claude-code", + "Claude Code", + root, + home.as_deref(), + &[".claude", "CLAUDE.md"], + &[".claude"], + "Reference `.tree-ring/SKILL.md` from `CLAUDE.md` or `.claude` project instructions.", + ), + detect( + "agent-zero", + "Agent Zero / A0", + root, + home.as_deref(), + &["usr/plugins", "a0", "agent-zero", ".a0"], + &[".a0"], + "Use the generated skill/CLI guidance, or bridge via an Agent Zero plugin without modifying core code.", + ), + detect( + "goose", + "Goose", + root, + home.as_deref(), + &[".goose", "goosehints"], + &[".goose"], + "Add Tree Ring Memory recall and remember commands to Goose project instructions.", + ), + detect( + "opencode", + "OpenCode", + root, + home.as_deref(), + &[".opencode", "opencode.json", "opencode.toml"], + &[".opencode"], + "Reference the Tree Ring CLI from OpenCode project configuration or instructions.", + ), + detect( + "hermes", + "Hermes", + root, + home.as_deref(), + &[".hermes", "hermes.toml"], + &[".hermes"], + "Reference Tree Ring Memory as a local CLI memory lifecycle layer.", + ), + detect( + "pi", + "Pi", + root, + home.as_deref(), + &[".pi", "pi.toml"], + &[".pi"], + "Use the generated portable skill and CLI reference as project instructions.", + ), + ]; + integrations.sort_by(|left, right| { + right + .confidence + .partial_cmp(&left.confidence) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.name.cmp(right.name)) + }); + let detected_count = integrations + .iter() + .filter(|integration| integration.status == IntegrationStatus::Detected) + .count(); + IntegrationScanReport { + root: root.to_path_buf(), + detected_count, + integrations, + } +} + +fn detect( + id: &'static str, + name: &'static str, + root: &Path, + home: Option<&Path>, + project_markers: &[&str], + home_markers: &[&str], + next_step: &'static str, +) -> AgentIntegration { + let mut markers = Vec::new(); + for marker in project_markers { + let path = root.join(marker); + if path.exists() { + markers.push(path.display().to_string()); + } + } + if let Some(home) = home { + for marker in home_markers { + let path = home.join(marker); + if path.exists() { + markers.push(path.display().to_string()); + } + } + } + markers.sort(); + markers.dedup(); + let confidence = if markers.is_empty() { + 0.0 + } else { + (0.55 + markers.len() as f64 * 0.15).min(0.95) + }; + AgentIntegration { + id, + name, + status: if markers.is_empty() { + IntegrationStatus::Available + } else { + IntegrationStatus::Detected + }, + confidence, + markers, + next_step, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn detects_dox_revolve_and_claude_project_markers() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Project rules").unwrap(); + fs::write(dir.path().join("CLAUDE.md"), "# Claude instructions").unwrap(); + fs::create_dir_all(dir.path().join("revolve")).unwrap(); + + let report = scan_integrations(dir.path()); + + assert!(report.detected_count >= 3); + assert!(detected(&report, "dox")); + assert!(detected(&report, "revolve")); + assert!(detected(&report, "claude-code")); + } + + #[test] + fn unavailable_integrations_still_include_onboarding_next_steps() { + let dir = tempdir().unwrap(); + + let report = scan_integrations(dir.path()); + + let opencode = report + .integrations + .iter() + .find(|integration| integration.id == "opencode") + .unwrap(); + assert_eq!(opencode.status, IntegrationStatus::Available); + assert!(opencode.next_step.contains("Tree Ring")); + } + + fn detected(report: &IntegrationScanReport, id: &str) -> bool { + report.integrations.iter().any(|integration| { + integration.id == id && integration.status == IntegrationStatus::Detected + }) + } +} diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 73ebf97..015f6b7 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -5,14 +5,16 @@ use std::path::PathBuf; use tree_ring_memory_core::plan_maintenance; use tree_ring_memory_core::sensitivity::SensitivityGuard; use tree_ring_memory_core::{ - audit_memories, consolidate_memories, decode_jsonl, normalize_import_events, AuditReport, - ConsolidationPeriod, ConsolidationReport, ConsolidationRequest, MaintenanceReport, - MaintenanceRequest, + audit_memories, collect_dox_memories, collect_revolve_memories, consolidate_memories, + decode_jsonl, normalize_import_events, AuditReport, ConsolidationPeriod, ConsolidationReport, + ConsolidationRequest, DoxSyncReport, DoxSyncRequest, MaintenanceReport, MaintenanceRequest, + RevolveSyncReport, RevolveSyncRequest, }; use tree_ring_memory_core::{MemoryEvent, MemoryLink}; use tree_ring_memory_sqlite::{MemoryRetriever, SQLiteMemoryStore}; mod agent_awareness; +mod integrations; mod tui; mod welcome; @@ -179,6 +181,64 @@ enum Command { #[arg(long, help = "print a stable onboarding screen without animation")] no_animation: bool, }, + #[command(about = "summarize DOX-style AGENTS.md guidance into memory")] + Dox { + #[command(subcommand)] + command: DoxCommand, + }, + #[command(about = "import Revolve-style evidence records into memory")] + Revolve { + #[command(subcommand)] + command: RevolveCommand, + }, + #[command(about = "discover local agent-framework integration markers")] + Integrations { + #[command(subcommand)] + command: IntegrationCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum DoxCommand { + #[command(about = "scan AGENTS.md files and store concise source-linked memories")] + Sync { + #[arg( + long, + default_value = ".", + help = "project root or AGENTS.md file to scan" + )] + source_root: PathBuf, + #[arg(long, help = "optional project scope for imported memories")] + project: Option, + #[arg(long, help = "preview generated memories without writing them")] + dry_run: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum RevolveCommand { + #[command(about = "scan Revolve records and store evidence-linked memories")] + Sync { + #[arg( + long, + default_value = "revolve", + help = "Revolve root or evidence file to scan" + )] + source_root: PathBuf, + #[arg(long, help = "optional project scope for imported memories")] + project: Option, + #[arg(long, help = "preview generated memories without writing them")] + dry_run: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum IntegrationCommand { + #[command(about = "scan a project root for known agent-framework markers")] + Scan { + #[arg(long, default_value = ".", help = "project root to scan")] + source_root: PathBuf, + }, } #[derive(Debug, Clone, clap::ValueEnum)] @@ -204,6 +264,15 @@ fn run(cli: Cli) -> Result<(), String> { return welcome::run(&cli.root, *init, *no_animation, cli.json); } + if let Command::Integrations { + command: IntegrationCommand::Scan { source_root }, + } = &cli.command + { + let report = integrations::scan_integrations(source_root); + print_integration_report(&report, cli.json)?; + return Ok(()); + } + if let Command::Tui { event_stream, tick_ms, @@ -313,6 +382,37 @@ fn run(cli: Cli) -> Result<(), String> { } } + if let Command::Dox { + command: + DoxCommand::Sync { + source_root, + project, + dry_run: true, + }, + } = &cli.command + { + let report = collect_dox_memories(&dox_request(source_root.clone(), project.clone())) + .map_err(|err| err.to_string())?; + print_dox_report(&report, cli.json, true)?; + return Ok(()); + } + + if let Command::Revolve { + command: + RevolveCommand::Sync { + source_root, + project, + dry_run: true, + }, + } = &cli.command + { + let report = + collect_revolve_memories(&revolve_request(source_root.clone(), project.clone())) + .map_err(|err| err.to_string())?; + print_revolve_report(&report, cli.json, true)?; + return Ok(()); + } + let mut store = SQLiteMemoryStore::open(&db_path).map_err(|err| err.to_string())?; match cli.command { @@ -568,10 +668,59 @@ fn run(cli: Cli) -> Result<(), String> { Command::Welcome { .. } => { unreachable!("welcome returns before opening the scriptable store") } + Command::Integrations { .. } => { + unreachable!("integrations scan returns before opening the scriptable store") + } + Command::Dox { + command: + DoxCommand::Sync { + source_root, + project, + dry_run, + }, + } => { + let report = collect_dox_memories(&dox_request(source_root, project)) + .map_err(|err| err.to_string())?; + if !dry_run { + store + .put_many(&report.events) + .map_err(|err| err.to_string())?; + } + print_dox_report(&report, cli.json, dry_run)?; + } + Command::Revolve { + command: + RevolveCommand::Sync { + source_root, + project, + dry_run, + }, + } => { + let report = collect_revolve_memories(&revolve_request(source_root, project)) + .map_err(|err| err.to_string())?; + if !dry_run { + store + .put_many(&report.events) + .map_err(|err| err.to_string())?; + } + print_revolve_report(&report, cli.json, dry_run)?; + } } Ok(()) } +fn dox_request(source_root: PathBuf, project: Option) -> DoxSyncRequest { + let mut request = DoxSyncRequest::new(source_root); + request.project = project; + request +} + +fn revolve_request(source_root: PathBuf, project: Option) -> RevolveSyncRequest { + let mut request = RevolveSyncRequest::new(source_root); + request.project = project; + request +} + fn maintenance_request( project: Option, include_superseded: bool, @@ -814,6 +963,108 @@ fn print_maintenance_report(report: &MaintenanceReport, json_output: bool) -> Re Ok(()) } +fn print_dox_report( + report: &DoxSyncReport, + json_output: bool, + dry_run: bool, +) -> Result<(), String> { + if json_output { + println!( + "{}", + json!({ + "ok": true, + "dry_run": dry_run, + "report": report, + }) + ); + } else { + println!( + "Tree Ring Memory DOX sync: sources={} memories={} skipped_secret={} dry_run={}", + report.source_count, report.memory_count, report.skipped_secret_count, dry_run + ); + for warning in &report.warnings { + println!("warning: {warning}"); + } + for event in &report.events { + println!( + "{} [{}] {} <- {}", + event.id, event.ring, event.summary, event.source.ref_ + ); + } + println!("Source AGENTS.md files remain authoritative; re-read them before acting."); + } + Ok(()) +} + +fn print_revolve_report( + report: &RevolveSyncReport, + json_output: bool, + dry_run: bool, +) -> Result<(), String> { + if json_output { + println!( + "{}", + json!({ + "ok": true, + "dry_run": dry_run, + "report": report, + }) + ); + } else { + println!( + "Tree Ring Memory Revolve sync: sources={} memories={} skipped_large={} skipped_secret={} dry_run={}", + report.source_count, + report.memory_count, + report.skipped_large_count, + report.skipped_secret_count, + dry_run + ); + for warning in &report.warnings { + println!("warning: {warning}"); + } + for event in &report.events { + println!( + "{} [{}] {} <- {}", + event.id, event.ring, event.summary, event.source.ref_ + ); + } + println!("Revolve records remain authoritative; re-read evaluations before treating memory as current truth."); + } + Ok(()) +} + +fn print_integration_report( + report: &integrations::IntegrationScanReport, + json_output: bool, +) -> Result<(), String> { + if json_output { + println!( + "{}", + json!({ + "ok": true, + "report": report, + }) + ); + } else { + println!( + "Tree Ring Memory integrations: root={} detected={}", + report.root.display(), + report.detected_count + ); + for integration in &report.integrations { + println!( + "{} [{:?}] confidence={:.2}", + integration.name, integration.status, integration.confidence + ); + if !integration.markers.is_empty() { + println!(" markers: {}", integration.markers.join(", ")); + } + println!(" next: {}", integration.next_step); + } + } + Ok(()) +} + fn print_agent_awareness_summary(report: &agent_awareness::AgentAwarenessReport) { if !report.created.is_empty() { println!("Agent awareness files created:"); @@ -997,6 +1248,121 @@ mod tests { assert_eq!(err, "evidence score must be between 0 and 1"); } + #[test] + fn dox_sync_dry_run_does_not_create_store() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Rules\nYou must run focused tests before full cargo test.", + ) + .unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli { + root: root.clone(), + json: true, + command: Command::Dox { + command: DoxCommand::Sync { + source_root: dir.path().to_path_buf(), + project: Some("tree-ring".to_string()), + dry_run: true, + }, + }, + }) + .unwrap(); + + assert!(!root.exists()); + } + + #[test] + fn dox_sync_persists_source_linked_contract_memory() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Contract\nYou must keep memory source documents authoritative.", + ) + .unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli { + root: root.clone(), + json: false, + command: Command::Dox { + command: DoxCommand::Sync { + source_root: dir.path().to_path_buf(), + project: Some("tree-ring".to_string()), + dry_run: false, + }, + }, + }) + .unwrap(); + + let store = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap(); + let memories = store.list_all(false).unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0].scope, "dox"); + assert_eq!(memories[0].ring, "heartwood"); + assert_eq!(memories[0].source.source_type, "dox"); + assert_eq!(memories[0].source.ref_, "AGENTS.md#contract-2"); + } + + #[test] + fn integrations_scan_is_read_only_and_detects_project_markers() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Rules").unwrap(); + fs::create_dir_all(dir.path().join("revolve")).unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli { + root: root.clone(), + json: true, + command: Command::Integrations { + command: IntegrationCommand::Scan { + source_root: dir.path().to_path_buf(), + }, + }, + }) + .unwrap(); + + assert!(!root.exists()); + let report = integrations::scan_integrations(dir.path()); + assert!(report.detected_count >= 2); + } + + #[test] + fn revolve_sync_persists_rejection_as_scar() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("revolve/projects/ui/branches/cache")).unwrap(); + fs::write( + dir.path() + .join("revolve/projects/ui/branches/cache/AGENTS.md"), + "# Rejected\nRejected aggressive caching after stale state regression.", + ) + .unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli { + root: root.clone(), + json: false, + command: Command::Revolve { + command: RevolveCommand::Sync { + source_root: dir.path().join("revolve"), + project: Some("ui".to_string()), + dry_run: false, + }, + }, + }) + .unwrap(); + + let store = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap(); + let memories = store.list_all(false).unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0].scope, "revolve"); + assert_eq!(memories[0].ring, "scar"); + assert_eq!(memories[0].event_type, "evaluation_rejection"); + assert_eq!(memories[0].source.source_type, "revolve"); + } + #[test] fn remember_classifies_sensitive_metadata_before_default_recall() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-cli/src/tui/actions.rs b/crates/tree-ring-memory-cli/src/tui/actions.rs index c788947..5105fe6 100644 --- a/crates/tree-ring-memory-cli/src/tui/actions.rs +++ b/crates/tree-ring-memory-cli/src/tui/actions.rs @@ -1,10 +1,28 @@ +use std::path::PathBuf; + +use tree_ring_memory_core::ConsolidationRequest; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ActionKind { Delete, Redact, - ChangeRing { ring: String, event_type: String }, - Supersede { old_id: String, new_id: String }, - Placeholder { command: String }, + ChangeRing { + ring: String, + event_type: String, + }, + Supersede { + old_id: String, + new_id: String, + }, + Consolidate { + request: ConsolidationRequest, + }, + Export { + output: PathBuf, + include_sensitive: bool, + include_superseded: bool, + }, + Sync, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -54,13 +72,47 @@ impl PendingAction { } } - pub fn placeholder(command: &str) -> Self { + pub fn consolidate(request: ConsolidationRequest) -> Self { + Self { + summary: format!( + "Run {} consolidation{}", + request.period_type, + if request.force { " with force" } else { "" } + ), + kind: ActionKind::Consolidate { request }, + memory_id: None, + } + } + + pub fn export(output: PathBuf, include_sensitive: bool, include_superseded: bool) -> Self { + let mut warnings = Vec::new(); + if include_sensitive { + warnings.push("including sensitive memory"); + } + if include_superseded { + warnings.push("including superseded memory"); + } + let suffix = if warnings.is_empty() { + String::new() + } else { + format!(" ({})", warnings.join(", ")) + }; Self { - kind: ActionKind::Placeholder { - command: command.to_string(), + summary: format!("Export memory to {}{suffix}", output.display()), + kind: ActionKind::Export { + output, + include_sensitive, + include_superseded, }, memory_id: None, - summary: format!("Run {command} maintenance flow"), + } + } + + pub fn sync_placeholder() -> Self { + Self { + kind: ActionKind::Sync, + memory_id: None, + summary: "Run integration sync".to_string(), } } diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index 5f20140..d470ddf 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -1,9 +1,12 @@ -use std::path::PathBuf; +use std::fs; +use std::path::{Component, Path, PathBuf}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use tree_ring_memory_core::{now_iso, MemoryEvent, SensitivityGuard}; +use tree_ring_memory_core::{now_iso, ConsolidationRequest, MemoryEvent, SensitivityGuard}; use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore}; +use crate::integrations::{scan_integrations, IntegrationScanReport}; + use super::actions::{ActionKind, PendingAction}; use super::input::{parse_slash_command, SlashCommand}; use super::model::DashboardStats; @@ -18,9 +21,11 @@ pub enum AppMode { Search, Stream, Watch, + Integrations, } pub struct App { + root: PathBuf, pub store: SQLiteMemoryStore, watcher: StoreWatcher, pub dashboard: DashboardStats, @@ -36,6 +41,7 @@ pub struct App { pub pending_action: Option, pub status: String, pub live_events: Vec, + pub integration_report: Option, event_stream: Option, pub tick: u64, pub should_quit: bool, @@ -46,6 +52,7 @@ impl App { let db_path = root.join("memory.sqlite"); let store = SQLiteMemoryStore::open(&db_path).map_err(|err| err.to_string())?; let mut app = Self { + root, store, watcher: StoreWatcher::new(), dashboard: DashboardStats::empty(), @@ -61,6 +68,7 @@ impl App { pending_action: None, status: format!("Store {}", db_path.display()), live_events: Vec::new(), + integration_report: None, event_stream: event_stream_path.map(EventStreamReader::new), tick: 0, should_quit: false, @@ -233,12 +241,12 @@ impl App { } } SlashCommand::Consolidate => { - self.pending_action = Some(PendingAction::placeholder("consolidate")) - } - SlashCommand::Export => { - self.pending_action = Some(PendingAction::placeholder("export")) + let request = ConsolidationRequest::new("daily").map_err(|err| err.to_string())?; + self.pending_action = Some(PendingAction::consolidate(request)); } - SlashCommand::Sync => self.pending_action = Some(PendingAction::placeholder("sync")), + SlashCommand::Export(target) => self.pending_export(target), + SlashCommand::Sync => self.pending_action = Some(PendingAction::sync_placeholder()), + SlashCommand::Integrations => self.show_integrations(), SlashCommand::Stream => { self.mode = AppMode::Stream; self.status = "showing recent event-stream signals".to_string(); @@ -343,13 +351,83 @@ impl App { .map_err(|err| err.to_string())?; self.status = format!("superseded {old_id} with {new_id}"); } - ActionKind::Placeholder { command } => { - self.status = format!("{command} is confirmation-gated; backend flow pending"); + ActionKind::Consolidate { request } => { + let report = self + .store + .consolidate(&request) + .map_err(|err| err.to_string())?; + self.status = format!( + "consolidation {}: candidates={} outputs={}", + report.status, + report.candidate_count, + report.output_memory_ids.len() + ); + } + ActionKind::Export { + output, + include_sensitive, + include_superseded, + } => { + if output.exists() { + self.status = format!("export refused existing file {}", output.display()); + } else { + let (jsonl, report) = self + .store + .export_jsonl(include_sensitive, include_superseded) + .map_err(|err| err.to_string())?; + if let Some(parent) = output.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + } + fs::write(&output, jsonl).map_err(|err| err.to_string())?; + self.status = format!( + "exported {} memories to {}", + report.memory_count, + output.display() + ); + } + } + ActionKind::Sync => { + self.status = "sync adapters are available through CLI commands".to_string(); } } self.refresh_store() } + fn pending_export(&mut self, target: String) { + if target.trim().is_empty() { + self.status = "export requires an output file".to_string(); + return; + } + match resolve_export_path(&self.root, target.trim()) { + Ok(output) => { + if output.exists() { + self.status = format!("export refused existing file {}", output.display()); + return; + } + self.pending_action = Some(PendingAction::export( + output, + self.include_sensitive, + self.include_superseded, + )); + } + Err(error) => self.status = error, + } + } + + fn show_integrations(&mut self) { + let root = project_root_for_memory_root(&self.root); + let report = scan_integrations(&root); + self.status = format!( + "integration scan: {} detected under {}", + report.detected_count, + report.root.display() + ); + self.integration_report = Some(report); + self.mode = AppMode::Integrations; + } + pub fn run_search(&mut self) -> Result<(), String> { if self.search_query.trim().is_empty() { self.results.clear(); @@ -441,8 +519,32 @@ fn wrap_index(current: usize, len: usize, delta: isize) -> usize { } } +fn resolve_export_path(root: &Path, target: &str) -> Result { + let path = PathBuf::from(target); + if path.is_absolute() { + return Ok(path); + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("relative export paths cannot contain '..'".to_string()); + } + Ok(root.join("exports").join(path)) +} + +fn project_root_for_memory_root(root: &Path) -> PathBuf { + if root.file_name().and_then(|name| name.to_str()) == Some(".tree-ring") { + root.parent().unwrap_or(root).to_path_buf() + } else { + root.to_path_buf() + } +} + #[cfg(test)] mod tests { + use std::fs; + use ratatui::crossterm::event::{KeyCode, KeyEvent}; use tempfile::{tempdir, TempDir}; @@ -452,6 +554,14 @@ mod tests { App::new(dir.path().join(".tree-ring"), None).unwrap() } + fn confirm(app: &mut App) { + app.handle_key(KeyEvent::new( + KeyCode::Char('y'), + ratatui::crossterm::event::KeyModifiers::NONE, + )) + .unwrap(); + } + #[test] fn slash_remember_stores_and_pulses_cambium() { let dir = tempdir().unwrap(); @@ -498,11 +608,7 @@ mod tests { .unwrap(); app.execute_slash_command("/forget").unwrap(); - app.handle_key(KeyEvent::new( - KeyCode::Char('y'), - ratatui::crossterm::event::KeyModifiers::NONE, - )) - .unwrap(); + confirm(&mut app); assert_eq!(app.dashboard.total, 0); } @@ -529,4 +635,98 @@ mod tests { assert!(app.results.is_empty()); assert!(app.selected_memory().is_none()); } + + #[test] + fn slash_export_requires_target() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + + app.execute_slash_command("/export").unwrap(); + + assert!(app.pending_action.is_none()); + assert_eq!(app.status, "export requires an output file"); + } + + #[test] + fn slash_export_can_be_cancelled_without_writing() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + app.execute_slash_command("/remember Exportable lesson") + .unwrap(); + + app.execute_slash_command("/export backup.jsonl").unwrap(); + app.handle_key(KeyEvent::new( + KeyCode::Char('n'), + ratatui::crossterm::event::KeyModifiers::NONE, + )) + .unwrap(); + + assert!(!dir + .path() + .join(".tree-ring") + .join("exports") + .join("backup.jsonl") + .exists()); + assert_eq!(app.status, "action cancelled"); + } + + #[test] + fn confirmed_export_writes_jsonl_with_default_privacy_filters() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + app.execute_slash_command("/remember Public lesson") + .unwrap(); + let mut sensitive = MemoryEvent::new("Private detail", "lesson").unwrap(); + sensitive.sensitivity = "private".to_string(); + app.store.put(&sensitive).unwrap(); + + app.execute_slash_command("/export backup.jsonl").unwrap(); + confirm(&mut app); + + let output = dir + .path() + .join(".tree-ring") + .join("exports") + .join("backup.jsonl"); + let jsonl = fs::read_to_string(output).unwrap(); + assert!(jsonl.contains("Public lesson")); + assert!(!jsonl.contains("Private detail")); + assert!(app.status.contains("exported 1 memories")); + } + + #[test] + fn confirmed_consolidation_creates_summary_then_idempotent_status() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + app.execute_slash_command("/remember First durable lesson") + .unwrap(); + app.execute_slash_command("/remember Second durable lesson") + .unwrap(); + + app.execute_slash_command("/consolidate").unwrap(); + confirm(&mut app); + let first_status = app.status.clone(); + assert!(first_status.contains("consolidation created")); + assert!(app.dashboard.total > 2); + + app.execute_slash_command("/consolidate").unwrap(); + confirm(&mut app); + + assert!(app.status.contains("consolidation unchanged")); + } + + #[test] + fn slash_integrations_scans_project_root_markers() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Rules").unwrap(); + fs::create_dir_all(dir.path().join("revolve")).unwrap(); + let mut app = app(&dir); + + app.execute_slash_command("/integrations").unwrap(); + + assert_eq!(app.mode, AppMode::Integrations); + let report = app.integration_report.as_ref().unwrap(); + assert!(report.detected_count >= 2); + assert!(app.status.contains("integration scan")); + } } diff --git a/crates/tree-ring-memory-cli/src/tui/input.rs b/crates/tree-ring-memory-cli/src/tui/input.rs index 0952ca8..68aec9e 100644 --- a/crates/tree-ring-memory-cli/src/tui/input.rs +++ b/crates/tree-ring-memory-cli/src/tui/input.rs @@ -10,8 +10,9 @@ pub enum SlashCommand { Seed, Supersede(String), Consolidate, - Export, + Export(String), Sync, + Integrations, Stream, Watch, Unknown(String), @@ -34,8 +35,9 @@ pub fn parse_slash_command(input: &str) -> SlashCommand { "seed" => SlashCommand::Seed, "supersede" => SlashCommand::Supersede(argument), "consolidate" => SlashCommand::Consolidate, - "export" => SlashCommand::Export, + "export" => SlashCommand::Export(argument), "sync" => SlashCommand::Sync, + "integrations" | "connect" => SlashCommand::Integrations, "stream" => SlashCommand::Stream, "watch" => SlashCommand::Watch, "" => SlashCommand::Unknown(String::new()), @@ -44,7 +46,7 @@ pub fn parse_slash_command(input: &str) -> SlashCommand { } pub fn command_help() -> &'static str { - "/rings /search /remember /forget /redact /promote /scar /seed /supersede /consolidate /export /sync" + "/rings /search /remember /forget /redact /promote /scar /seed /supersede /consolidate /export /sync /integrations" } #[cfg(test)] @@ -71,4 +73,21 @@ mod tests { SlashCommand::Supersede("mem_old".to_string()) ); } + + #[test] + fn parses_export_target() { + assert_eq!( + parse_slash_command("/export backup.jsonl"), + SlashCommand::Export("backup.jsonl".to_string()) + ); + } + + #[test] + fn parses_integrations_aliases() { + assert_eq!( + parse_slash_command("/integrations"), + SlashCommand::Integrations + ); + assert_eq!(parse_slash_command("/connect"), SlashCommand::Integrations); + } } diff --git a/crates/tree-ring-memory-cli/src/tui/render.rs b/crates/tree-ring-memory-cli/src/tui/render.rs index 63d320d..e1b9ea3 100644 --- a/crates/tree-ring-memory-cli/src/tui/render.rs +++ b/crates/tree-ring-memory-cli/src/tui/render.rs @@ -46,6 +46,7 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) { AppMode::Search => "search", AppMode::Stream => "stream", AppMode::Watch => "watch", + AppMode::Integrations => "integrations", }; let header = Paragraph::new(Line::from(vec![ Span::styled("TREE RING MEMORY", theme::brand()), @@ -144,6 +145,10 @@ fn render_ring_hud(frame: &mut Frame<'_>, area: Rect, app: &App) { } fn render_results(frame: &mut Frame<'_>, area: Rect, app: &App) { + if app.mode == AppMode::Integrations { + render_integrations(frame, area, app); + return; + } let title = if app.search_query.is_empty() { "Memories".to_string() } else { @@ -188,6 +193,48 @@ fn render_results(frame: &mut Frame<'_>, area: Rect, app: &App) { frame.render_widget(list, area); } +fn render_integrations(frame: &mut Frame<'_>, area: Rect, app: &App) { + let items = app + .integration_report + .as_ref() + .map(|report| { + report + .integrations + .iter() + .map(|integration| { + let detected = + integration.status == crate::integrations::IntegrationStatus::Detected; + let marker = if detected { "*" } else { " " }; + let style = if detected { + theme::selected() + } else { + theme::dim() + }; + ListItem::new(Line::from(vec![ + Span::styled(marker, theme::secondary_accent()), + Span::styled(format!(" {:<18}", integration.name), style), + Span::styled( + format!(" {:?} {:.2}", integration.status, integration.confidence), + theme::dim(), + ), + ])) + }) + .collect::>() + }) + .unwrap_or_else(|| { + vec![ListItem::new(Line::from(Span::styled( + "Run /integrations to scan.", + theme::dim(), + )))] + }); + let title = app + .integration_report + .as_ref() + .map(|report| format!("Agent Frameworks: {} detected", report.detected_count)) + .unwrap_or_else(|| "Agent Frameworks".to_string()); + frame.render_widget(List::new(items).block(theme::panel(title)), area); +} + fn memory_item<'a>( index: usize, selected: usize, @@ -227,7 +274,31 @@ fn memory_item<'a>( fn render_detail(frame: &mut Frame<'_>, area: Rect, app: &App) { let mut lines = Vec::new(); - if let Some(memory) = app.selected_memory() { + if app.mode == AppMode::Integrations { + if let Some(report) = &app.integration_report { + lines.push(Line::from(vec![ + Span::styled("root ", theme::dim()), + Span::raw(report.root.display().to_string()), + ])); + lines.push(Line::from("")); + for integration in report.integrations.iter().take(6) { + lines.push(Line::from(vec![ + Span::styled(format!("{} ", integration.name), theme::brand()), + Span::styled(format!("{:?}", integration.status), theme::dim()), + ])); + lines.push(Line::from(truncate(integration.next_step, 140))); + if !integration.markers.is_empty() { + lines.push(Line::from(Span::styled( + truncate(&format!("markers: {}", integration.markers.join(", ")), 140), + theme::dim(), + ))); + } + lines.push(Line::from("")); + } + } else { + lines.push(Line::from("Run /integrations to scan local agent markers.")); + } + } else if let Some(memory) = app.selected_memory() { let details = if memory.sensitivity == "normal" || app.include_sensitive { truncate(&memory.details, 220) } else { diff --git a/crates/tree-ring-memory-core/Cargo.toml b/crates/tree-ring-memory-core/Cargo.toml index b669590..add3165 100644 --- a/crates/tree-ring-memory-core/Cargo.toml +++ b/crates/tree-ring-memory-core/Cargo.toml @@ -17,3 +17,4 @@ uuid.workspace = true [dev-dependencies] serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/tree-ring-memory-core/src/audit.rs b/crates/tree-ring-memory-core/src/audit.rs index 7e570ea..3585bb0 100644 --- a/crates/tree-ring-memory-core/src/audit.rs +++ b/crates/tree-ring-memory-core/src/audit.rs @@ -513,7 +513,7 @@ mod tests { } #[test] - fn contradiction_normalization_preserves_unicode_and_underscores_like_python() { + fn contradiction_normalization_preserves_unicode_and_underscores() { let mut use_memory = MemoryEvent::new("Use résumé_cache_key", "decision").unwrap(); let mut avoid_memory = MemoryEvent::new("Avoid résumé_cache_key.", "decision").unwrap(); use_memory.project = Some("ui".to_string()); diff --git a/crates/tree-ring-memory-core/src/dox.rs b/crates/tree-ring-memory-core/src/dox.rs new file mode 100644 index 0000000..5034d3e --- /dev/null +++ b/crates/tree-ring-memory-core/src/dox.rs @@ -0,0 +1,466 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::models::{sqlite_error, MemoryEvent, MemoryLink, TreeRingResult}; +use crate::sensitivity::SensitivityGuard; + +const DEFAULT_MAX_FILES: usize = 128; +const DEFAULT_MAX_SECTIONS_PER_FILE: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoxSyncRequest { + pub root: PathBuf, + pub project: Option, + pub max_files: usize, + pub max_sections_per_file: usize, +} + +impl DoxSyncRequest { + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + project: None, + max_files: DEFAULT_MAX_FILES, + max_sections_per_file: DEFAULT_MAX_SECTIONS_PER_FILE, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DoxSyncReport { + pub root: PathBuf, + pub source_count: usize, + pub memory_count: usize, + pub skipped_secret_count: usize, + pub warnings: Vec, + pub events: Vec, +} + +pub fn collect_dox_memories(request: &DoxSyncRequest) -> TreeRingResult { + let files = discover_agents_files(&request.root, request.max_files)?; + let mut events = Vec::new(); + let mut warnings = Vec::new(); + let mut skipped_secret_count = 0; + + for path in &files { + match events_from_agents_file(request, path) { + Ok(mut file_events) => events.append(&mut file_events), + Err(AdapterSkip::Secret) => skipped_secret_count += 1, + Err(AdapterSkip::Unreadable(message)) => warnings.push(message), + } + } + + let source_count = files.len(); + Ok(DoxSyncReport { + root: request.root.clone(), + source_count, + memory_count: events.len(), + skipped_secret_count, + warnings, + events, + }) +} + +fn discover_agents_files(root: &Path, max_files: usize) -> TreeRingResult> { + let mut output = Vec::new(); + if !root.exists() { + return Err(sqlite_error(format!( + "DOX root does not exist: {}", + root.display() + ))); + } + let metadata = fs::symlink_metadata(root).map_err(|err| sqlite_error(err.to_string()))?; + if metadata.file_type().is_symlink() { + return Err(sqlite_error(format!( + "DOX root cannot be a symlink: {}", + root.display() + ))); + } + if metadata.is_file() { + if root.file_name().and_then(|name| name.to_str()) == Some("AGENTS.md") { + output.push(root.to_path_buf()); + } + return Ok(output); + } + visit_directory(root, max_files, &mut output)?; + output.sort(); + Ok(output) +} + +fn visit_directory(root: &Path, max_files: usize, output: &mut Vec) -> TreeRingResult<()> { + if output.len() >= max_files { + return Ok(()); + } + let mut entries = fs::read_dir(root) + .map_err(|err| sqlite_error(err.to_string()))? + .collect::, _>>() + .map_err(|err| sqlite_error(err.to_string()))?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + if output.len() >= max_files { + break; + } + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|err| sqlite_error(err.to_string()))?; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_dir() { + if should_skip_dir(&path) { + continue; + } + visit_directory(&path, max_files, output)?; + } else if path.file_name().and_then(|name| name.to_str()) == Some("AGENTS.md") { + output.push(path); + } + } + Ok(()) +} + +fn should_skip_dir(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some(".git" | "target" | "node_modules" | ".tree-ring") + ) +} + +fn events_from_agents_file( + request: &DoxSyncRequest, + path: &Path, +) -> Result, AdapterSkip> { + let content = + fs::read_to_string(path).map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + let relative = relative_display(&request.root, path); + let sections = extract_sections(&content, request.max_sections_per_file); + let mut events = Vec::new(); + let guard = SensitivityGuard::default(); + + for section in sections { + let snippet = summarize_lines(§ion.lines); + if snippet.is_empty() { + continue; + } + let category = classify_section(§ion.heading, &snippet); + let durable = is_durable_rule(§ion.heading, &snippet); + let event_type = if durable { + "dox_contract" + } else { + "dox_guidance" + }; + let ring = if durable { "heartwood" } else { "outer" }; + let summary = format!("DOX {category} guidance in {relative}: {snippet}"); + let source_ref = format!( + "{relative}#{}-{}", + slugify(§ion.heading), + section.occurrence + ); + let mut event = MemoryEvent::new(summary, event_type) + .map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + event.id = stable_id("dox", &source_ref); + event.scope = "dox".to_string(); + event.ring = ring.to_string(); + event.project = request.project.clone(); + event.details = format!( + "Source AGENTS.md remains authoritative. Re-read {relative} before acting. Heading: {}.", + section.heading + ); + event.source.source_type = "dox".to_string(); + event.source.ref_ = source_ref.clone(); + event.source.quote = snippet.clone(); + event.tags = vec![ + "dox".to_string(), + "agents-md".to_string(), + category.to_string(), + ]; + event.salience = if durable { 0.84 } else { 0.62 }; + event.confidence = if durable { 0.88 } else { 0.72 }; + event.retention = if durable { + "durable".to_string() + } else { + "normal".to_string() + }; + event.links.push(MemoryLink { + link_type: "dox".to_string(), + target: relative.clone(), + }); + match guard.detect_memory_event_sensitivity(&event) { + Ok(sensitivity) => { + if sensitivity != "normal" { + event.sensitivity = sensitivity; + } + } + Err(_) => return Err(AdapterSkip::Secret), + } + event + .validate() + .map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + events.push(event); + } + + Ok(events) +} + +#[derive(Debug, Clone)] +struct Section { + heading: String, + occurrence: usize, + lines: Vec, +} + +fn extract_sections(content: &str, max_sections: usize) -> Vec
{ + let mut sections = Vec::new(); + let mut current = Section { + heading: "overview".to_string(), + occurrence: 1, + lines: Vec::new(), + }; + let mut in_fence = false; + let mut occurrence = 1usize; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + if trimmed.starts_with('#') { + push_section(&mut sections, &mut current, max_sections); + current.heading = trimmed.trim_start_matches('#').trim().to_string(); + occurrence += 1; + current.occurrence = occurrence; + current.lines.clear(); + continue; + } + if !trimmed.is_empty() { + current.lines.push(trimmed.to_string()); + } + } + push_section(&mut sections, &mut current, max_sections); + sections +} + +fn push_section(sections: &mut Vec
, current: &mut Section, max_sections: usize) { + if sections.len() >= max_sections || current.lines.is_empty() { + return; + } + sections.push(current.clone()); +} + +fn summarize_lines(lines: &[String]) -> String { + let mut summary = lines + .iter() + .take(4) + .map(|line| { + line.trim() + .trim_start_matches(['-', '*']) + .trim() + .to_string() + }) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + summary = summary.split_whitespace().collect::>().join(" "); + truncate_chars(&summary, 180) +} + +fn classify_section(heading: &str, snippet: &str) -> &'static str { + let text = format!("{heading} {snippet}").to_ascii_lowercase(); + if text.contains("security") || text.contains("privacy") || text.contains("secret") { + "safety" + } else if text.contains("test") || text.contains("verify") || text.contains("check") { + "verification" + } else if text.contains("build") || text.contains("run") || text.contains("install") { + "workflow" + } else if text.contains("owner") || text.contains("authority") || text.contains("contract") { + "authority" + } else { + "project" + } +} + +fn is_durable_rule(heading: &str, snippet: &str) -> bool { + let text = format!("{heading} {snippet}").to_ascii_lowercase(); + [ + "must", + "never", + "required", + "do not", + "contract", + "authority", + "rule", + "security", + "privacy", + ] + .iter() + .any(|needle| text.contains(needle)) +} + +fn relative_display(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn slugify(value: &str) -> String { + let slug = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + slug.split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-") +} + +fn stable_id(prefix: &str, value: &str) -> String { + format!("mem_{prefix}_{:016x}", stable_hash(value.as_bytes())) +} + +fn stable_hash(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push_str("..."); + truncated +} + +#[derive(Debug, Clone)] +enum AdapterSkip { + Secret, + Unreadable(String), +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn discovers_nested_agents_files_and_preserves_source_refs() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Rules\n- You must run cargo test before release.", + ) + .unwrap(); + fs::create_dir_all(dir.path().join("crates/core")).unwrap(); + fs::write( + dir.path().join("crates/core/AGENTS.md"), + "# Verification\nUse focused tests before full cargo test.", + ) + .unwrap(); + + let mut request = DoxSyncRequest::new(dir.path()); + request.project = Some("tree-ring".to_string()); + let report = collect_dox_memories(&request).unwrap(); + + assert_eq!(report.source_count, 2); + assert!(report + .events + .iter() + .any(|event| event.source.ref_ == "AGENTS.md#rules-2")); + assert!(report + .events + .iter() + .any(|event| event.source.ref_ == "crates/core/AGENTS.md#verification-2")); + assert!(report.events.iter().all(|event| event.scope == "dox")); + } + + #[test] + fn summarizes_agents_without_full_doc_dump() { + let dir = tempdir().unwrap(); + let long_line = "a ".repeat(400); + fs::write( + dir.path().join("AGENTS.md"), + format!("# Guidance\n{long_line}\n\n## More\n{long_line}"), + ) + .unwrap(); + + let report = collect_dox_memories(&DoxSyncRequest::new(dir.path())).unwrap(); + + assert!(!report.events.is_empty()); + assert!(report.events.iter().all(|event| event.summary.len() < 260)); + assert!(report + .events + .iter() + .all(|event| event.details.contains("remains authoritative"))); + } + + #[test] + fn durable_dox_rules_become_heartwood_contracts() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Contract\nYou must not store secrets in memory.", + ) + .unwrap(); + + let report = collect_dox_memories(&DoxSyncRequest::new(dir.path())).unwrap(); + let event = report.events.first().unwrap(); + + assert_eq!(event.ring, "heartwood"); + assert_eq!(event.event_type, "dox_contract"); + assert_eq!(event.retention, "durable"); + } + + #[test] + fn duplicate_headings_get_distinct_source_refs_and_ids() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Rules\nYou must run focused tests.\n# Rules\nNever store secrets.", + ) + .unwrap(); + + let report = collect_dox_memories(&DoxSyncRequest::new(dir.path())).unwrap(); + + assert_eq!(report.events.len(), 2); + assert_ne!(report.events[0].id, report.events[1].id); + assert_ne!(report.events[0].source.ref_, report.events[1].source.ref_); + } + + #[cfg(unix)] + #[test] + fn skips_symlinked_directories() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::write( + outside.path().join("AGENTS.md"), + "# Outside\nYou must not import this.", + ) + .unwrap(); + symlink(outside.path(), dir.path().join("outside-link")).unwrap(); + + let report = collect_dox_memories(&DoxSyncRequest::new(dir.path())).unwrap(); + + assert_eq!(report.source_count, 0); + assert!(report.events.is_empty()); + } +} diff --git a/crates/tree-ring-memory-core/src/lib.rs b/crates/tree-ring-memory-core/src/lib.rs index 54dae17..10c8759 100644 --- a/crates/tree-ring-memory-core/src/lib.rs +++ b/crates/tree-ring-memory-core/src/lib.rs @@ -1,9 +1,11 @@ pub mod audit; pub mod consolidation; +pub mod dox; pub mod import_export; pub mod maintenance; pub mod models; pub mod recall; +pub mod revolve; pub mod sensitivity; pub use audit::{audit_memories, AuditFinding, AuditReport, AuditSeverity, AuditType, AUDIT_TYPES}; @@ -11,6 +13,7 @@ pub use consolidation::{ consolidate_memories, period_key_for_datetime, ConsolidationOutput, ConsolidationPeriod, ConsolidationReport, ConsolidationRequest, CONSOLIDATION_PERIODS, }; +pub use dox::{collect_dox_memories, DoxSyncReport, DoxSyncRequest}; pub use import_export::{ decode_jsonl, encode_jsonl, normalize_import_event, normalize_import_events, DecodedJsonl, ExportHeader, MemoryEventEnvelope, EXPORT_PLUGIN_VERSION, EXPORT_RECORD_TYPE, @@ -24,4 +27,5 @@ pub use models::{ now_iso, MemoryEvent, MemoryLink, MemoryReview, MemorySource, TreeRingError, TreeRingResult, }; pub use recall::{RecallRanking, RecallScore, RecallScorer}; +pub use revolve::{collect_revolve_memories, RevolveSyncReport, RevolveSyncRequest}; pub use sensitivity::{SensitivityGuard, SensitivityResult}; diff --git a/crates/tree-ring-memory-core/src/models.rs b/crates/tree-ring-memory-core/src/models.rs index 43cbb64..88beec0 100644 --- a/crates/tree-ring-memory-core/src/models.rs +++ b/crates/tree-ring-memory-core/src/models.rs @@ -5,7 +5,7 @@ use uuid::Uuid; pub const RINGS: &[&str] = &["cambium", "outer", "inner", "heartwood", "scar", "seed"]; pub const SCOPES: &[&str] = &[ - "global", "project", "agent", "session", "workflow", "tool", "eval", "manual", + "global", "project", "agent", "session", "workflow", "tool", "eval", "manual", "dox", "revolve", ]; pub const SENSITIVITIES: &[&str] = &[ "normal", @@ -291,7 +291,7 @@ mod tests { } #[test] - fn schema_valid_sparse_memory_defaults_like_python() { + fn schema_valid_sparse_memory_uses_legacy_defaults() { let payload = include_str!("../../../fixtures/parity/schema-valid-sparse-memory.json"); let event: MemoryEvent = serde_json::from_str(payload).unwrap(); event.validate().unwrap(); diff --git a/crates/tree-ring-memory-core/src/revolve.rs b/crates/tree-ring-memory-core/src/revolve.rs new file mode 100644 index 0000000..e532142 --- /dev/null +++ b/crates/tree-ring-memory-core/src/revolve.rs @@ -0,0 +1,420 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::models::{sqlite_error, MemoryEvent, MemoryLink, TreeRingResult}; +use crate::sensitivity::SensitivityGuard; + +const DEFAULT_MAX_FILES: usize = 256; +const MAX_SOURCE_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RevolveSyncRequest { + pub root: PathBuf, + pub project: Option, + pub max_files: usize, +} + +impl RevolveSyncRequest { + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + project: None, + max_files: DEFAULT_MAX_FILES, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RevolveSyncReport { + pub root: PathBuf, + pub source_count: usize, + pub memory_count: usize, + pub skipped_large_count: usize, + pub skipped_secret_count: usize, + pub warnings: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RevolveOutcome { + Promoted, + Rejected, + Deferred, + Observed, +} + +pub fn collect_revolve_memories(request: &RevolveSyncRequest) -> TreeRingResult { + let files = discover_revolve_files(&request.root, request.max_files)?; + let mut events = Vec::new(); + let mut warnings = Vec::new(); + let mut skipped_large_count = 0; + let mut skipped_secret_count = 0; + + for path in &files { + match event_from_revolve_file(request, path) { + Ok(Some(event)) => events.push(event), + Ok(None) => {} + Err(AdapterSkip::Large) => skipped_large_count += 1, + Err(AdapterSkip::Secret) => skipped_secret_count += 1, + Err(AdapterSkip::Unreadable(message)) => warnings.push(message), + } + } + + let source_count = files.len(); + Ok(RevolveSyncReport { + root: request.root.clone(), + source_count, + memory_count: events.len(), + skipped_large_count, + skipped_secret_count, + warnings, + events, + }) +} + +fn discover_revolve_files(root: &Path, max_files: usize) -> TreeRingResult> { + if !root.exists() { + return Err(sqlite_error(format!( + "Revolve root does not exist: {}", + root.display() + ))); + } + let metadata = fs::symlink_metadata(root).map_err(|err| sqlite_error(err.to_string()))?; + if metadata.file_type().is_symlink() { + return Err(sqlite_error(format!( + "Revolve root cannot be a symlink: {}", + root.display() + ))); + } + if metadata.is_file() { + return Ok(if is_supported_source(root) { + vec![root.to_path_buf()] + } else { + Vec::new() + }); + } + let mut output = Vec::new(); + visit_directory(root, max_files, &mut output)?; + output.sort(); + Ok(output) +} + +fn visit_directory(root: &Path, max_files: usize, output: &mut Vec) -> TreeRingResult<()> { + if output.len() >= max_files { + return Ok(()); + } + let mut entries = fs::read_dir(root) + .map_err(|err| sqlite_error(err.to_string()))? + .collect::, _>>() + .map_err(|err| sqlite_error(err.to_string()))?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + if output.len() >= max_files { + break; + } + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|err| sqlite_error(err.to_string()))?; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_dir() { + if should_skip_dir(&path) { + continue; + } + visit_directory(&path, max_files, output)?; + } else if is_supported_source(&path) { + output.push(path); + } + } + Ok(()) +} + +fn should_skip_dir(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some(".git" | "target" | "node_modules" | ".tree-ring") + ) +} + +fn is_supported_source(path: &Path) -> bool { + matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("md" | "txt" | "json" | "jsonl") + ) +} + +fn event_from_revolve_file( + request: &RevolveSyncRequest, + path: &Path, +) -> Result, AdapterSkip> { + let metadata = fs::metadata(path).map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + if metadata.len() > MAX_SOURCE_BYTES { + return Err(AdapterSkip::Large); + } + let content = + fs::read_to_string(path).map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + let relative = relative_display(&request.root, path); + let haystack = format!("{} {}", relative, content).to_ascii_lowercase(); + let Some(outcome) = classify_outcome(&haystack) else { + return Ok(None); + }; + let summary = revolve_summary(outcome, &relative, &content); + let (ring, event_type, salience, confidence, retention) = outcome_mapping(outcome); + let source_ref = relative.clone(); + let mut event = MemoryEvent::new(summary, event_type) + .map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + event.id = stable_id("revolve", &format!("{source_ref}:{event_type}")); + event.scope = "revolve".to_string(); + event.ring = ring.to_string(); + event.project = request.project.clone(); + event.details = format!( + "Outcome: {}. Source Revolve record remains authoritative. Re-read {relative} before treating this memory as current truth.", + outcome.as_str() + ); + event.source.source_type = "revolve".to_string(); + event.source.ref_ = source_ref.clone(); + event.tags = vec![ + "revolve".to_string(), + "evidence".to_string(), + format!("outcome:{}", outcome.as_str()), + ]; + event.salience = salience; + event.confidence = confidence; + event.retention = retention.to_string(); + event.links.push(MemoryLink { + link_type: "revolve".to_string(), + target: relative, + }); + match SensitivityGuard::default().detect_memory_event_sensitivity(&event) { + Ok(sensitivity) => { + if sensitivity != "normal" { + event.sensitivity = sensitivity; + } + } + Err(_) => return Err(AdapterSkip::Secret), + } + event + .validate() + .map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + Ok(Some(event)) +} + +fn classify_outcome(text: &str) -> Option { + if contains_any( + text, + &[ + "rejected", + "rejection", + "rolled back", + "rollback", + "regression", + "failed branch", + ], + ) { + Some(RevolveOutcome::Rejected) + } else if contains_any(text, &["promoted", "promotion", "accepted", "winner"]) { + Some(RevolveOutcome::Promoted) + } else if contains_any( + text, + &[ + "deferred", + "future work", + "hypothesis", + "seed", + "unresolved", + ], + ) { + Some(RevolveOutcome::Deferred) + } else if contains_any( + text, + &["evaluation", "result", "score", "checkpoint", "run"], + ) { + Some(RevolveOutcome::Observed) + } else { + None + } +} + +fn contains_any(text: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| text.contains(needle)) +} + +fn outcome_mapping( + outcome: RevolveOutcome, +) -> (&'static str, &'static str, f64, f64, &'static str) { + match outcome { + RevolveOutcome::Promoted => ("heartwood", "evaluation_promotion", 0.86, 0.84, "durable"), + RevolveOutcome::Rejected => ("scar", "evaluation_rejection", 0.90, 0.78, "durable"), + RevolveOutcome::Deferred => ("seed", "evaluation_hypothesis", 0.68, 0.60, "normal"), + RevolveOutcome::Observed => ("outer", "evaluation_result", 0.72, 0.70, "normal"), + } +} + +fn revolve_summary(outcome: RevolveOutcome, relative: &str, content: &str) -> String { + let excerpt = first_meaningful_line(content) + .map(|line| truncate_chars(&line, 180)) + .unwrap_or_else(|| format!("Revolve {} evidence in {relative}", outcome.as_str())); + format!( + "Revolve {} evidence from {relative}: {excerpt}", + outcome.as_str() + ) +} + +fn first_meaningful_line(content: &str) -> Option { + content + .lines() + .map(|line| { + line.trim() + .trim_start_matches('#') + .trim_start_matches(['-', '*']) + .trim() + .to_string() + }) + .find(|line| !line.is_empty() && line.len() > 6) +} + +impl RevolveOutcome { + fn as_str(&self) -> &'static str { + match self { + Self::Promoted => "promoted", + Self::Rejected => "rejected", + Self::Deferred => "deferred", + Self::Observed => "observed", + } + } +} + +fn relative_display(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn stable_id(prefix: &str, value: &str) -> String { + format!("mem_{prefix}_{:016x}", stable_hash(value.as_bytes())) +} + +fn stable_hash(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push_str("..."); + truncated +} + +#[derive(Debug, Clone)] +enum AdapterSkip { + Large, + Secret, + Unreadable(String), +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn imports_promotions_as_heartwood_evidence() { + let dir = tempdir().unwrap(); + fs::create_dir_all( + dir.path() + .join("projects/ui/revisions/rev-001/checkpoints/win"), + ) + .unwrap(); + fs::write( + dir.path() + .join("projects/ui/revisions/rev-001/checkpoints/win/AGENTS.md"), + "# Promotion\nPromoted snapshot invalidation because it fixed stale unread state.", + ) + .unwrap(); + + let report = collect_revolve_memories(&RevolveSyncRequest::new(dir.path())).unwrap(); + let event = report.events.first().unwrap(); + + assert_eq!(event.ring, "heartwood"); + assert_eq!(event.scope, "revolve"); + assert_eq!(event.event_type, "evaluation_promotion"); + assert_eq!(event.retention, "durable"); + assert!(event.source.ref_.contains("projects/ui")); + } + + #[test] + fn imports_rejections_as_scars() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("cache-experiment.md"), + "# Rejected\nRejected aggressive caching after stale multi-chat regression.", + ) + .unwrap(); + + let report = collect_revolve_memories(&RevolveSyncRequest::new(dir.path())).unwrap(); + let event = report.events.first().unwrap(); + + assert_eq!(event.ring, "scar"); + assert_eq!(event.event_type, "evaluation_rejection"); + assert!(event.tags.contains(&"outcome:rejected".to_string())); + } + + #[test] + fn imports_deferred_hypotheses_as_seeds() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("timeline-ui.md"), + "# Deferred\nHypothesis: a visual ring timeline may improve explainability.", + ) + .unwrap(); + + let report = collect_revolve_memories(&RevolveSyncRequest::new(dir.path())).unwrap(); + let event = report.events.first().unwrap(); + + assert_eq!(event.ring, "seed"); + assert_eq!(event.event_type, "evaluation_hypothesis"); + } + + #[test] + fn ignores_sources_without_evidence_outcomes() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("notes.md"), "plain notes without outcome").unwrap(); + + let report = collect_revolve_memories(&RevolveSyncRequest::new(dir.path())).unwrap(); + + assert_eq!(report.memory_count, 0); + } + + #[cfg(unix)] + #[test] + fn skips_symlinked_directories() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::write( + outside.path().join("promotion.md"), + "# Promotion\nPromoted external branch.", + ) + .unwrap(); + symlink(outside.path(), dir.path().join("outside-link")).unwrap(); + + let report = collect_revolve_memories(&RevolveSyncRequest::new(dir.path())).unwrap(); + + assert_eq!(report.source_count, 0); + assert!(report.events.is_empty()); + } +} diff --git a/docs/architecture/rust-core-roadmap.md b/docs/architecture/rust-core-roadmap.md index d6b5c9f..d4b3a94 100644 --- a/docs/architecture/rust-core-roadmap.md +++ b/docs/architecture/rust-core-roadmap.md @@ -10,7 +10,7 @@ Rust fits the long-term shape of Tree Ring Memory because the framework should b - fast for local recall and consolidation - safe around privacy-sensitive local data - predictable under concurrent CLI, server, and tool access -- portable across Python, Node, CLI, desktop, and sidecar deployments +- portable across CLI, desktop, sidecar, and agent-harness deployments - strict about schema boundaries and invalid state SQLite and FTS remain the right default storage layer. Rust should own the lifecycle logic around it. @@ -23,9 +23,6 @@ tree-ring-memory/ │ ├── tree-ring-memory-core/ # models, validation, privacy, recall ranking │ ├── tree-ring-memory-sqlite/ # SQLite/FTS storage backend │ └── tree-ring-memory-cli/ # native CLI -├── bindings/ -│ ├── python/ # optional Rust-built CPython extension -│ └── node/ # optional Node package wrapper ├── skills/ ├── templates/ ├── schemas/ @@ -38,8 +35,8 @@ tree-ring-memory/ evidence. 2. Keep Rust as the runtime owner for schema, sensitivity, storage, recall, forget, import/export, audit, consolidation, maintenance, CLI, and TUI. -3. Keep host bindings as optional Rust-built artifacts, not runtime owners. -4. Add optional Node bindings after the Rust API stabilizes. +3. Keep host integrations outside the runtime core unless they are Rust-native + adapters maintained with the same lifecycle guarantees. ## Rust Core Requirements @@ -55,12 +52,12 @@ The Rust core must support: - JSON import/export compatibility with the published schemas v0.4 implements the JSONL import/export baseline in Rust and exposes it through -the CLI and Python backends. Markdown exports, SQLite backups, and signed -bundles remain future extension points. +the CLI. Markdown exports, SQLite backups, and signed bundles remain future +extension points. v0.5 implements deterministic local audit checks in Rust and exposes them -through SQLite, CLI, and native Python surfaces. Consolidation and automatic -repair remain future extension points. +through SQLite and CLI surfaces. Consolidation and automatic repair remain +future extension points. v0.6 implements deterministic consolidation in Rust. It creates source-linked summary memories without LLMs, persists idempotent consolidation records, and @@ -71,13 +68,16 @@ secret-like redaction, protected-memory review, invalid-expiry review, and SQLite FTS drift repair. Apply/repair behavior is explicit and Rust-owned. Adapter-specific sync remains a future extension point. -v0.8 removes Python-owned runtime behavior. Python remains useful for agent -workflows through the native binding, but no durable behavior is implemented in -Python. +v0.8 removes Python-owned runtime behavior. -v0.9 removes tracked Python source, tests, and smoke scripts from the canonical -repository. The optional CPython extension remains under `bindings/python`, but -it is built from Rust and is not the public runtime owner. +v0.9 removes tracked Python source, tests, smoke scripts, and the optional +CPython extension from the canonical repository. + +v0.10 adds one-line installer onboarding and the Rust-native terminal welcome +flow. + +v0.11 adds Rust-native DOX and Revolve source adapters, TUI export and +consolidation backend actions, and read-only agent-framework discovery. ## Non-Goals @@ -90,5 +90,5 @@ The Rust rewrite should not: ## Decision -The framework direction is Rust-native runtime, adapter-friendly edges. -Host bindings are integration edges, not runtime owners. +The framework direction is Rust-native runtime, adapter-friendly CLI and local +protocol edges. Host integrations are coordination edges, not runtime owners. diff --git a/docs/architecture/rust-core-status.md b/docs/architecture/rust-core-status.md index 7efb99b..a9fb3f3 100644 --- a/docs/architecture/rust-core-status.md +++ b/docs/architecture/rust-core-status.md @@ -1,12 +1,12 @@ # Rust Core Status -Tree Ring Memory has moved from an early Python prototype to a Rust-owned -runtime. This page tracks the v0.2 Rust -core, v0.3 native Python binding work, the Rust-native Ratatui terminal -console, the v0.4 Rust-owned JSONL import/export path, and v0.5 deterministic -audit checks, the v0.6 deterministic consolidation path, the v0.7 Rust-owned -maintenance lifecycle, v0.8 Python-runtime removal, v0.9 removal of tracked -Python source from the canonical repo, and v0.10 installer/onboarding work. +Tree Ring Memory has moved from an early prototype to a Rust-owned runtime. +This page tracks the v0.2 Rust core, the Rust-native Ratatui terminal console, +the v0.4 Rust-owned JSONL import/export path, v0.5 deterministic audit checks, +v0.6 deterministic consolidation, v0.7 Rust-owned maintenance, v0.8 +Python-runtime removal, v0.9 removal of tracked Python source and optional +CPython bindings from the canonical repo, v0.10 installer/onboarding work, and +v0.11 Rust-native source adapters plus framework discovery. ## Current Status @@ -16,13 +16,8 @@ Python source from the canonical repo, and v0.10 installer/onboarding work. - Rust SQLite crate owns schema-compatible SQLite/FTS storage. - Rust CLI can initialize, remember, recall, and forget local memory. - Rust CLI has JSON output for machine-readable adapter use. -- `bindings/python` remains an optional Rust-built CPython extension crate. - The repository no longer tracks a root Python package, Python wrapper layer, - pytest suite, or Python smoke scripts. -- The v0.3 native backend supports the full public `remember()` and `recall()` - contracts, including details, source metadata, agent profile, scores, - retention, expiry, links, review metadata, supersession, recall filters, - superseded-memory inclusion, and ranking explanations. + pytest suite, Python smoke scripts, PyO3 crate, or CPython extension. - The v0.4 Rust core and SQLite store own portable JSONL import/export. Exports exclude sensitive and superseded memories by default; import validates events, supports dry-run previews, skips duplicate ids by default, and only @@ -46,6 +41,11 @@ Python source from the canonical repo, and v0.10 installer/onboarding work. gates for destructive or authority-changing actions. - The repository includes `install.sh` for one-line global or project-local installs, plus `tree-ring welcome` for first-run terminal onboarding. +- The Rust CLI includes `tree-ring dox sync` and `tree-ring revolve sync` as + source adapters that produce concise, source-linked memory events without + replacing DOX contracts or Revolve evidence records. +- The Rust CLI and TUI include read-only agent-framework discovery for DOX, + Revolve, Codex, Claude Code, Agent Zero/A0, Goose, OpenCode, Hermes, and Pi. ## Build Commands @@ -60,29 +60,24 @@ cargo run -p tree-ring-memory-cli -- import --help cargo run -p tree-ring-memory-cli -- audit --help cargo run -p tree-ring-memory-cli -- consolidate --help cargo run -p tree-ring-memory-cli -- maintain --help -cargo build -p tree-ring-memory-python --features extension-module +cargo run -p tree-ring-memory-cli -- dox sync --help +cargo run -p tree-ring-memory-cli -- revolve sync --help +cargo run -p tree-ring-memory-cli -- integrations scan --help cargo run --release -p tree-ring-memory-sqlite --example performance_smoke -- 1000 ``` -## Optional CPython Extension - -`bindings/python` builds a CPython extension from Rust through PyO3. It is an -adapter artifact for host runtimes that need it, not a Python implementation and -not the canonical public runtime. - ## Smoke Coverage - Rust unit tests cover model validation, sensitivity checks, recall scoring, SQLite/FTS storage, transactional row/FTS consistency, redaction, JSONL import/export filtering and duplicate handling, deterministic audit checks, deterministic consolidation planning, maintenance planning/application, FTS - repair, and basic concurrent writes. Rust PyO3 tests cover native JSON - remember/recall round-trip, forget validation, JSONL import/export, audit, - consolidation, and maintenance. + repair, and basic concurrent writes. - Rust CLI tests cover the scriptable init/remember/recall/forget commands and JSONL import/export/audit/consolidate commands plus the Ratatui TUI model, stream reader, slash-command parser, store-watch refresh, confirmation-gated - actions, CLI parsing, and render-buffer smoke. + actions, DOX/Revolve sync commands, framework discovery, CLI parsing, and + render-buffer smoke. - `crates/tree-ring-memory-sqlite/examples/performance_smoke.rs` provides an operator-run local insert and recall timing check. It fails if expected recalls are empty, emits a stable `METRICS_JSON=` line, and enforces diff --git a/docs/feature/README.md b/docs/feature/README.md new file mode 100644 index 0000000..77d07f9 --- /dev/null +++ b/docs/feature/README.md @@ -0,0 +1,8 @@ +# Historical Feature Exploration + +This directory contains exploratory design artifacts from earlier phases. Keep +them for provenance, but use the root README and `docs/architecture/` documents +for current Tree Ring Memory behavior. + +Earlier files may mention Python reference implementations or binding surfaces. +Those decisions have been superseded by the Rust-native runtime and CLI. diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md new file mode 100644 index 0000000..cdbd8cd --- /dev/null +++ b/docs/superpowers/README.md @@ -0,0 +1,19 @@ +# Historical Planning Records + +This directory preserves earlier Tree Ring Memory planning records. They are +useful context for how the project moved from a prototype into a Rust-native +framework, but they are not the current implementation contract. + +Current source of truth: + +- `README.md` +- `docs/architecture/rust-core-status.md` +- `docs/architecture/rust-core-roadmap.md` +- `docs/protocol/memory-event.md` +- `skills/tree-ring-memory/SKILL.md` + +Some archived plans and specs mention a Python prototype, PyO3 bindings, pytest, +or transitional compatibility surfaces. Those references are historical. The +canonical runtime is now Rust-native: crates plus the `tree-ring` CLI own +storage, recall, import/export, audit, consolidation, maintenance, source +adapters, framework discovery, and the terminal UI. diff --git a/install.sh b/install.sh index 213d788..3d843de 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,8 @@ GIT_REF=${TREE_RING_REF:-"main"} INSTALL_SCOPE=${TREE_RING_INSTALL_SCOPE:-"global"} INSTALL_DIR=${TREE_RING_INSTALL_DIR:-""} SOURCE_DIR=${TREE_RING_SOURCE:-""} +ARCHIVE_URL=${TREE_RING_ARCHIVE_URL:-""} +ARCHIVE_SHA256=${TREE_RING_ARCHIVE_SHA256:-""} MEMORY_ROOT=${TREE_RING_ROOT:-".tree-ring"} RUN_INIT=${TREE_RING_INIT:-"0"} RUN_ONBOARDING=${TREE_RING_ONBOARDING:-"1"} @@ -42,6 +44,8 @@ Options: --repo URL Git repository used by cargo install. --ref REF Git branch used by cargo install (default main). --source DIR Install from a local checkout instead of git. + --archive-url URL Install from a release tarball containing tree-ring. + --archive-sha256 SUM Required SHA-256 for --archive-url. -h, --help Show this help. Environment: @@ -51,6 +55,8 @@ Environment: TREE_RING_REPO=https://github.com/TerminallyLazy/Tree-Ring-Memory TREE_RING_REF=main TREE_RING_SOURCE=/path/to/checkout + TREE_RING_ARCHIVE_URL=https://example/tree-ring-memory.tar.gz + TREE_RING_ARCHIVE_SHA256=... TREE_RING_INIT=1 TREE_RING_ANIMATION=0 TREE_RING_ONBOARDING=0 @@ -102,6 +108,16 @@ while [ "$#" -gt 0 ]; do [ "$#" -gt 0 ] || die "--source requires a value" SOURCE_DIR=$1 ;; + --archive-url) + shift + [ "$#" -gt 0 ] || die "--archive-url requires a value" + ARCHIVE_URL=$1 + ;; + --archive-sha256) + shift + [ "$#" -gt 0 ] || die "--archive-sha256 requires a value" + ARCHIVE_SHA256=$1 + ;; -h|--help) usage exit 0 @@ -113,6 +129,10 @@ while [ "$#" -gt 0 ]; do shift done +if [ "$ARCHIVE_URL" != "" ] && [ "$ARCHIVE_SHA256" = "" ]; then + die "--archive-sha256 is required with --archive-url" +fi + paint() { code=$1 text=$2 @@ -167,11 +187,40 @@ intro() { } require_cargo() { + if [ "$ARCHIVE_URL" != "" ]; then + return + fi if ! command -v cargo >/dev/null 2>&1; then die "cargo was not found. Install Rust from https://rustup.rs, then rerun this installer." fi } +download() { + url=$1 + output=$2 + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" + else + die "curl or wget is required for --archive-url" + fi +} + +verify_sha256() { + file=$1 + expected=$2 + [ "$expected" != "" ] || return + if command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$file" | awk '{print $1}') + elif command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$file" | awk '{print $1}') + else + die "SHA-256 verification requested, but shasum/sha256sum was not found" + fi + [ "$actual" = "$expected" ] || die "archive checksum mismatch" +} + install_prefix() { if [ "$INSTALL_DIR" != "" ]; then printf '%s' "$INSTALL_DIR" @@ -189,10 +238,23 @@ install_binary() { prefix=$1 mkdir -p "$prefix" line "Installing tree-ring into $prefix/bin" - line "Setting things up. This usually takes about 30 seconds after dependencies are cached." - if [ "$SOURCE_DIR" != "" ]; then + if [ "$ARCHIVE_URL" != "" ]; then + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/tree-ring-install.XXXXXX") + archive="$tmp_dir/tree-ring-memory.tar.gz" + download "$ARCHIVE_URL" "$archive" + verify_sha256 "$archive" "$ARCHIVE_SHA256" + tar -xzf "$archive" -C "$tmp_dir" + binary=$(find "$tmp_dir" -type f -name tree-ring | head -n 1) + [ "$binary" != "" ] || die "release archive did not contain tree-ring" + mkdir -p "$prefix/bin" + cp "$binary" "$prefix/bin/tree-ring" + chmod +x "$prefix/bin/tree-ring" + rm -rf "$tmp_dir" + elif [ "$SOURCE_DIR" != "" ]; then + line "Setting things up. This usually takes about 30 seconds after dependencies are cached." cargo install --path "$SOURCE_DIR/crates/tree-ring-memory-cli" --root "$prefix" --locked --force else + line "Setting things up. This usually takes about 30 seconds after dependencies are cached." cargo install --git "$REPO_URL" --branch "$GIT_REF" --package tree-ring-memory-cli --root "$prefix" --locked --force fi } diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..5c4d5e1 --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +VERSION=${TREE_RING_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)} +[ "$VERSION" != "" ] || { + printf '%s\n' "Could not determine Tree Ring Memory version" >&2 + exit 1 +} + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') +NAME="tree-ring-memory-$VERSION-$OS-$ARCH" +DIST_DIR=${TREE_RING_DIST_DIR:-dist} +WORK_DIR="$DIST_DIR/$NAME" + +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" + +cargo build --release -p tree-ring-memory-cli --locked + +cp target/release/tree-ring "$WORK_DIR/tree-ring" +cp README.md LICENSE install.sh "$WORK_DIR/" + +tar -C "$DIST_DIR" -czf "$DIST_DIR/$NAME.tar.gz" "$NAME" + +if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$DIST_DIR/$NAME.tar.gz" > "$DIST_DIR/$NAME.tar.gz.sha256" +elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$DIST_DIR/$NAME.tar.gz" > "$DIST_DIR/$NAME.tar.gz.sha256" +else + printf '%s\n' "warning: no SHA-256 tool found; checksum not written" >&2 +fi + +printf 'release_package=%s\n' "$DIST_DIR/$NAME.tar.gz" diff --git a/skills/tree-ring-memory/SKILL.md b/skills/tree-ring-memory/SKILL.md index a237b7e..a72fe7f 100644 --- a/skills/tree-ring-memory/SKILL.md +++ b/skills/tree-ring-memory/SKILL.md @@ -1,7 +1,7 @@ --- name: tree-ring-memory description: Guides AI agents in using Tree Ring Memory for durable recall, project decisions, user preferences, warnings, future seeds, privacy-safe memory capture, and lifecycle-aware forgetting. -version: 0.1.0 +version: 0.11.0 tags: ["memory", "agents", "recall", "privacy", "projects", "dox", "skills"] triggers: - "remember this" @@ -59,6 +59,19 @@ Use `tree-ring evidence` instead of plain `remember` when the lesson comes from an evaluation, checkpoint, experiment, branch, incident, or reviewed run artifact. +Use source adapters when project artifacts already contain structured guidance +or evaluated outcomes: + +```bash +tree-ring dox sync --source-root . --dry-run +tree-ring revolve sync --source-root revolve --dry-run +tree-ring integrations scan --source-root . +``` + +Run adapter commands with `--dry-run` first. Sync only concise, source-linked +summaries; never treat imported memory as more authoritative than the source +`AGENTS.md`, Revolve record, evaluation, PR, issue, or test artifact. + Evidence outcome mapping: - `promoted`: durable heartwood from supported evidence @@ -120,6 +133,9 @@ Set project and scope deliberately: - use global scope only for durable user preferences or cross-project guidance - include source references such as file paths, issue ids, PR ids, run ids, or docs paths - use `tree-ring evidence ... --evidence-ref ` for evaluated outcomes +- use `tree-ring dox sync` for concise `AGENTS.md` summaries +- use `tree-ring revolve sync` for promoted, rejected, deferred, or observed evaluation records +- use `tree-ring integrations scan` before configuring a new agent harness Memory does not replace source documents. If a repo has `AGENTS.md`, project docs, tests, architectural records, or host-specific instruction files, read those sources directly and treat them as authoritative. From fe9e3a6990a294bd6455cd4f08b2a1176c8097d3 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 5 Jul 2026 08:45:47 -0400 Subject: [PATCH 2/2] fix: harden export and sync review edges --- crates/tree-ring-memory-cli/src/tui/app.rs | 40 ++++++++++++++++++-- crates/tree-ring-memory-core/src/dox.rs | 41 +++++++++++++++++++-- crates/tree-ring-memory-core/src/revolve.rs | 37 +++++++++++++++---- install.sh | 4 +- 4 files changed, 107 insertions(+), 15 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index d470ddf..67feef9 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -521,14 +521,14 @@ fn wrap_index(current: usize, len: usize, delta: isize) -> usize { fn resolve_export_path(root: &Path, target: &str) -> Result { let path = PathBuf::from(target); - if path.is_absolute() { - return Ok(path); - } if path .components() .any(|component| matches!(component, Component::ParentDir)) { - return Err("relative export paths cannot contain '..'".to_string()); + return Err("export paths cannot contain '..'".to_string()); + } + if path.is_absolute() { + return Ok(path); } Ok(root.join("exports").join(path)) } @@ -670,6 +670,38 @@ mod tests { assert_eq!(app.status, "action cancelled"); } + #[test] + fn export_path_rejects_parent_dir_in_relative_and_absolute_targets() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + + let relative_error = resolve_export_path(&root, "../outside.jsonl").unwrap_err(); + assert!(relative_error.contains("..")); + + let absolute_with_parent = std::env::current_dir() + .unwrap() + .join("exports") + .join("..") + .join("outside.jsonl"); + assert!(absolute_with_parent.is_absolute()); + let absolute_error = + resolve_export_path(&root, &absolute_with_parent.to_string_lossy()).unwrap_err(); + assert!(absolute_error.contains("..")); + } + + #[test] + fn export_path_allows_absolute_target_without_parent_dir() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + let absolute = std::env::current_dir() + .unwrap() + .join("tree-ring-export.jsonl"); + + let resolved = resolve_export_path(&root, &absolute.to_string_lossy()).unwrap(); + + assert_eq!(resolved, absolute); + } + #[test] fn confirmed_export_writes_jsonl_with_default_privacy_filters() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-core/src/dox.rs b/crates/tree-ring-memory-core/src/dox.rs index 5034d3e..b7c1b7d 100644 --- a/crates/tree-ring-memory-core/src/dox.rs +++ b/crates/tree-ring-memory-core/src/dox.rs @@ -7,6 +7,7 @@ use crate::sensitivity::SensitivityGuard; const DEFAULT_MAX_FILES: usize = 128; const DEFAULT_MAX_SECTIONS_PER_FILE: usize = 8; +const MAX_SOURCE_BYTES: u64 = 256 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DoxSyncRequest { @@ -103,11 +104,13 @@ fn visit_directory(root: &Path, max_files: usize, output: &mut Vec) -> break; } let path = entry.path(); - let metadata = fs::symlink_metadata(&path).map_err(|err| sqlite_error(err.to_string()))?; - if metadata.file_type().is_symlink() { + let file_type = entry + .file_type() + .map_err(|err| sqlite_error(err.to_string()))?; + if file_type.is_symlink() { continue; } - if metadata.is_dir() { + if file_type.is_dir() { if should_skip_dir(&path) { continue; } @@ -130,6 +133,21 @@ fn events_from_agents_file( request: &DoxSyncRequest, path: &Path, ) -> Result, AdapterSkip> { + let metadata = + fs::symlink_metadata(path).map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; + if metadata.file_type().is_symlink() { + return Err(AdapterSkip::Unreadable(format!( + "Refusing symlinked AGENTS.md: {}", + path.display() + ))); + } + if metadata.len() > MAX_SOURCE_BYTES { + return Err(AdapterSkip::Unreadable(format!( + "AGENTS.md exceeds {} bytes: {}", + MAX_SOURCE_BYTES, + path.display() + ))); + } let content = fs::read_to_string(path).map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; let relative = relative_display(&request.root, path); @@ -463,4 +481,21 @@ mod tests { assert_eq!(report.source_count, 0); assert!(report.events.is_empty()); } + + #[test] + fn skips_oversized_agents_file_before_reading() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + vec![b'a'; MAX_SOURCE_BYTES as usize + 1], + ) + .unwrap(); + + let report = collect_dox_memories(&DoxSyncRequest::new(dir.path())).unwrap(); + + assert_eq!(report.source_count, 1); + assert_eq!(report.memory_count, 0); + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("exceeds")); + } } diff --git a/crates/tree-ring-memory-core/src/revolve.rs b/crates/tree-ring-memory-core/src/revolve.rs index e532142..9911c60 100644 --- a/crates/tree-ring-memory-core/src/revolve.rs +++ b/crates/tree-ring-memory-core/src/revolve.rs @@ -115,11 +115,13 @@ fn visit_directory(root: &Path, max_files: usize, output: &mut Vec) -> break; } let path = entry.path(); - let metadata = fs::symlink_metadata(&path).map_err(|err| sqlite_error(err.to_string()))?; - if metadata.file_type().is_symlink() { + let file_type = entry + .file_type() + .map_err(|err| sqlite_error(err.to_string()))?; + if file_type.is_symlink() { continue; } - if metadata.is_dir() { + if file_type.is_dir() { if should_skip_dir(&path) { continue; } @@ -139,10 +141,15 @@ fn should_skip_dir(path: &Path) -> bool { } fn is_supported_source(path: &Path) -> bool { - matches!( - path.extension().and_then(|extension| extension.to_str()), - Some("md" | "txt" | "json" | "jsonl") - ) + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "md" | "txt" | "json" | "jsonl" + ) + }) + .unwrap_or(false) } fn event_from_revolve_file( @@ -388,6 +395,22 @@ mod tests { assert_eq!(event.event_type, "evaluation_hypothesis"); } + #[test] + fn imports_supported_sources_with_uppercase_extensions() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("PROMOTION.MD"), + "# Promotion\nPromoted uppercase extension support.", + ) + .unwrap(); + + let report = collect_revolve_memories(&RevolveSyncRequest::new(dir.path())).unwrap(); + + assert_eq!(report.source_count, 1); + assert_eq!(report.memory_count, 1); + assert_eq!(report.events[0].ring, "heartwood"); + } + #[test] fn ignores_sources_without_evidence_outcomes() { let dir = tempdir().unwrap(); diff --git a/install.sh b/install.sh index 3d843de..efc4b34 100755 --- a/install.sh +++ b/install.sh @@ -218,7 +218,9 @@ verify_sha256() { else die "SHA-256 verification requested, but shasum/sha256sum was not found" fi - [ "$actual" = "$expected" ] || die "archive checksum mismatch" + actual_lower=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]') + expected_lower=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') + [ "$actual_lower" = "$expected_lower" ] || die "archive checksum mismatch" } install_prefix() {