From 7bb49b1b333934d57fdbe375597c4d203804e857 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:29:51 -0400 Subject: [PATCH 01/13] Design shared action foundation --- ...ee-ring-shared-action-foundation-design.md | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-tree-ring-shared-action-foundation-design.md diff --git a/docs/superpowers/specs/2026-07-09-tree-ring-shared-action-foundation-design.md b/docs/superpowers/specs/2026-07-09-tree-ring-shared-action-foundation-design.md new file mode 100644 index 0000000..38b77d2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-tree-ring-shared-action-foundation-design.md @@ -0,0 +1,307 @@ +# Tree Ring Shared Action Foundation Design + +## Status + +Approved brainstorming direction: implement the improvement roadmap in this +order: + +1. Shared action foundation and core simplification. +2. TUI operator cockpit and integration-link workflow. +3. Harness certification matrix. +4. Recall quality dashboard and observability. + +This spec covers the first lane only. Later lanes are named here as downstream +consumers, not implementation scope. + +## Intent + +Tree Ring Memory is now Rust-native across the CLI, SQLite store, TUI, import +and export, maintenance, consolidation, DOX/Revolve adapters, and integration +discovery. The next improvement should reduce internal coupling before adding +more operator and harness features. + +The current pressure points are large files that mix parsing, operation +semantics, storage calls, and presentation: + +- `crates/tree-ring-memory-cli/src/main.rs` +- `crates/tree-ring-memory-cli/src/tui/app.rs` +- `crates/tree-ring-memory-cli/src/tui/render.rs` +- `crates/tree-ring-memory-sqlite/src/lib.rs` + +The shared action foundation should make common durable operations reusable by +both CLI and TUI callers. It should preserve existing behavior while creating a +clean place for later `/audit`, `/maintain`, real `/sync`, and +`integrations link` workflows. + +## Goals + +- Keep user-facing behavior stable while simplifying internal boundaries. +- Move operation semantics out of CLI parsing and TUI state handling. +- Give CLI and TUI shared request/report contracts for durable operations. +- Keep `SQLiteMemoryStore` as the public storage facade while allowing focused + internal modules. +- Prepare the TUI cockpit lane to call shared actions instead of duplicating + CLI behavior. +- Keep tests and certification as the safety net for the refactor. + +## Non-Goals + +- Do not add new user-facing product behavior in this lane. +- Do not change the SQLite schema or public JSONL schema. +- Do not change recall ranking, sensitivity classification, adapter summaries, + generated guidance wording, installer behavior, or TUI layout. +- Do not add a daemon, MCP server, background recorder, or hidden durable + memory writer. +- Do not bind Tree Ring Memory to one agent harness. + +## Architecture + +The target shape is a behavior-preserving split with a shared action layer: + +```text +crates/tree-ring-memory-cli/src/ +├── main.rs # clap parsing and thin dispatch +├── commands/ # CLI command handlers and output formatting +├── actions/ # shared operation semantics +│ ├── remember.rs +│ ├── recall.rs +│ ├── export_import.rs +│ ├── audit.rs +│ ├── maintain.rs +│ ├── consolidate.rs +│ ├── adapters.rs +│ └── integrations.rs +└── tui/ # TUI input, state, rendering, confirmations + +crates/tree-ring-memory-sqlite/src/ +├── lib.rs # public SQLiteMemoryStore facade +├── schema.rs # open/init/migrations/helpers +├── write.rs # put/delete/redact/supersede helpers +├── search.rs # list/search/recall filtering helpers +├── import_export.rs # JSONL store integration helpers +└── lifecycle.rs # audit/maintain/consolidate storage glue +``` + +The action layer belongs in the CLI crate for this pass because it coordinates +CLI/TUI behavior and depends on the SQLite store. The core crate should remain +focused on portable models, validation, recall scoring, sensitivity, audit +planning, consolidation planning, maintenance planning, and adapters that do +not require terminal or command presentation concerns. + +## Shared Action Contracts + +Actions should use request/report structs instead of CLI arg structs or TUI app +state. Each action should answer: + +- What operation is being requested? +- What durable operation was attempted? +- What changed? +- What concise status should a caller display? + +Initial action families: + +- `RememberRequest` / `RememberReport` +- `RecallRequest` / `RecallReport` +- `ExportRequest` / `ExportReport` +- `ImportRequest` / `ImportReport` +- `AuditActionRequest` / `AuditActionReport` +- `MaintainActionRequest` / `MaintainActionReport` +- `ConsolidateActionRequest` / `ConsolidateActionReport` +- `AdapterSyncRequest` / `AdapterSyncReport` +- `IntegrationScanRequest` / `IntegrationScanReport` + +The exact Rust names may vary to avoid collisions with existing core types, but +the boundary should stay consistent. + +The CLI remains responsible for: + +- Clap argument parsing. +- Text and JSON presentation. +- Exit behavior. +- Help text. + +The TUI remains responsible for: + +- Mode transitions. +- Selection state. +- Confirmation panels. +- Rendering. +- Keyboard and slash-command input. + +The action layer owns: + +- Validation and sensitivity handling for a requested operation. +- Store calls. +- Operation-level warnings. +- Structured success reports. +- Structured errors where they materially improve caller behavior. + +## Data Flow + +CLI flow: + +```text +CLI args + -> commands:: + -> actions:: + -> SQLiteMemoryStore facade + -> tree-ring-memory-core logic where applicable + -> commands:: formats text or JSON +``` + +TUI flow: + +```text +TUI key/slash input + -> tui::app selection and confirmation state + -> actions:: + -> SQLiteMemoryStore facade + -> tree-ring-memory-core logic where applicable + -> TUI status/report rendering +``` + +Storage flow: + +```text +actions:: + -> SQLiteMemoryStore public method + -> focused internal SQLite helper module + -> SQLite transaction/query +``` + +`SQLiteMemoryStore` should remain the public storage API used by tests and +callers. Internal modules should reduce file size and clarify responsibilities +without forcing downstream consumers to know about the split. + +## Migration Strategy + +The refactor should move one action family at a time. + +### Phase 1: Simple Action Extraction + +Start with flows that already have clear behavior and strong tests: + +- `remember` +- `recall` +- `export` +- `audit` + +Keep CLI output and JSON stable. Update the TUI to call the shared action only +where its current behavior is equivalent, especially `/remember` and `/export`. + +### Phase 2: Lifecycle And Adapter Actions + +Move operation semantics for: + +- `import` +- `consolidate` +- `maintain` +- `dox sync` +- `revolve sync` +- `integrations scan` + +Keep dry-run behavior unchanged. Keep source refs, adapter summaries, +sensitivity behavior, and supersession behavior unchanged. + +### Phase 3: Storage Internal Split + +Split SQLite internals only around seams already touched by actions: + +- schema and open/init helpers +- write helpers +- search and recall helpers +- import/export helpers +- lifecycle helpers + +Avoid reshaping the storage API beyond private/internal module boundaries. + +## Error Handling + +Action functions should return operation-specific `Result` +values. A perfect global error hierarchy is not required for this pass, but +errors should preserve context. + +Expected behavior: + +- CLI turns action reports and errors into existing text or JSON output. +- TUI turns action reports and errors into status strings and confirmation + panels. +- Storage errors stay specific enough to diagnose the failing operation. +- Secret-like input and sensitive-memory behavior remain fail-closed where they + already fail closed. +- Existing non-mutating dry-run semantics remain non-mutating. + +## TUI Boundary + +This lane does not redesign the TUI. It prepares the TUI cockpit lane by making +the TUI call shared actions. + +Allowed TUI changes: + +- Replace inline durable operation logic with action calls. +- Keep confirmation ownership in `tui::app`. +- Keep rendering ownership in `tui::render`. +- Add small report-to-status adapters when needed. + +Deferred to the next lane: + +- `/audit` +- `/maintain` +- real `/sync` +- guided integration linking +- richer filters +- score explanation panels +- visual layout changes + +## Testing + +Verification should focus on behavior preservation: + +- Full `cargo test --locked`. +- `cargo clippy --locked --all-targets`. +- `cargo fmt --check`. +- `git diff --check`. +- `sh scripts/certify-tree-ring.sh`. +- Focused CLI JSON checks for remember, recall, export, import, audit, + consolidate, maintain, DOX sync, Revolve sync, and integrations scan. +- TUI action tests for `/remember`, `/export`, `/consolidate`, and existing + confirmation flows. + +Where practical, tests should exercise the action layer directly and also +confirm CLI/TUI callers still behave as before. + +## Acceptance Criteria + +- `main.rs`, `tui/app.rs`, and `sqlite/lib.rs` become meaningfully thinner or + their responsibilities become clearly narrower. +- Shared actions exist for the main durable operations. +- CLI and TUI both use at least the first shared actions. +- Existing user-facing CLI text and JSON behavior remain stable. +- Existing TUI behavior remains stable. +- Existing tests pass. +- Certification still passes. +- The next TUI cockpit design can add lifecycle and integration workflows by + calling action contracts instead of duplicating command logic. + +## Downstream Roadmap + +After this lane: + +1. **TUI operator cockpit** can add `/audit`, `/maintain`, real `/sync`, and + guided integration linking through shared actions. +2. **Harness certification matrix** can certify real CLI and bridge flows + against Codex, Claude Code, OpenCode, Goose, Pi, Agent Zero, DOX, and + Revolve. +3. **Recall quality dashboard** can add ranking explanations, evaluation + fixtures, stale-memory visibility, and contradiction visibility without + mixing those concerns into CLI parsing or TUI state management. + +## Spec Self-Review + +- Incomplete-marker scan: no incomplete markers remain. +- Consistency check: architecture, action contracts, data flow, and testing all + preserve existing public behavior. +- Scope check: this is a single refactor foundation lane; later TUI, harness, + and recall-quality work is deferred. +- Ambiguity check: behavior changes are explicitly out of scope; public storage + and CLI/TUI behavior should remain stable. From 48d104964e63c3f4c225d52900ccf9b1cc41c9fb Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:37:34 -0400 Subject: [PATCH 02/13] Plan shared action foundation --- ...d-action-foundation-implementation-plan.md | 1873 +++++++++++++++++ 1 file changed, 1873 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md diff --git a/docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md b/docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md new file mode 100644 index 0000000..7b1d92d --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md @@ -0,0 +1,1873 @@ +# Tree Ring Shared Action Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create shared action contracts that keep CLI and TUI durable memory operations behavior-stable while reducing coupling in the largest command, TUI, and storage files. + +**Architecture:** Add a focused `actions` layer inside `tree-ring-memory-cli` with request/report structs and operation functions consumed by both command handlers and the TUI. Keep `SQLiteMemoryStore` as the public storage facade while splitting private SQLite helpers into responsibility-focused modules only after action seams are established. + +**Tech Stack:** Rust 2021, Clap, Ratatui, serde/serde_json, rusqlite with bundled SQLite, existing `tree-ring-memory-core`, existing `tree-ring-memory-sqlite`, cargo tests, `scripts/certify-tree-ring.sh`. + +## Global Constraints + +- Keep user-facing behavior stable while simplifying internal boundaries. +- Do not add new user-facing product behavior in this lane. +- Do not change the SQLite schema or public JSONL schema. +- Do not change recall ranking, sensitivity classification, adapter summaries, generated guidance wording, installer behavior, or TUI layout. +- Do not add a daemon, MCP server, background recorder, or hidden durable memory writer. +- Keep `SQLiteMemoryStore` as the public storage facade. +- CLI remains responsible for Clap argument parsing, text and JSON presentation, exit behavior, and help text. +- TUI remains responsible for mode transitions, selection state, confirmation panels, rendering, keyboard input, and slash-command input. +- Existing non-mutating dry-run semantics remain non-mutating. +- Run `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked --all-targets`, `git diff --check`, and `sh scripts/certify-tree-ring.sh` before final PR handoff. + +--- + +## Scope Check + +This plan covers one subsystem: the shared action foundation and behavior-preserving simplification of CLI/TUI/storage boundaries. The TUI cockpit, harness certification matrix, and recall quality dashboard are not part of this plan. + +## File Structure + +- Create `crates/tree-ring-memory-cli/src/actions/mod.rs`: shared action module exports, `ActionResult`, and small string-context helper. +- Create `crates/tree-ring-memory-cli/src/actions/remember.rs`: remember request/report and store operation. +- Create `crates/tree-ring-memory-cli/src/actions/recall.rs`: recall request/report and retriever operation. +- Create `crates/tree-ring-memory-cli/src/actions/export_import.rs`: export/import request/report wrappers around store JSONL behavior and dry-run validation. +- Create `crates/tree-ring-memory-cli/src/actions/audit.rs`: audit request/report behavior, including missing-store read-only behavior. +- Create `crates/tree-ring-memory-cli/src/actions/lifecycle.rs`: consolidation and maintenance action behavior. +- Create `crates/tree-ring-memory-cli/src/actions/adapters.rs`: DOX and Revolve sync action behavior. +- Create `crates/tree-ring-memory-cli/src/actions/integrations.rs`: integration scan action behavior. +- Create `crates/tree-ring-memory-cli/src/commands/mod.rs`: command module exports. +- Create `crates/tree-ring-memory-cli/src/commands/scriptable.rs`: CLI command handlers that call shared actions and keep output behavior stable. +- Modify `crates/tree-ring-memory-cli/src/main.rs`: module declarations, thin dispatch, output formatting delegation, and reduced inline operation semantics. +- Modify `crates/tree-ring-memory-cli/src/tui/app.rs`: call shared actions for matching existing TUI operations. +- Modify `crates/tree-ring-memory-cli/src/tui/actions.rs`: keep confirmation models, but store action request values where useful. +- Modify `crates/tree-ring-memory-sqlite/src/lib.rs`: keep public facade, move private helpers out in later tasks. +- Create `crates/tree-ring-memory-sqlite/src/schema.rs`: private open/migration helpers. +- Create `crates/tree-ring-memory-sqlite/src/write.rs`: private write/delete/redact/supersede helpers. +- Create `crates/tree-ring-memory-sqlite/src/search.rs`: private list/search/recall helpers. +- Create `crates/tree-ring-memory-sqlite/src/import_export.rs`: private store JSONL import/export helpers. +- Create `crates/tree-ring-memory-sqlite/src/lifecycle.rs`: private audit/consolidate/maintenance helpers. +- Modify `docs/architecture/rust-core-status.md`: short note that shared action contracts now back CLI/TUI operations. +- Modify `README.md`: only if command examples or verification guidance need a behavior-stable note. + +--- + +### Task 1: Add Shared Action Foundation For Remember + +**Files:** +- Create: `crates/tree-ring-memory-cli/src/actions/mod.rs` +- Create: `crates/tree-ring-memory-cli/src/actions/remember.rs` +- Modify: `crates/tree-ring-memory-cli/src/main.rs` + +**Interfaces:** +- Produces: `actions::ActionResult = Result` +- Produces: `actions::remember::RememberRequest` +- Produces: `actions::remember::RememberReport` +- Produces: `actions::remember::remember(store: &mut SQLiteMemoryStore, request: RememberRequest) -> ActionResult` +- Consumes: `tree_ring_memory_core::{MemoryEvent, SensitivityGuard}` +- Consumes: `tree_ring_memory_sqlite::SQLiteMemoryStore` + +- [ ] **Step 1: Write the failing remember action tests** + +Add this complete file at `crates/tree-ring-memory-cli/src/actions/remember.rs`: + +```rust +use tree_ring_memory_core::{MemoryEvent, SensitivityGuard}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RememberRequest { + pub summary: String, + pub event_type: String, + pub ring: String, + pub scope: String, + pub project: Option, + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RememberReport { + pub memory: MemoryEvent, +} + +pub fn remember( + store: &mut SQLiteMemoryStore, + request: RememberRequest, +) -> ActionResult { + let guard = SensitivityGuard::default(); + let values = [ + request.summary.as_str(), + request.event_type.as_str(), + request.ring.as_str(), + request.scope.as_str(), + ] + .into_iter() + .chain(request.project.iter().map(String::as_str)) + .chain(request.tags.iter().map(String::as_str)); + let detected_sensitivity = guard + .detect_text_sensitivity(values) + .map_err(|err| err.to_string())?; + let mut event = + MemoryEvent::new(request.summary, request.event_type).map_err(|err| err.to_string())?; + event.ring = request.ring; + event.scope = request.scope; + event.project = request.project; + event.tags = request.tags; + if detected_sensitivity != "normal" { + event.sensitivity = detected_sensitivity; + } + event.validate().map_err(|err| err.to_string())?; + store.put(&event).map_err(|err| err.to_string())?; + Ok(RememberReport { memory: event }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn remember_action_stores_memory_with_cli_defaults() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + + let report = remember( + &mut store, + RememberRequest { + summary: "Use shared actions for durable operations.".to_string(), + event_type: "lesson".to_string(), + ring: "cambium".to_string(), + scope: "project".to_string(), + project: Some("tree-ring".to_string()), + tags: vec!["refactor".to_string()], + }, + ) + .unwrap(); + + let stored = store.get(&report.memory.id).unwrap().unwrap(); + assert_eq!(stored.summary, "Use shared actions for durable operations."); + assert_eq!(stored.ring, "cambium"); + assert_eq!(stored.scope, "project"); + assert_eq!(stored.project.as_deref(), Some("tree-ring")); + assert_eq!(stored.tags, vec!["refactor"]); + } + + #[test] + fn remember_action_classifies_sensitive_input_before_storage() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + + let report = remember( + &mut store, + RememberRequest { + summary: "Private diagnosis should be guarded.".to_string(), + event_type: "lesson".to_string(), + ring: "cambium".to_string(), + scope: "global".to_string(), + project: None, + tags: Vec::new(), + }, + ) + .unwrap(); + + let stored = store.get(&report.memory.id).unwrap().unwrap(); + assert_eq!(stored.sensitivity, "private"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify module wiring is missing** + +Run: + +```bash +cargo test -p tree-ring-memory-cli remember_action --locked +``` + +Expected: FAIL because `super::ActionResult` and the `actions` module are not declared yet. + +- [ ] **Step 3: Add the shared action module** + +Create `crates/tree-ring-memory-cli/src/actions/mod.rs`: + +```rust +pub mod remember; + +pub type ActionResult = Result; +``` + +Modify the module declarations near the top of `crates/tree-ring-memory-cli/src/main.rs`: + +```rust +mod actions; +mod agent_awareness; +mod integrations; +mod ring_mark; +mod tui; +mod welcome; +``` + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: + +```bash +cargo test -p tree-ring-memory-cli remember_action --locked +``` + +Expected: PASS with both `remember_action_*` tests passing. + +- [ ] **Step 5: Commit Task 1** + +Run: + +```bash +git add crates/tree-ring-memory-cli/src/actions/mod.rs crates/tree-ring-memory-cli/src/actions/remember.rs crates/tree-ring-memory-cli/src/main.rs +git commit -m "Add shared remember action" +``` + +--- + +### Task 2: Add Recall, Export, Import, And Audit Actions + +**Files:** +- Create: `crates/tree-ring-memory-cli/src/actions/recall.rs` +- Create: `crates/tree-ring-memory-cli/src/actions/export_import.rs` +- Create: `crates/tree-ring-memory-cli/src/actions/audit.rs` +- Modify: `crates/tree-ring-memory-cli/src/actions/mod.rs` + +**Interfaces:** +- Consumes: `actions::ActionResult` +- Produces: `actions::recall::RecallRequest` +- Produces: `actions::recall::RecallReport` +- Produces: `actions::recall::recall(store: &SQLiteMemoryStore, request: RecallRequest) -> ActionResult` +- Produces: `actions::export_import::ExportActionRequest` +- Produces: `actions::export_import::ExportActionReport` +- Produces: `actions::export_import::export_jsonl(store: &SQLiteMemoryStore, request: ExportActionRequest) -> ActionResult` +- Produces: `actions::export_import::ImportActionRequest` +- Produces: `actions::export_import::ImportActionReport` +- Produces: `actions::export_import::import_jsonl(store: &mut SQLiteMemoryStore, request: ImportActionRequest) -> ActionResult` +- Produces: `actions::audit::AuditActionRequest` +- Produces: `actions::audit::AuditActionReport` +- Produces: `actions::audit::audit_store(db_path: &Path, request: AuditActionRequest) -> ActionResult` + +- [ ] **Step 1: Add recall action tests and implementation** + +Create `crates/tree-ring-memory-cli/src/actions/recall.rs`: + +```rust +use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore}; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecallRequest { + pub query: String, + pub project: Option, + pub limit: usize, + pub include_sensitive: bool, + pub include_superseded: bool, + pub explain: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RecallReport { + pub results: Vec, +} + +pub fn recall(store: &SQLiteMemoryStore, request: RecallRequest) -> ActionResult { + let results = MemoryRetriever::new(store) + .recall( + &request.query, + request.project.as_deref(), + None, + None, + None, + None, + request.include_sensitive, + request.include_superseded, + request.limit, + request.explain, + ) + .map_err(|err| err.to_string())?; + Ok(RecallReport { results }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + use tree_ring_memory_core::MemoryEvent; + + use super::*; + + #[test] + fn recall_action_returns_ranked_memories() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let event = MemoryEvent::new("Use shared recall action.", "lesson").unwrap(); + store.put(&event).unwrap(); + + let report = recall( + &store, + RecallRequest { + query: "shared recall".to_string(), + project: None, + limit: 8, + include_sensitive: false, + include_superseded: false, + explain: true, + }, + ) + .unwrap(); + + assert_eq!(report.results.len(), 1); + assert_eq!(report.results[0].memory.id, event.id); + assert!(report.results[0].ranking.contains_key("text")); + } +} +``` + +- [ ] **Step 2: Add export/import action tests and implementation** + +Create `crates/tree-ring-memory-cli/src/actions/export_import.rs`: + +```rust +use std::fs; +use std::path::PathBuf; + +use serde_json::json; +use tree_ring_memory_core::{decode_jsonl, normalize_import_events}; +use tree_ring_memory_sqlite::{ExportReport, ImportReport, SQLiteMemoryStore}; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportActionRequest { + pub output: Option, + pub include_sensitive: bool, + pub include_superseded: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportActionReport { + pub jsonl: Option, + pub output: Option, + pub report: ExportReport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportActionRequest { + pub path: PathBuf, + pub dry_run: bool, + pub replace_existing: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportActionReport { + pub path: PathBuf, + pub report: ImportReport, +} + +pub fn export_jsonl( + store: &SQLiteMemoryStore, + request: ExportActionRequest, +) -> ActionResult { + let (jsonl, report) = store + .export_jsonl(request.include_sensitive, request.include_superseded) + .map_err(|err| err.to_string())?; + if let Some(output) = request.output { + 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())?; + Ok(ExportActionReport { + jsonl: None, + output: Some(output), + report, + }) + } else { + Ok(ExportActionReport { + jsonl: Some(jsonl), + output: None, + report, + }) + } +} + +pub fn import_jsonl( + store: &mut SQLiteMemoryStore, + request: ImportActionRequest, +) -> ActionResult { + let input = fs::read_to_string(&request.path).map_err(|err| err.to_string())?; + let report = if request.dry_run { + let decoded = decode_jsonl(&input).map_err(|err| err.to_string())?; + let events = normalize_import_events(decoded.events).map_err(|err| err.to_string())?; + ImportReport { + valid_count: events.len(), + inserted_count: 0, + replaced_count: 0, + skipped_duplicate_count: 0, + dry_run: true, + } + } else { + store + .import_jsonl(&input, false, request.replace_existing) + .map_err(|err| err.to_string())? + }; + Ok(ImportActionReport { + path: request.path, + report, + }) +} + +pub fn import_json_payload(report: &ImportActionReport) -> serde_json::Value { + json!({ + "ok": true, + "path": report.path, + "valid_count": report.report.valid_count, + "inserted_count": report.report.inserted_count, + "replaced_count": report.report.replaced_count, + "skipped_duplicate_count": report.report.skipped_duplicate_count, + "dry_run": report.report.dry_run, + }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + use tree_ring_memory_core::MemoryEvent; + + use super::*; + + #[test] + fn export_action_can_return_stdout_jsonl() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + store + .put(&MemoryEvent::new("Export through shared action.", "lesson").unwrap()) + .unwrap(); + + let report = export_jsonl( + &store, + ExportActionRequest { + output: None, + include_sensitive: false, + include_superseded: false, + }, + ) + .unwrap(); + + assert_eq!(report.report.memory_count, 1); + assert!(report.jsonl.unwrap().contains("memory_event")); + } + + #[test] + fn import_action_dry_run_validates_without_writing() { + let dir = tempdir().unwrap(); + let mut source = SQLiteMemoryStore::open(dir.path().join("source.sqlite")).unwrap(); + let mut target = SQLiteMemoryStore::open(dir.path().join("target.sqlite")).unwrap(); + source + .put(&MemoryEvent::new("Import through shared action.", "lesson").unwrap()) + .unwrap(); + let (jsonl, _) = source.export_jsonl(false, false).unwrap(); + let input = dir.path().join("input.jsonl"); + fs::write(&input, jsonl).unwrap(); + + let report = import_jsonl( + &mut target, + ImportActionRequest { + path: input, + dry_run: true, + replace_existing: false, + }, + ) + .unwrap(); + + assert_eq!(report.report.valid_count, 1); + assert_eq!(target.list_all(true).unwrap().len(), 0); + } +} +``` + +- [ ] **Step 3: Add audit action tests and implementation** + +Create `crates/tree-ring-memory-cli/src/actions/audit.rs`: + +```rust +use std::path::Path; + +use tree_ring_memory_core::{audit_memories, AuditReport}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditActionRequest { + pub audit_type: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AuditActionReport { + pub report: AuditReport, +} + +pub fn audit_store(db_path: &Path, request: AuditActionRequest) -> ActionResult { + let report = if db_path.exists() { + SQLiteMemoryStore::open_read_only(db_path) + .and_then(|store| store.audit(&request.audit_type)) + .map_err(|err| err.to_string())? + } else { + audit_memories(&[], &request.audit_type).map_err(|err| err.to_string())? + }; + Ok(AuditActionReport { report }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn audit_action_missing_store_reports_empty_audit_without_creating_store() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join(".tree-ring/memory.sqlite"); + + let report = audit_store( + &db_path, + AuditActionRequest { + audit_type: "all".to_string(), + }, + ) + .unwrap(); + + assert_eq!(report.report.memory_count, 0); + assert!(!db_path.exists()); + } +} +``` + +- [ ] **Step 4: Export the new action modules** + +Replace `crates/tree-ring-memory-cli/src/actions/mod.rs` with: + +```rust +pub mod audit; +pub mod export_import; +pub mod recall; +pub mod remember; + +pub type ActionResult = Result; +``` + +- [ ] **Step 5: Run focused action tests** + +Run: + +```bash +cargo test -p tree-ring-memory-cli actions --locked +``` + +Expected: PASS with remember, recall, export/import, and audit action tests passing. + +- [ ] **Step 6: Commit Task 2** + +Run: + +```bash +git add crates/tree-ring-memory-cli/src/actions +git commit -m "Add shared scriptable actions" +``` + +--- + +### Task 3: Wire CLI Remember, Recall, Export, Import, And Audit Through Actions + +**Files:** +- Create: `crates/tree-ring-memory-cli/src/commands/mod.rs` +- Create: `crates/tree-ring-memory-cli/src/commands/scriptable.rs` +- Modify: `crates/tree-ring-memory-cli/src/main.rs` + +**Interfaces:** +- Consumes: `actions::remember::remember` +- Consumes: `actions::recall::recall` +- Consumes: `actions::export_import::{export_jsonl, import_jsonl, import_json_payload}` +- Consumes: `actions::audit::audit_store` +- Produces: `commands::scriptable::print_recall_report(report: actions::recall::RecallReport, json_output: bool) -> Result<(), String>` +- Produces: `commands::scriptable::print_export_report(report: actions::export_import::ExportActionReport, json_output: bool) -> Result<(), String>` +- Produces: `commands::scriptable::print_import_report(report: actions::export_import::ImportActionReport, json_output: bool) -> Result<(), String>` + +- [ ] **Step 1: Add CLI parity tests for action-backed commands** + +Append these tests to the existing `#[cfg(test)] mod tests` in `crates/tree-ring-memory-cli/src/main.rs`: + +```rust +#[test] +fn remember_and_recall_output_stays_stable_after_action_extraction() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli::parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "remember", + "Use action-backed CLI behavior.", + "--event-type", + "lesson", + "--scope", + "project", + "--project", + "tree-ring", + ])) + .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].summary, "Use action-backed CLI behavior."); +} + +#[test] +fn import_dry_run_still_does_not_create_store_rows_after_action_extraction() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + let source_path = dir.path().join("source.sqlite"); + let mut source = SQLiteMemoryStore::open(&source_path).unwrap(); + source + .put(&MemoryEvent::new("Dry-run import action parity.", "lesson").unwrap()) + .unwrap(); + let (jsonl, _) = source.export_jsonl(false, false).unwrap(); + let jsonl_path = dir.path().join("memories.jsonl"); + fs::write(&jsonl_path, jsonl).unwrap(); + + run(Cli::parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "import", + jsonl_path.to_str().unwrap(), + "--dry-run", + ])) + .unwrap(); + + assert!(!root.join("memory.sqlite").exists()); +} +``` + +- [ ] **Step 2: Run the parity tests before rewiring** + +Run: + +```bash +cargo test -p tree-ring-memory-cli action_extraction --locked +``` + +Expected: PASS before rewiring, proving the tests capture current behavior. + +- [ ] **Step 3: Add command output helpers** + +Create `crates/tree-ring-memory-cli/src/commands/mod.rs`: + +```rust +pub mod scriptable; +``` + +Create `crates/tree-ring-memory-cli/src/commands/scriptable.rs`: + +```rust +use serde_json::json; + +use crate::actions::export_import::{ + import_json_payload, ExportActionReport, ImportActionReport, +}; +use crate::actions::recall::RecallReport; + +pub fn print_recall_report(report: RecallReport, json_output: bool) -> Result<(), String> { + if json_output { + let payload: Vec<_> = report + .results + .into_iter() + .map(|result| { + json!({ + "memory": result.memory, + "score": result.score, + "ranking": result.ranking, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string(&payload).map_err(|err| err.to_string())? + ); + } else { + for result in report.results { + println!( + "{} [{}] {} score={:.3}", + result.memory.id, result.memory.ring, result.memory.summary, result.score + ); + } + } + Ok(()) +} + +pub fn print_export_report( + report: ExportActionReport, + json_output: bool, +) -> Result<(), String> { + if let Some(jsonl) = report.jsonl { + print!("{jsonl}"); + return Ok(()); + } + let Some(output) = report.output else { + return Err("export action did not return output path or JSONL".to_string()); + }; + if json_output { + println!( + "{}", + json!({ + "ok": true, + "path": output, + "memory_count": report.report.memory_count, + "sensitive_included": report.report.sensitive_included, + "superseded_included": report.report.superseded_included, + }) + ); + } else { + println!( + "Tree Ring Memory export complete: {} memories -> {}", + report.report.memory_count, + output.display() + ); + } + Ok(()) +} + +pub fn print_import_report( + report: ImportActionReport, + json_output: bool, +) -> Result<(), String> { + if json_output { + println!("{}", import_json_payload(&report)); + } else { + println!( + "Tree Ring Memory import complete: valid={} inserted={} replaced={} skipped_duplicates={} dry_run={}", + report.report.valid_count, + report.report.inserted_count, + report.report.replaced_count, + report.report.skipped_duplicate_count, + report.report.dry_run + ); + } + Ok(()) +} +``` + +- [ ] **Step 4: Declare the command module** + +Modify the module declarations in `crates/tree-ring-memory-cli/src/main.rs`: + +```rust +mod actions; +mod agent_awareness; +mod commands; +mod integrations; +mod ring_mark; +mod tui; +mod welcome; +``` + +- [ ] **Step 5: Replace CLI remember, recall, export, import dry-run, import write, and audit semantics with actions** + +Use these imports near the top of `crates/tree-ring-memory-cli/src/main.rs`: + +```rust +use actions::audit::{audit_store, AuditActionRequest}; +use actions::export_import::{ + export_jsonl as export_action, import_jsonl as import_action, ImportActionRequest, + ExportActionRequest, +}; +use actions::recall::{recall as recall_action, RecallRequest}; +use actions::remember::{remember as remember_action, RememberRequest}; +``` + +Replace the import dry-run pre-store branch body with: + +```rust +if let Command::Import { + path, + dry_run: true, + replace_existing, +} = cli.command +{ + let mut store = SQLiteMemoryStore::open(&db_path).map_err(|err| err.to_string())?; + let report = import_action( + &mut store, + ImportActionRequest { + path, + dry_run: true, + replace_existing, + }, + )?; + commands::scriptable::print_import_report(report, cli.json)?; + return Ok(()); +} +``` + +Replace the audit pre-store branch body with: + +```rust +if let Command::Audit { audit_type } = &cli.command { + let report = audit_store( + &db_path, + AuditActionRequest { + audit_type: audit_type.clone(), + }, + )?; + print_audit_report(&report.report, cli.json)?; + return Ok(()); +} +``` + +Replace the `Command::Remember` match arm body with: + +```rust +let report = remember_action( + &mut store, + RememberRequest { + summary, + event_type, + ring, + scope, + project, + tags, + }, +)?; +if cli.json { + println!( + "{}", + serde_json::to_string(&report.memory).map_err(|err| err.to_string())? + ); +} else { + println!("{}", report.memory.id); +} +``` + +Replace the `Command::Recall` match arm body with: + +```rust +let report = recall_action( + &store, + RecallRequest { + query, + project, + limit, + include_sensitive, + include_superseded: false, + explain: false, + }, +)?; +commands::scriptable::print_recall_report(report, cli.json)?; +``` + +Replace the `Command::Export` match arm body with: + +```rust +let report = export_action( + &store, + ExportActionRequest { + output, + include_sensitive, + include_superseded, + }, +)?; +commands::scriptable::print_export_report(report, cli.json)?; +``` + +Replace the `Command::Import` writable match arm body with: + +```rust +let report = import_action( + &mut store, + ImportActionRequest { + path, + dry_run, + replace_existing, + }, +)?; +commands::scriptable::print_import_report(report, cli.json)?; +``` + +- [ ] **Step 6: Remove now-unused imports** + +Remove these imports from `crates/tree-ring-memory-cli/src/main.rs` when the compiler reports they are unused: + +```rust +use std::fs; +use tree_ring_memory_core::{decode_jsonl, normalize_import_events, audit_memories}; +use tree_ring_memory_sqlite::MemoryRetriever; +``` + +Keep `std::fs` if later code in tests or command paths still uses it. + +- [ ] **Step 7: Run focused CLI tests** + +Run: + +```bash +cargo test -p tree-ring-memory-cli remember_json_emits_memory_payload recall_filters action_extraction import_dry_run audit_missing_root --locked +``` + +Expected: PASS. If the filter selects zero tests for `action_extraction`, run: + +```bash +cargo test -p tree-ring-memory-cli remember_and_recall_output_stays_stable_after_action_extraction import_dry_run_still_does_not_create_store_rows_after_action_extraction --locked +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 3** + +Run: + +```bash +git add crates/tree-ring-memory-cli/src/actions crates/tree-ring-memory-cli/src/commands crates/tree-ring-memory-cli/src/main.rs +git commit -m "Wire scriptable CLI through shared actions" +``` + +--- + +### Task 4: Update Existing TUI Remember And Export To Use Shared Actions + +**Files:** +- Modify: `crates/tree-ring-memory-cli/src/tui/app.rs` +- Modify: `crates/tree-ring-memory-cli/src/tui/actions.rs` + +**Interfaces:** +- Consumes: `actions::remember::{remember, RememberRequest}` +- Consumes: `actions::export_import::{export_jsonl, ExportActionRequest}` +- Keeps: `tui::actions::PendingAction` +- Keeps: `tui::app::App::execute_slash_command(&mut self, input: &str) -> Result<(), String>` + +- [ ] **Step 1: Add focused TUI parity tests** + +Append these tests to `crates/tree-ring-memory-cli/src/tui/app.rs` inside the existing test module: + +```rust +#[test] +fn slash_remember_uses_shared_action_and_keeps_status_shape() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + + app.execute_slash_command("/remember Use shared TUI remember action") + .unwrap(); + + assert!(app.status.starts_with("remembered mem_")); + let memories = app.store.list_all(false).unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0].summary, "Use shared TUI remember action"); + assert_eq!(memories[0].ring, "cambium"); +} + +#[test] +fn confirmed_export_uses_shared_action_and_keeps_default_filters() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + app.execute_slash_command("/remember Export through shared TUI action") + .unwrap(); + app.execute_slash_command("/export shared.jsonl").unwrap(); + confirm(&mut app); + + let output = dir.path().join(".tree-ring/exports/shared.jsonl"); + let jsonl = fs::read_to_string(output).unwrap(); + assert!(jsonl.contains("tree_ring_memory_export")); + assert!(jsonl.contains("Export through shared TUI action")); +} +``` + +- [ ] **Step 2: Run focused TUI tests before changing code** + +Run: + +```bash +cargo test -p tree-ring-memory-cli slash_remember_uses_shared_action confirmed_export_uses_shared_action --locked +``` + +Expected: PASS before rewiring, proving current behavior. + +- [ ] **Step 3: Import shared actions in TUI app** + +Add these imports to `crates/tree-ring-memory-cli/src/tui/app.rs`: + +```rust +use crate::actions::export_import::{export_jsonl, ExportActionRequest}; +use crate::actions::remember::{remember, RememberRequest}; +``` + +- [ ] **Step 4: Replace `remember_summary` internals** + +Replace the body of `fn remember_summary(&mut self, summary: String) -> Result<(), String>` with: + +```rust +if summary.trim().is_empty() { + self.status = "remember requires a summary".to_string(); + return Ok(()); +} +let report = remember( + &mut self.store, + RememberRequest { + summary: summary.trim().to_string(), + event_type: "lesson".to_string(), + ring: "cambium".to_string(), + scope: "project".to_string(), + project: None, + tags: Vec::new(), + }, +)?; +self.status = format!("remembered {}", report.memory.id); +self.refresh_store() +``` + +- [ ] **Step 5: Replace confirmed export internals** + +Inside `confirm_pending_action`, replace the `ActionKind::Export` branch with: + +```rust +ActionKind::Export { + output, + include_sensitive, + include_superseded, +} => { + if output.exists() { + self.status = format!("export refused existing file {}", output.display()); + } else { + let report = export_jsonl( + &self.store, + ExportActionRequest { + output: Some(output.clone()), + include_sensitive, + include_superseded, + }, + )?; + self.status = format!( + "exported {} memories to {}", + report.report.memory_count, + output.display() + ); + } +} +``` + +- [ ] **Step 6: Remove unused imports from TUI app** + +Remove these imports from `crates/tree-ring-memory-cli/src/tui/app.rs` if the compiler reports they are unused: + +```rust +use std::fs; +use tree_ring_memory_core::SensitivityGuard; +``` + +Keep `std::fs` inside the test module because export tests read generated JSONL. + +- [ ] **Step 7: Run focused and full TUI tests** + +Run: + +```bash +cargo test -p tree-ring-memory-cli slash_remember_uses_shared_action confirmed_export_uses_shared_action tui::app --locked +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 4** + +Run: + +```bash +git add crates/tree-ring-memory-cli/src/tui/app.rs crates/tree-ring-memory-cli/src/tui/actions.rs +git commit -m "Use shared actions in TUI write flows" +``` + +--- + +### Task 5: Add Lifecycle, Adapter, And Integration Actions + +**Files:** +- Create: `crates/tree-ring-memory-cli/src/actions/lifecycle.rs` +- Create: `crates/tree-ring-memory-cli/src/actions/adapters.rs` +- Create: `crates/tree-ring-memory-cli/src/actions/integrations.rs` +- Modify: `crates/tree-ring-memory-cli/src/actions/mod.rs` +- Modify: `crates/tree-ring-memory-cli/src/main.rs` +- Modify: `crates/tree-ring-memory-cli/src/tui/app.rs` + +**Interfaces:** +- Produces: `actions::lifecycle::ConsolidateActionRequest` +- Produces: `actions::lifecycle::consolidate(store: &mut SQLiteMemoryStore, request: ConsolidateActionRequest) -> ActionResult` +- Produces: `actions::lifecycle::MaintainActionRequest` +- Produces: `actions::lifecycle::maintain(db_path: &Path, store: Option<&mut SQLiteMemoryStore>, request: MaintainActionRequest) -> ActionResult` +- Produces: `actions::adapters::DoxSyncActionRequest` +- Produces: `actions::adapters::sync_dox(store: &mut SQLiteMemoryStore, request: DoxSyncActionRequest) -> ActionResult` +- Produces: `actions::adapters::RevolveSyncActionRequest` +- Produces: `actions::adapters::sync_revolve(store: &mut SQLiteMemoryStore, request: RevolveSyncActionRequest) -> ActionResult` +- Produces: `actions::integrations::scan(request: IntegrationScanRequest) -> IntegrationScanActionReport` + +- [ ] **Step 1: Add lifecycle action implementation and tests** + +Create `crates/tree-ring-memory-cli/src/actions/lifecycle.rs`: + +```rust +use std::path::Path; + +use tree_ring_memory_core::{ + consolidate_memories, plan_maintenance, ConsolidationPeriod, ConsolidationReport, + ConsolidationRequest, MaintenanceReport, MaintenanceRequest, +}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsolidateActionRequest { + pub period_type: String, + pub period_key: Option, + pub project: Option, + pub dry_run: bool, + pub force: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaintainActionRequest { + pub project: Option, + pub include_superseded: bool, + pub apply_expired: bool, + pub apply_secret_redactions: bool, + pub repair_fts: bool, +} + +pub fn consolidation_request( + request: ConsolidateActionRequest, +) -> ActionResult { + Ok(ConsolidationRequest { + period_type: ConsolidationPeriod::parse(&request.period_type) + .map_err(|err| err.to_string())?, + period_key: request.period_key, + project: request.project, + dry_run: request.dry_run, + force: request.force, + }) +} + +pub fn consolidate( + store: &mut SQLiteMemoryStore, + request: ConsolidateActionRequest, +) -> ActionResult { + let request = consolidation_request(request)?; + store.consolidate(&request).map_err(|err| err.to_string()) +} + +pub fn consolidate_dry_run_from_path( + db_path: &Path, + request: ConsolidateActionRequest, +) -> ActionResult { + let request = consolidation_request(request)?; + if db_path.exists() { + let store = SQLiteMemoryStore::open_read_only(db_path).map_err(|err| err.to_string())?; + let events = store.list_all(false).map_err(|err| err.to_string())?; + consolidate_memories(&events, &request).map_err(|err| err.to_string()) + } else { + consolidate_memories(&[], &request).map_err(|err| err.to_string()) + } +} + +pub fn maintenance_request(request: MaintainActionRequest) -> MaintenanceRequest { + MaintenanceRequest { + dry_run: !(request.apply_expired || request.apply_secret_redactions || request.repair_fts), + apply_expired: request.apply_expired, + apply_secret_redactions: request.apply_secret_redactions, + repair_fts: request.repair_fts, + include_superseded: request.include_superseded, + project: request.project, + } +} + +pub fn maintain( + db_path: &Path, + store: Option<&mut SQLiteMemoryStore>, + request: MaintainActionRequest, +) -> ActionResult { + let request = maintenance_request(request); + if request.dry_run && !db_path.exists() { + return Ok(plan_maintenance(&[], &request)); + } + if let Some(store) = store { + return store.maintain(&request).map_err(|err| err.to_string()); + } + let mut store = SQLiteMemoryStore::open_read_only(db_path).map_err(|err| err.to_string())?; + store.maintain(&request).map_err(|err| err.to_string()) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn maintenance_action_missing_store_is_non_mutating_dry_run() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join(".tree-ring/memory.sqlite"); + + let report = maintain( + &db_path, + None, + MaintainActionRequest { + project: None, + include_superseded: false, + apply_expired: false, + apply_secret_redactions: false, + repair_fts: false, + }, + ) + .unwrap(); + + assert_eq!(report.memory_count, 0); + assert!(!db_path.exists()); + } +} +``` + +- [ ] **Step 2: Add adapter action implementation and tests** + +Create `crates/tree-ring-memory-cli/src/actions/adapters.rs`: + +```rust +use std::path::PathBuf; + +use tree_ring_memory_core::{ + collect_dox_memories, collect_revolve_memories, DoxSyncReport, DoxSyncRequest, + RevolveSyncReport, RevolveSyncRequest, +}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoxSyncActionRequest { + pub source_root: PathBuf, + pub project: Option, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DoxSyncActionReport { + pub report: DoxSyncReport, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RevolveSyncActionRequest { + pub source_root: PathBuf, + pub project: Option, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RevolveSyncActionReport { + pub report: RevolveSyncReport, + pub dry_run: bool, +} + +pub fn sync_dox( + store: &mut SQLiteMemoryStore, + request: DoxSyncActionRequest, +) -> ActionResult { + let mut dox_request = DoxSyncRequest::new(request.source_root); + dox_request.project = request.project; + let report = collect_dox_memories(&dox_request).map_err(|err| err.to_string())?; + if !request.dry_run { + store.put_many(&report.events).map_err(|err| err.to_string())?; + } + Ok(DoxSyncActionReport { + report, + dry_run: request.dry_run, + }) +} + +pub fn sync_revolve( + store: &mut SQLiteMemoryStore, + request: RevolveSyncActionRequest, +) -> ActionResult { + let mut revolve_request = RevolveSyncRequest::new(request.source_root); + revolve_request.project = request.project; + let report = collect_revolve_memories(&revolve_request).map_err(|err| err.to_string())?; + if !request.dry_run { + store.put_many(&report.events).map_err(|err| err.to_string())?; + } + Ok(RevolveSyncActionReport { + report, + dry_run: request.dry_run, + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::tempdir; + + use super::*; + + #[test] + fn dox_action_dry_run_does_not_write_events() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Rules\n\nAlways run tests.").unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + + let report = sync_dox( + &mut store, + DoxSyncActionRequest { + source_root: dir.path().to_path_buf(), + project: Some("tree-ring".to_string()), + dry_run: true, + }, + ) + .unwrap(); + + assert_eq!(report.report.memory_count, 1); + assert_eq!(store.list_all(true).unwrap().len(), 0); + } +} +``` + +- [ ] **Step 3: Add integration scan action implementation and tests** + +Create `crates/tree-ring-memory-cli/src/actions/integrations.rs`: + +```rust +use std::path::PathBuf; + +use crate::integrations::{scan_integrations, IntegrationScanReport}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrationScanRequest { + pub source_root: PathBuf, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IntegrationScanActionReport { + pub report: IntegrationScanReport, +} + +pub fn scan(request: IntegrationScanRequest) -> IntegrationScanActionReport { + IntegrationScanActionReport { + report: scan_integrations(&request.source_root), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::tempdir; + + use super::*; + + #[test] + fn integration_action_scans_project_markers() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Rules").unwrap(); + + let report = scan(IntegrationScanRequest { + source_root: dir.path().to_path_buf(), + }); + + assert!(report.report.detected_count > 0); + } +} +``` + +- [ ] **Step 4: Export the lifecycle, adapter, and integration modules** + +Replace `crates/tree-ring-memory-cli/src/actions/mod.rs` with: + +```rust +pub mod adapters; +pub mod audit; +pub mod export_import; +pub mod integrations; +pub mod lifecycle; +pub mod recall; +pub mod remember; + +pub type ActionResult = Result; +``` + +- [ ] **Step 5: Wire CLI branches through lifecycle and adapter actions** + +In `crates/tree-ring-memory-cli/src/main.rs`, replace calls to local helper functions for consolidation, maintenance, DOX, Revolve, and integrations scan with action calls from: + +```rust +use actions::adapters::{ + sync_dox, sync_revolve, DoxSyncActionRequest, RevolveSyncActionRequest, +}; +use actions::integrations::{scan as integration_scan_action, IntegrationScanRequest}; +use actions::lifecycle::{ + consolidate, consolidate_dry_run_from_path, maintain, ConsolidateActionRequest, + MaintainActionRequest, +}; +``` + +Replace the integrations pre-store branch body with: + +```rust +let report = integration_scan_action(IntegrationScanRequest { + source_root: source_root.clone(), +}); +print_integration_report(&report.report, cli.json)?; +return Ok(()); +``` + +Replace consolidation dry-run pre-store behavior with: + +```rust +let report = consolidate_dry_run_from_path( + &db_path, + ConsolidateActionRequest { + period_type: period_type.clone(), + period_key: period_key.clone(), + project: project.clone(), + dry_run: true, + force: *force, + }, +)?; +print_consolidation_report(&report, cli.json)?; +return Ok(()); +``` + +Replace maintenance dry-run pre-store behavior with: + +```rust +let report = maintain( + &db_path, + None, + MaintainActionRequest { + project: project.clone(), + include_superseded: *include_superseded, + apply_expired: *apply_expired, + apply_secret_redactions: *apply_secret_redactions, + repair_fts: *repair_fts, + }, +)?; +print_maintenance_report(&report, cli.json)?; +return Ok(()); +``` + +Replace writable `Command::Consolidate`, `Command::Maintain`, `Command::Dox`, and `Command::Revolve` match arm bodies with calls to `consolidate`, `maintain`, `sync_dox`, and `sync_revolve`, then call existing print functions with the returned reports. + +- [ ] **Step 6: Update TUI `/integrations` to call the integration action** + +In `crates/tree-ring-memory-cli/src/tui/app.rs`, import: + +```rust +use crate::actions::integrations::{scan as scan_integrations_action, IntegrationScanRequest}; +``` + +Replace `show_integrations` scan line with: + +```rust +let report = scan_integrations_action(IntegrationScanRequest { source_root: root }); +self.status = format!( + "integration scan: {} detected under {}", + report.report.detected_count, + report.report.root.display() +); +self.integration_report = Some(report.report); +self.mode = AppMode::Integrations; +``` + +- [ ] **Step 7: Run focused action, CLI, and TUI tests** + +Run: + +```bash +cargo test -p tree-ring-memory-cli actions::lifecycle actions::adapters actions::integrations integrations_scan_is_read_only slash_integrations --locked +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 5** + +Run: + +```bash +git add crates/tree-ring-memory-cli/src/actions crates/tree-ring-memory-cli/src/main.rs crates/tree-ring-memory-cli/src/tui/app.rs +git commit -m "Add lifecycle and integration actions" +``` + +--- + +### Task 6: Split Private SQLite Internals Behind The Existing Store Facade + +**Files:** +- Modify: `crates/tree-ring-memory-sqlite/src/lib.rs` +- Create: `crates/tree-ring-memory-sqlite/src/schema.rs` +- Create: `crates/tree-ring-memory-sqlite/src/write.rs` +- Create: `crates/tree-ring-memory-sqlite/src/search.rs` +- Create: `crates/tree-ring-memory-sqlite/src/import_export.rs` +- Create: `crates/tree-ring-memory-sqlite/src/lifecycle.rs` + +**Interfaces:** +- Keeps: `SQLiteMemoryStore::open(path) -> TreeRingResult` +- Keeps: `SQLiteMemoryStore::open_read_only(path) -> TreeRingResult` +- Keeps: `SQLiteMemoryStore::put`, `put_many`, `get`, `list_all`, `search_text`, `search_text_filtered_limited`, `supersede`, `delete`, `redact`, `export_jsonl`, `import_jsonl`, `audit`, `consolidate`, `maintain` +- Produces only crate-private helpers in new files. + +- [ ] **Step 1: Add facade preservation tests** + +Add this test to `crates/tree-ring-memory-sqlite/src/lib.rs` inside the existing test module: + +```rust +#[test] +fn public_store_facade_still_covers_write_search_export_import_and_maintenance() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("memory.sqlite"); + let mut store = SQLiteMemoryStore::open(&db_path).unwrap(); + let event = MemoryEvent::new("Facade preservation memory.", "lesson").unwrap(); + store.put(&event).unwrap(); + + assert!(store.get(&event.id).unwrap().is_some()); + assert_eq!(store.search_text("facade preservation", false).unwrap().len(), 1); + + let (jsonl, export_report) = store.export_jsonl(false, false).unwrap(); + assert_eq!(export_report.memory_count, 1); + + let target_dir = tempdir().unwrap(); + let mut target = SQLiteMemoryStore::open(target_dir.path().join("memory.sqlite")).unwrap(); + let import_report = target.import_jsonl(&jsonl, false, false).unwrap(); + assert_eq!(import_report.inserted_count, 1); + + let audit = store.audit("all").unwrap(); + assert_eq!(audit.memory_count, 1); + + let maintenance = store.maintain(&MaintenanceRequest::default()).unwrap(); + assert_eq!(maintenance.memory_count, 1); +} +``` + +- [ ] **Step 2: Run the facade preservation test before moving helpers** + +Run: + +```bash +cargo test -p tree-ring-memory-sqlite public_store_facade_still_covers --locked +``` + +Expected: PASS before moving helpers. + +- [ ] **Step 3: Move schema/open helpers into `schema.rs`** + +Create `crates/tree-ring-memory-sqlite/src/schema.rs` with crate-private functions moved from `lib.rs`: + +```rust +use rusqlite::{Connection, OpenFlags}; +use std::path::{Component, Path}; + +use tree_ring_memory_core::models::{sqlite_error, TreeRingResult}; + +use crate::sqlite_error_from_rusqlite; + +pub(crate) fn open_connection(path: &Path) -> TreeRingResult { + if let Some(parent) = parent_dir_to_create(path) { + std::fs::create_dir_all(parent).map_err(|err| sqlite_error(err.to_string()))?; + } + let connection = Connection::open(path).map_err(sqlite_error_from_rusqlite)?; + configure_connection(&connection)?; + Ok(connection) +} + +pub(crate) fn open_read_only_connection(path: &Path) -> TreeRingResult { + let path = path + .canonicalize() + .map_err(|err| sqlite_error(err.to_string()))?; + let uri = sqlite_uri_for_path(&path); + let connection = Connection::open_with_flags( + uri, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(sqlite_error_from_rusqlite)?; + configure_connection(&connection)?; + Ok(connection) +} + +fn configure_connection(connection: &Connection) -> TreeRingResult<()> { + connection + .busy_timeout(std::time::Duration::from_millis(30_000)) + .map_err(sqlite_error_from_rusqlite)?; + connection + .execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=30000;", + ) + .map_err(sqlite_error_from_rusqlite)?; + Ok(()) +} + +fn parent_dir_to_create(path: &Path) -> Option<&Path> { + if path.components().count() <= 1 { + return None; + } + path.parent().filter(|parent| !parent.as_os_str().is_empty()) +} + +fn sqlite_uri_for_path(path: &Path) -> String { + let mut encoded = String::from("file:"); + for component in path.components() { + match component { + Component::RootDir => encoded.push('/'), + Component::Normal(value) => { + if !encoded.ends_with('/') { + encoded.push('/'); + } + encoded.push_str(&percent_encode_path_segment(&value.to_string_lossy())); + } + _ => {} + } + } + encoded.push_str("?mode=ro"); + encoded +} + +fn percent_encode_path_segment(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} +``` + +In `lib.rs`, add: + +```rust +mod schema; +``` + +Then replace open/open_read_only connection setup with `schema::open_connection(path)?` and `schema::open_read_only_connection(path)?`. + +- [ ] **Step 4: Move write helpers into `write.rs`** + +Create `crates/tree-ring-memory-sqlite/src/write.rs` by moving these private helpers from `lib.rs` without changing function bodies: + +```rust +pub(crate) fn delete_in_transaction(transaction: &Transaction<'_>, memory_id: &str) -> TreeRingResult +pub(crate) fn redact_in_transaction(transaction: &Transaction<'_>, memory_id: &str) -> TreeRingResult +pub(crate) fn put_in_transaction(transaction: &Transaction<'_>, event: &MemoryEvent) -> TreeRingResult<()> +pub(crate) fn put_with_statements( + event: &MemoryEvent, + insert_memory: &mut rusqlite::Statement<'_>, + delete_fts: &mut rusqlite::Statement<'_>, + insert_fts: &mut rusqlite::Statement<'_>, +) -> TreeRingResult<()> +pub(crate) fn retry_locked(operation: impl FnMut() -> TreeRingResult) -> TreeRingResult +``` + +Use the existing signatures from `lib.rs`, make them `pub(crate)`, and update `lib.rs` call sites to `write::delete_in_transaction`, `write::redact_in_transaction`, `write::put_in_transaction`, `write::put_with_statements`, and `write::retry_locked`. + +- [ ] **Step 5: Move search helpers into `search.rs`** + +Create `crates/tree-ring-memory-sqlite/src/search.rs` by moving these private helpers from `lib.rs` without changing function bodies: + +```rust +pub(crate) fn event_from_row(row: &Row<'_>) -> rusqlite::Result> +pub(crate) fn collect_rows(rows: I) -> TreeRingResult> +where + I: IntoIterator>> +pub(crate) fn push_in_filter( + sql: &mut String, + parameters: &mut Vec, + column_name: &str, + values: &[String], +) +``` + +Use the existing signatures from `lib.rs`, make them `pub(crate)`, and update `lib.rs` call sites through `search::`. + +- [ ] **Step 6: Move JSONL import/export helpers into `import_export.rs`** + +Create `crates/tree-ring-memory-sqlite/src/import_export.rs` only if the helper boundary stays cleaner than keeping methods on `SQLiteMemoryStore`. The two candidate methods are: + +```rust +fn apply_supersedes(&mut self, event: &MemoryEvent) -> TreeRingResult<()> +fn existing_memory_ids(&self, ids: &[String]) -> TreeRingResult> +``` + +If moving those methods requires exposing store internals, keep them in `lib.rs` and record that decision in the task commit message. The acceptance criterion is a narrower `lib.rs`, not a forced split that damages readability. + +- [ ] **Step 7: Move lifecycle helpers into `lifecycle.rs`** + +Create `crates/tree-ring-memory-sqlite/src/lifecycle.rs` by moving these private helpers from `lib.rs` without changing function bodies: + +```rust +pub(crate) fn count_query(connection: &Connection, sql: &str) -> TreeRingResult +pub(crate) fn rebuild_fts_in_transaction(transaction: &Transaction<'_>) -> TreeRingResult<()> +pub(crate) fn consolidation_from_row(row: &Row<'_>) -> rusqlite::Result> +``` + +Use the existing signatures from `lib.rs`, make them `pub(crate)`, and update `lib.rs` call sites through `lifecycle::`. + +- [ ] **Step 8: Run sqlite tests after each helper group** + +After each helper group, run: + +```bash +cargo test -p tree-ring-memory-sqlite --locked +``` + +Expected: PASS after each group. If a helper move creates lifetime or privacy churn that spreads beyond the helper group, revert that helper move and leave it in `lib.rs` for a smaller follow-up plan. + +- [ ] **Step 9: Commit Task 6** + +Run: + +```bash +git add crates/tree-ring-memory-sqlite/src +git commit -m "Split sqlite store internals" +``` + +--- + +### Task 7: Final Verification, Docs, And PR Handoff + +**Files:** +- Modify: `docs/architecture/rust-core-status.md` +- Modify: `README.md` only if the final implementation changes internal verification guidance + +**Interfaces:** +- Consumes: completed action modules, CLI wiring, TUI wiring, and SQLite helper split. +- Produces: final verification evidence and PR-ready branch. + +- [ ] **Step 1: Add a short architecture status note** + +In `docs/architecture/rust-core-status.md`, add this bullet near the current Rust CLI/TUI status bullets: + +```markdown +- CLI and TUI durable operations now share action request/report contracts for + behavior-preserving command execution. This keeps CLI output ownership, + TUI state/render ownership, and storage ownership separate while preparing + the TUI cockpit and integration-link workflows. +``` + +- [ ] **Step 2: Run formatter** + +Run: + +```bash +cargo fmt --check +``` + +Expected: PASS. + +- [ ] **Step 3: Run full test suite** + +Run: + +```bash +cargo test --locked +``` + +Expected: PASS for all CLI, core, sqlite, and doc tests. + +- [ ] **Step 4: Run Clippy** + +Run: + +```bash +cargo clippy --locked --all-targets +``` + +Expected: PASS with no new warnings. + +- [ ] **Step 5: Run diff whitespace check** + +Run: + +```bash +git diff --check +``` + +Expected: no output and exit code 0. + +- [ ] **Step 6: Run certification** + +Run: + +```bash +sh scripts/certify-tree-ring.sh +``` + +Expected: certification passes and writes `target/tree-ring-certification/summary.md` plus `target/tree-ring-certification/metrics.json`. + +- [ ] **Step 7: Review size reduction and boundaries** + +Run: + +```bash +wc -l crates/tree-ring-memory-cli/src/main.rs crates/tree-ring-memory-cli/src/tui/app.rs crates/tree-ring-memory-sqlite/src/lib.rs crates/tree-ring-memory-cli/src/actions/*.rs crates/tree-ring-memory-cli/src/commands/*.rs crates/tree-ring-memory-sqlite/src/*.rs +``` + +Expected: `main.rs`, `tui/app.rs`, or `sqlite/lib.rs` is thinner than before, or the final PR description explains which file remains large and why the responsibility boundary is still improved. + +- [ ] **Step 8: Commit final docs and verification note** + +Run: + +```bash +git add docs/architecture/rust-core-status.md README.md +git commit -m "Document shared action foundation" +``` + +If `README.md` has no changes, run: + +```bash +git add docs/architecture/rust-core-status.md +git commit -m "Document shared action foundation" +``` + +- [ ] **Step 9: Push and open PR** + +Run: + +```bash +git status --short --branch +git push -u origin codex/shared-action-foundation +gh pr create --base main --head codex/shared-action-foundation --title "Add shared action foundation" --body "## Summary +- add shared action request/report contracts for durable CLI and TUI operations +- wire existing CLI and TUI flows through those actions without changing user-facing behavior +- split private SQLite helpers behind the existing SQLiteMemoryStore facade +- document the shared action foundation as the base for the TUI cockpit lane + +## Verification +- cargo fmt --check +- cargo test --locked +- cargo clippy --locked --all-targets +- git diff --check +- sh scripts/certify-tree-ring.sh" +``` + +Expected: branch pushed and PR opened against `main`. + +--- + +## Plan Self-Review + +- Spec coverage: Tasks 1-5 cover shared actions and CLI/TUI adoption. Task 6 covers SQLite facade-preserving internal split. Task 7 covers docs and certification. +- Incomplete-marker scan: no incomplete markers remain. +- Type consistency: request/report names used by later tasks are introduced before use. +- Scope check: no TUI cockpit features, harness matrix, recall-quality dashboard, schema changes, or behavior changes are included in this plan. From 6c7520023d19e2172c43227dfd6926ba36cfd151 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:39:20 -0400 Subject: [PATCH 03/13] Ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4bc0fde..cbd1f13 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/ .tree-ring/ target/ outputs/ +.worktrees/ bindings/python/python/tree_ring_memory/*.abi3.so bindings/python/python/tree_ring_memory/*.so bindings/python/python/tree_ring_memory/*.pyd From 5ce7246a8f5f7640bf17d3939b04c3d36cb12816 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:42:33 -0400 Subject: [PATCH 04/13] Fix shared action implementation plan --- ...d-action-foundation-implementation-plan.md | 103 +++++++++++++----- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md b/docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md index 7b1d92d..3e4b9b2 100644 --- a/docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md +++ b/docs/superpowers/plans/2026-07-09-tree-ring-shared-action-foundation-implementation-plan.md @@ -180,24 +180,12 @@ mod tests { } ``` -- [ ] **Step 2: Run the focused test and verify module wiring is missing** - -Run: - -```bash -cargo test -p tree-ring-memory-cli remember_action --locked -``` - -Expected: FAIL because `super::ActionResult` and the `actions` module are not declared yet. - -- [ ] **Step 3: Add the shared action module** +- [ ] **Step 2: Wire the new file just enough to make the red test compile and fail** Create `crates/tree-ring-memory-cli/src/actions/mod.rs`: ```rust pub mod remember; - -pub type ActionResult = Result; ``` Modify the module declarations near the top of `crates/tree-ring-memory-cli/src/main.rs`: @@ -211,7 +199,27 @@ mod tui; mod welcome; ``` -- [ ] **Step 4: Run the focused test and verify it passes** +- [ ] **Step 3: Run the focused test and verify the shared result alias is missing** + +Run: + +```bash +cargo test -p tree-ring-memory-cli remember_action --locked +``` + +Expected: FAIL with an unresolved import or missing item for `super::ActionResult`. + +- [ ] **Step 4: Add the shared action result alias** + +Replace `crates/tree-ring-memory-cli/src/actions/mod.rs` with: + +```rust +pub mod remember; + +pub type ActionResult = Result; +``` + +- [ ] **Step 5: Run the focused test and verify it passes** Run: @@ -221,7 +229,7 @@ cargo test -p tree-ring-memory-cli remember_action --locked Expected: PASS with both `remember_action_*` tests passing. -- [ ] **Step 5: Commit Task 1** +- [ ] **Step 6: Commit Task 1** Run: @@ -250,7 +258,7 @@ git commit -m "Add shared remember action" - Produces: `actions::export_import::export_jsonl(store: &SQLiteMemoryStore, request: ExportActionRequest) -> ActionResult` - Produces: `actions::export_import::ImportActionRequest` - Produces: `actions::export_import::ImportActionReport` -- Produces: `actions::export_import::import_jsonl(store: &mut SQLiteMemoryStore, request: ImportActionRequest) -> ActionResult` +- Produces: `actions::export_import::import_jsonl(store: Option<&mut SQLiteMemoryStore>, request: ImportActionRequest) -> ActionResult` - Produces: `actions::audit::AuditActionRequest` - Produces: `actions::audit::AuditActionReport` - Produces: `actions::audit::audit_store(db_path: &Path, request: AuditActionRequest) -> ActionResult` @@ -401,7 +409,7 @@ pub fn export_jsonl( } pub fn import_jsonl( - store: &mut SQLiteMemoryStore, + store: Option<&mut SQLiteMemoryStore>, request: ImportActionRequest, ) -> ActionResult { let input = fs::read_to_string(&request.path).map_err(|err| err.to_string())?; @@ -416,6 +424,9 @@ pub fn import_jsonl( dry_run: true, } } else { + let store = store.ok_or_else(|| { + "import action requires an open writable store when dry_run=false".to_string() + })?; store .import_jsonl(&input, false, request.replace_existing) .map_err(|err| err.to_string())? @@ -480,7 +491,7 @@ mod tests { fs::write(&input, jsonl).unwrap(); let report = import_jsonl( - &mut target, + None, ImportActionRequest { path: input, dry_run: true, @@ -806,9 +817,8 @@ if let Command::Import { replace_existing, } = cli.command { - let mut store = SQLiteMemoryStore::open(&db_path).map_err(|err| err.to_string())?; let report = import_action( - &mut store, + None, ImportActionRequest { path, dry_run: true, @@ -894,7 +904,7 @@ Replace the `Command::Import` writable match arm body with: ```rust let report = import_action( - &mut store, + Some(&mut store), ImportActionRequest { path, dry_run, @@ -1112,9 +1122,9 @@ git commit -m "Use shared actions in TUI write flows" - Produces: `actions::lifecycle::MaintainActionRequest` - Produces: `actions::lifecycle::maintain(db_path: &Path, store: Option<&mut SQLiteMemoryStore>, request: MaintainActionRequest) -> ActionResult` - Produces: `actions::adapters::DoxSyncActionRequest` -- Produces: `actions::adapters::sync_dox(store: &mut SQLiteMemoryStore, request: DoxSyncActionRequest) -> ActionResult` +- Produces: `actions::adapters::sync_dox(store: Option<&mut SQLiteMemoryStore>, request: DoxSyncActionRequest) -> ActionResult` - Produces: `actions::adapters::RevolveSyncActionRequest` -- Produces: `actions::adapters::sync_revolve(store: &mut SQLiteMemoryStore, request: RevolveSyncActionRequest) -> ActionResult` +- Produces: `actions::adapters::sync_revolve(store: Option<&mut SQLiteMemoryStore>, request: RevolveSyncActionRequest) -> ActionResult` - Produces: `actions::integrations::scan(request: IntegrationScanRequest) -> IntegrationScanActionReport` - [ ] **Step 1: Add lifecycle action implementation and tests** @@ -1284,13 +1294,16 @@ pub struct RevolveSyncActionReport { } pub fn sync_dox( - store: &mut SQLiteMemoryStore, + store: Option<&mut SQLiteMemoryStore>, request: DoxSyncActionRequest, ) -> ActionResult { let mut dox_request = DoxSyncRequest::new(request.source_root); dox_request.project = request.project; let report = collect_dox_memories(&dox_request).map_err(|err| err.to_string())?; if !request.dry_run { + let store = store.ok_or_else(|| { + "DOX sync action requires an open writable store when dry_run=false".to_string() + })?; store.put_many(&report.events).map_err(|err| err.to_string())?; } Ok(DoxSyncActionReport { @@ -1300,13 +1313,16 @@ pub fn sync_dox( } pub fn sync_revolve( - store: &mut SQLiteMemoryStore, + store: Option<&mut SQLiteMemoryStore>, request: RevolveSyncActionRequest, ) -> ActionResult { let mut revolve_request = RevolveSyncRequest::new(request.source_root); revolve_request.project = request.project; let report = collect_revolve_memories(&revolve_request).map_err(|err| err.to_string())?; if !request.dry_run { + let store = store.ok_or_else(|| { + "Revolve sync action requires an open writable store when dry_run=false".to_string() + })?; store.put_many(&report.events).map_err(|err| err.to_string())?; } Ok(RevolveSyncActionReport { @@ -1326,10 +1342,9 @@ mod tests { fn dox_action_dry_run_does_not_write_events() { let dir = tempdir().unwrap(); fs::write(dir.path().join("AGENTS.md"), "# Rules\n\nAlways run tests.").unwrap(); - let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); let report = sync_dox( - &mut store, + None, DoxSyncActionRequest { source_root: dir.path().to_path_buf(), project: Some("tree-ring".to_string()), @@ -1339,7 +1354,7 @@ mod tests { .unwrap(); assert_eq!(report.report.memory_count, 1); - assert_eq!(store.list_all(true).unwrap().len(), 0); + assert!(!dir.path().join("memory.sqlite").exists()); } } ``` @@ -1448,6 +1463,36 @@ print_consolidation_report(&report, cli.json)?; return Ok(()); ``` +Replace DOX dry-run pre-store behavior with: + +```rust +let report = sync_dox( + None, + DoxSyncActionRequest { + source_root: source_root.clone(), + project: project.clone(), + dry_run: true, + }, +)?; +print_dox_report(&report.report, cli.json, report.dry_run)?; +return Ok(()); +``` + +Replace Revolve dry-run pre-store behavior with: + +```rust +let report = sync_revolve( + None, + RevolveSyncActionRequest { + source_root: source_root.clone(), + project: project.clone(), + dry_run: true, + }, +)?; +print_revolve_report(&report.report, cli.json, report.dry_run)?; +return Ok(()); +``` + Replace maintenance dry-run pre-store behavior with: ```rust @@ -1466,7 +1511,7 @@ print_maintenance_report(&report, cli.json)?; return Ok(()); ``` -Replace writable `Command::Consolidate`, `Command::Maintain`, `Command::Dox`, and `Command::Revolve` match arm bodies with calls to `consolidate`, `maintain`, `sync_dox`, and `sync_revolve`, then call existing print functions with the returned reports. +Replace writable `Command::Consolidate`, `Command::Maintain`, `Command::Dox`, and `Command::Revolve` match arm bodies with calls to `consolidate`, `maintain`, `sync_dox(Some(&mut store), dox_request)`, and `sync_revolve(Some(&mut store), revolve_request)`, then call existing print functions with the returned reports. - [ ] **Step 6: Update TUI `/integrations` to call the integration action** From 1a1d5d6c4f49b2e7195ace7f013f7631c78587a6 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:44:21 -0400 Subject: [PATCH 05/13] Add shared remember action --- .../tree-ring-memory-cli/src/actions/mod.rs | 3 + .../src/actions/remember.rs | 105 ++++++++++++++++++ crates/tree-ring-memory-cli/src/main.rs | 1 + 3 files changed, 109 insertions(+) create mode 100644 crates/tree-ring-memory-cli/src/actions/mod.rs create mode 100644 crates/tree-ring-memory-cli/src/actions/remember.rs diff --git a/crates/tree-ring-memory-cli/src/actions/mod.rs b/crates/tree-ring-memory-cli/src/actions/mod.rs new file mode 100644 index 0000000..01ed160 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/mod.rs @@ -0,0 +1,3 @@ +pub mod remember; + +pub type ActionResult = Result; diff --git a/crates/tree-ring-memory-cli/src/actions/remember.rs b/crates/tree-ring-memory-cli/src/actions/remember.rs new file mode 100644 index 0000000..e8f2609 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/remember.rs @@ -0,0 +1,105 @@ +use tree_ring_memory_core::{MemoryEvent, SensitivityGuard}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RememberRequest { + pub summary: String, + pub event_type: String, + pub ring: String, + pub scope: String, + pub project: Option, + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RememberReport { + pub memory: MemoryEvent, +} + +pub fn remember( + store: &mut SQLiteMemoryStore, + request: RememberRequest, +) -> ActionResult { + let guard = SensitivityGuard::default(); + let values = [ + request.summary.as_str(), + request.event_type.as_str(), + request.ring.as_str(), + request.scope.as_str(), + ] + .into_iter() + .chain(request.project.iter().map(String::as_str)) + .chain(request.tags.iter().map(String::as_str)); + let detected_sensitivity = guard + .detect_text_sensitivity(values) + .map_err(|err| err.to_string())?; + let mut event = + MemoryEvent::new(request.summary, request.event_type).map_err(|err| err.to_string())?; + event.ring = request.ring; + event.scope = request.scope; + event.project = request.project; + event.tags = request.tags; + if detected_sensitivity != "normal" { + event.sensitivity = detected_sensitivity; + } + event.validate().map_err(|err| err.to_string())?; + store.put(&event).map_err(|err| err.to_string())?; + Ok(RememberReport { memory: event }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn remember_action_stores_memory_with_cli_defaults() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + + let report = remember( + &mut store, + RememberRequest { + summary: "Use shared actions for durable operations.".to_string(), + event_type: "lesson".to_string(), + ring: "cambium".to_string(), + scope: "project".to_string(), + project: Some("tree-ring".to_string()), + tags: vec!["refactor".to_string()], + }, + ) + .unwrap(); + + let stored = store.get(&report.memory.id).unwrap().unwrap(); + assert_eq!(stored.summary, "Use shared actions for durable operations."); + assert_eq!(stored.ring, "cambium"); + assert_eq!(stored.scope, "project"); + assert_eq!(stored.project.as_deref(), Some("tree-ring")); + assert_eq!(stored.tags, vec!["refactor"]); + } + + #[test] + fn remember_action_classifies_sensitive_input_before_storage() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + + let report = remember( + &mut store, + RememberRequest { + summary: "Private diagnosis should be guarded.".to_string(), + event_type: "lesson".to_string(), + ring: "cambium".to_string(), + scope: "global".to_string(), + project: None, + tags: Vec::new(), + }, + ) + .unwrap(); + + let stored = store.get(&report.memory.id).unwrap().unwrap(); + assert_eq!(stored.sensitivity, "health"); + } +} diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 636ccb4..6b0936a 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -15,6 +15,7 @@ use tree_ring_memory_core::{MemoryEvent, MemoryLink}; use tree_ring_memory_sqlite::{MemoryRetriever, SQLiteMemoryStore}; mod agent_awareness; +mod actions; mod integrations; mod ring_mark; mod tui; From dc555306f0fe9dc1afd7f2a46392e55c4de9a699 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:48:28 -0400 Subject: [PATCH 06/13] Add shared scriptable actions --- .../tree-ring-memory-cli/src/actions/audit.rs | 51 ++++++ .../src/actions/export_import.rs | 160 ++++++++++++++++++ .../tree-ring-memory-cli/src/actions/mod.rs | 3 + .../src/actions/recall.rs | 72 ++++++++ 4 files changed, 286 insertions(+) create mode 100644 crates/tree-ring-memory-cli/src/actions/audit.rs create mode 100644 crates/tree-ring-memory-cli/src/actions/export_import.rs create mode 100644 crates/tree-ring-memory-cli/src/actions/recall.rs diff --git a/crates/tree-ring-memory-cli/src/actions/audit.rs b/crates/tree-ring-memory-cli/src/actions/audit.rs new file mode 100644 index 0000000..b09fc3c --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/audit.rs @@ -0,0 +1,51 @@ +use std::path::Path; + +use tree_ring_memory_core::{audit_memories, AuditReport}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditActionRequest { + pub audit_type: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AuditActionReport { + pub report: AuditReport, +} + +pub fn audit_store(db_path: &Path, request: AuditActionRequest) -> ActionResult { + let report = if db_path.exists() { + SQLiteMemoryStore::open_read_only(db_path) + .and_then(|store| store.audit(&request.audit_type)) + .map_err(|err| err.to_string())? + } else { + audit_memories(&[], &request.audit_type).map_err(|err| err.to_string())? + }; + Ok(AuditActionReport { report }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn audit_action_missing_store_reports_empty_audit_without_creating_store() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join(".tree-ring/memory.sqlite"); + + let report = audit_store( + &db_path, + AuditActionRequest { + audit_type: "all".to_string(), + }, + ) + .unwrap(); + + assert_eq!(report.report.memory_count, 0); + assert!(!db_path.exists()); + } +} diff --git a/crates/tree-ring-memory-cli/src/actions/export_import.rs b/crates/tree-ring-memory-cli/src/actions/export_import.rs new file mode 100644 index 0000000..cb82544 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/export_import.rs @@ -0,0 +1,160 @@ +use std::fs; +use std::path::PathBuf; + +use serde_json::json; +use tree_ring_memory_core::{decode_jsonl, normalize_import_events}; +use tree_ring_memory_sqlite::{ExportReport, ImportReport, SQLiteMemoryStore}; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportActionRequest { + pub output: Option, + pub include_sensitive: bool, + pub include_superseded: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportActionReport { + pub jsonl: Option, + pub output: Option, + pub report: ExportReport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportActionRequest { + pub path: PathBuf, + pub dry_run: bool, + pub replace_existing: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportActionReport { + pub path: PathBuf, + pub report: ImportReport, +} + +pub fn export_jsonl( + store: &SQLiteMemoryStore, + request: ExportActionRequest, +) -> ActionResult { + let (jsonl, report) = store + .export_jsonl(request.include_sensitive, request.include_superseded) + .map_err(|err| err.to_string())?; + if let Some(output) = request.output { + 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())?; + Ok(ExportActionReport { + jsonl: None, + output: Some(output), + report, + }) + } else { + Ok(ExportActionReport { + jsonl: Some(jsonl), + output: None, + report, + }) + } +} + +pub fn import_jsonl( + store: Option<&mut SQLiteMemoryStore>, + request: ImportActionRequest, +) -> ActionResult { + let input = fs::read_to_string(&request.path).map_err(|err| err.to_string())?; + let report = if request.dry_run { + let decoded = decode_jsonl(&input).map_err(|err| err.to_string())?; + let events = normalize_import_events(decoded.events).map_err(|err| err.to_string())?; + ImportReport { + valid_count: events.len(), + inserted_count: 0, + replaced_count: 0, + skipped_duplicate_count: 0, + dry_run: true, + } + } else { + let store = store.ok_or_else(|| { + "import action requires an open writable store when dry_run=false".to_string() + })?; + store + .import_jsonl(&input, false, request.replace_existing) + .map_err(|err| err.to_string())? + }; + Ok(ImportActionReport { + path: request.path, + report, + }) +} + +pub fn import_json_payload(report: &ImportActionReport) -> serde_json::Value { + json!({ + "ok": true, + "path": report.path, + "valid_count": report.report.valid_count, + "inserted_count": report.report.inserted_count, + "replaced_count": report.report.replaced_count, + "skipped_duplicate_count": report.report.skipped_duplicate_count, + "dry_run": report.report.dry_run, + }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + use tree_ring_memory_core::MemoryEvent; + + use super::*; + + #[test] + fn export_action_can_return_stdout_jsonl() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + store + .put(&MemoryEvent::new("Export through shared action.", "lesson").unwrap()) + .unwrap(); + + let report = export_jsonl( + &store, + ExportActionRequest { + output: None, + include_sensitive: false, + include_superseded: false, + }, + ) + .unwrap(); + + assert_eq!(report.report.memory_count, 1); + assert!(report.jsonl.unwrap().contains("memory_event")); + } + + #[test] + fn import_action_dry_run_validates_without_writing() { + let dir = tempdir().unwrap(); + let mut source = SQLiteMemoryStore::open(dir.path().join("source.sqlite")).unwrap(); + let target = SQLiteMemoryStore::open(dir.path().join("target.sqlite")).unwrap(); + source + .put(&MemoryEvent::new("Import through shared action.", "lesson").unwrap()) + .unwrap(); + let (jsonl, _) = source.export_jsonl(false, false).unwrap(); + let input = dir.path().join("input.jsonl"); + fs::write(&input, jsonl).unwrap(); + + let report = import_jsonl( + None, + ImportActionRequest { + path: input, + dry_run: true, + replace_existing: false, + }, + ) + .unwrap(); + + assert_eq!(report.report.valid_count, 1); + assert_eq!(target.list_all(true).unwrap().len(), 0); + } +} diff --git a/crates/tree-ring-memory-cli/src/actions/mod.rs b/crates/tree-ring-memory-cli/src/actions/mod.rs index 01ed160..518a540 100644 --- a/crates/tree-ring-memory-cli/src/actions/mod.rs +++ b/crates/tree-ring-memory-cli/src/actions/mod.rs @@ -1,3 +1,6 @@ +pub mod audit; +pub mod export_import; +pub mod recall; pub mod remember; pub type ActionResult = Result; diff --git a/crates/tree-ring-memory-cli/src/actions/recall.rs b/crates/tree-ring-memory-cli/src/actions/recall.rs new file mode 100644 index 0000000..1d7416b --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/recall.rs @@ -0,0 +1,72 @@ +use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore}; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecallRequest { + pub query: String, + pub project: Option, + pub limit: usize, + pub include_sensitive: bool, + pub include_superseded: bool, + pub explain: bool, +} + +#[derive(Debug, Clone)] +pub struct RecallReport { + pub results: Vec, +} + +pub fn recall( + store: &SQLiteMemoryStore, + request: RecallRequest, +) -> ActionResult { + let results = MemoryRetriever::new(store) + .recall( + &request.query, + request.project.as_deref(), + None, + None, + None, + None, + request.include_sensitive, + request.include_superseded, + request.limit, + request.explain, + ) + .map_err(|err| err.to_string())?; + Ok(RecallReport { results }) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + use tree_ring_memory_core::MemoryEvent; + + use super::*; + + #[test] + fn recall_action_returns_ranked_memories() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let event = MemoryEvent::new("Use shared recall action.", "lesson").unwrap(); + store.put(&event).unwrap(); + + let report = recall( + &store, + RecallRequest { + query: "shared recall".to_string(), + project: None, + limit: 8, + include_sensitive: false, + include_superseded: false, + explain: true, + }, + ) + .unwrap(); + + assert_eq!(report.results.len(), 1); + assert_eq!(report.results[0].memory.id, event.id); + assert!(report.results[0].ranking.contains_key("textual_match")); + } +} From 5b37f81b0cb3dcf9a0dbb33439a71941011bf91b Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:52:34 -0400 Subject: [PATCH 07/13] Wire scriptable CLI through shared actions --- .../tree-ring-memory-cli/src/commands/mod.rs | 1 + .../src/commands/scriptable.rs | 78 +++++ crates/tree-ring-memory-cli/src/main.rs | 275 ++++++++---------- 3 files changed, 203 insertions(+), 151 deletions(-) create mode 100644 crates/tree-ring-memory-cli/src/commands/mod.rs create mode 100644 crates/tree-ring-memory-cli/src/commands/scriptable.rs diff --git a/crates/tree-ring-memory-cli/src/commands/mod.rs b/crates/tree-ring-memory-cli/src/commands/mod.rs new file mode 100644 index 0000000..0974265 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod scriptable; diff --git a/crates/tree-ring-memory-cli/src/commands/scriptable.rs b/crates/tree-ring-memory-cli/src/commands/scriptable.rs new file mode 100644 index 0000000..fcb7c78 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/commands/scriptable.rs @@ -0,0 +1,78 @@ +use serde_json::json; + +use crate::actions::export_import::{import_json_payload, ExportActionReport, ImportActionReport}; +use crate::actions::recall::RecallReport; + +pub fn print_recall_report(report: RecallReport, json_output: bool) -> Result<(), String> { + if json_output { + let payload: Vec<_> = report + .results + .into_iter() + .map(|result| { + json!({ + "memory": result.memory, + "score": result.score, + "ranking": result.ranking, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string(&payload).map_err(|err| err.to_string())? + ); + } else { + for result in report.results { + println!( + "{} [{}] {} score={:.3}", + result.memory.id, result.memory.ring, result.memory.summary, result.score + ); + } + } + Ok(()) +} + +pub fn print_export_report(report: ExportActionReport, json_output: bool) -> Result<(), String> { + if let Some(jsonl) = report.jsonl { + print!("{jsonl}"); + return Ok(()); + } + let Some(output) = report.output else { + return Err("export action did not return output path or JSONL".to_string()); + }; + if json_output { + println!( + "{}", + serde_json::to_string(&json!({ + "ok": true, + "path": output, + "memory_count": report.report.memory_count, + "sensitive_included": report.report.sensitive_included, + "superseded_included": report.report.superseded_included, + })) + .map_err(|err| err.to_string())? + ); + } else { + println!( + "Tree Ring Memory export complete: {} memories -> {}", + report.report.memory_count, + output.display() + ); + } + Ok(()) +} + +pub fn print_import_report(report: ImportActionReport, json_output: bool) -> Result<(), String> { + if json_output { + println!("{}", import_json_payload(&report)); + } else { + println!( + "Tree Ring Memory import complete: valid={} inserted={} replaced={} skipped_duplicates={} dry_run={}", + report.report.valid_count, + report.report.inserted_count, + report.report.replaced_count, + report.report.skipped_duplicate_count, + report.report.dry_run + ); + } + Ok(()) +} diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 6b0936a..2209a6f 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -6,16 +6,24 @@ 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, collect_dox_memories, collect_revolve_memories, consolidate_memories, - decode_jsonl, normalize_import_events, AuditReport, ConsolidationPeriod, ConsolidationReport, - ConsolidationRequest, DoxSyncReport, DoxSyncRequest, MaintenanceReport, MaintenanceRequest, - RevolveSyncReport, RevolveSyncRequest, + collect_dox_memories, collect_revolve_memories, consolidate_memories, 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; +use actions::audit::{audit_store, AuditActionRequest}; +use actions::export_import::{ + export_jsonl as export_action, import_jsonl as import_action, ExportActionRequest, + ImportActionRequest, +}; +use actions::recall::{recall as recall_action, RecallRequest}; +use actions::remember::{remember as remember_action, RememberRequest}; + mod actions; +mod agent_awareness; +mod commands; mod integrations; mod ring_mark; mod tui; @@ -326,43 +334,29 @@ fn run(cli: Cli) -> Result<(), String> { if let Command::Import { path, dry_run: true, - replace_existing: _, + replace_existing, } = cli.command { - let input = fs::read_to_string(&path).map_err(|err| err.to_string())?; - let decoded = decode_jsonl(&input).map_err(|err| err.to_string())?; - let events = normalize_import_events(decoded.events).map_err(|err| err.to_string())?; - if cli.json { - println!( - "{}", - json!({ - "ok": true, - "path": path, - "valid_count": events.len(), - "inserted_count": 0, - "replaced_count": 0, - "skipped_duplicate_count": 0, - "dry_run": true, - }) - ); - } else { - println!( - "Tree Ring Memory import complete: valid={} inserted=0 replaced=0 skipped_duplicates=0 dry_run=true", - events.len() - ); - } + let report = import_action( + None, + ImportActionRequest { + path, + dry_run: true, + replace_existing, + }, + )?; + commands::scriptable::print_import_report(report, cli.json)?; return Ok(()); } if let Command::Audit { audit_type } = &cli.command { - let report = if db_path.exists() { - SQLiteMemoryStore::open_read_only(&db_path) - .and_then(|store| store.audit(audit_type)) - .map_err(|err| err.to_string())? - } else { - audit_memories(&[], audit_type).map_err(|err| err.to_string())? - }; - print_audit_report(&report, cli.json)?; + let report = audit_store( + &db_path, + AuditActionRequest { + audit_type: audit_type.clone(), + }, + )?; + print_audit_report(&report.report, cli.json)?; return Ok(()); } @@ -483,32 +477,24 @@ fn run(cli: Cli) -> Result<(), String> { project, tags, } => { - let guard = SensitivityGuard::default(); - let values = [&summary, &event_type, &ring, &scope] - .into_iter() - .chain(project.iter()) - .chain(tags.iter()) - .map(String::as_str); - let detected_sensitivity = guard - .detect_text_sensitivity(values) - .map_err(|err| err.to_string())?; - let mut event = MemoryEvent::new(summary, event_type).map_err(|err| err.to_string())?; - event.ring = ring; - event.scope = scope; - event.project = project; - event.tags = tags; - if detected_sensitivity != "normal" { - event.sensitivity = detected_sensitivity; - } - event.validate().map_err(|err| err.to_string())?; - store.put(&event).map_err(|err| err.to_string())?; + let report = remember_action( + &mut store, + RememberRequest { + summary, + event_type, + ring, + scope, + project, + tags, + }, + )?; if cli.json { println!( "{}", - serde_json::to_string(&event).map_err(|err| err.to_string())? + serde_json::to_string(&report.memory).map_err(|err| err.to_string())? ); } else { - println!("{}", event.id); + println!("{}", report.memory.id); } } Command::Evidence { @@ -548,43 +534,18 @@ fn run(cli: Cli) -> Result<(), String> { limit, include_sensitive, } => { - let results = MemoryRetriever::new(&store) - .recall( - &query, - project.as_deref(), - None, - None, - None, - None, - include_sensitive, - false, + let report = recall_action( + &store, + RecallRequest { + query, + project, limit, - false, - ) - .map_err(|err| err.to_string())?; - if cli.json { - let payload: Vec<_> = results - .into_iter() - .map(|result| { - json!({ - "memory": result.memory, - "score": result.score, - "ranking": result.ranking, - }) - }) - .collect(); - println!( - "{}", - serde_json::to_string(&payload).map_err(|err| err.to_string())? - ); - } else { - for result in results { - println!( - "{} [{}] {} score={:.3}", - result.memory.id, result.memory.ring, result.memory.summary, result.score - ); - } - } + include_sensitive, + include_superseded: false, + explain: false, + }, + )?; + commands::scriptable::print_recall_report(report, cli.json)?; } Command::Forget { memory_id, @@ -609,70 +570,30 @@ fn run(cli: Cli) -> Result<(), String> { include_sensitive, include_superseded, } => { - let (jsonl, report) = store - .export_jsonl(include_sensitive, include_superseded) - .map_err(|err| err.to_string())?; - if let Some(output) = output { - 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())?; - if cli.json { - println!( - "{}", - json!({ - "ok": true, - "path": output, - "memory_count": report.memory_count, - "sensitive_included": report.sensitive_included, - "superseded_included": report.superseded_included, - }) - ); - } else { - println!( - "Tree Ring Memory export complete: {} memories -> {}", - report.memory_count, - output.display() - ); - } - } else { - print!("{jsonl}"); - } + let report = export_action( + &store, + ExportActionRequest { + output, + include_sensitive, + include_superseded, + }, + )?; + commands::scriptable::print_export_report(report, cli.json)?; } Command::Import { path, dry_run, replace_existing, } => { - let input = fs::read_to_string(&path).map_err(|err| err.to_string())?; - let report = store - .import_jsonl(&input, dry_run, replace_existing) - .map_err(|err| err.to_string())?; - if cli.json { - println!( - "{}", - json!({ - "ok": true, - "path": path, - "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, - }) - ); - } else { - println!( - "Tree Ring Memory import complete: valid={} inserted={} replaced={} skipped_duplicates={} dry_run={}", - report.valid_count, - report.inserted_count, - report.replaced_count, - report.skipped_duplicate_count, - report.dry_run - ); - } + let report = import_action( + Some(&mut store), + ImportActionRequest { + path, + dry_run, + replace_existing, + }, + )?; + commands::scriptable::print_import_report(report, cli.json)?; } Command::Audit { .. } => unreachable!("audit returns before opening the writable store"), Command::Consolidate { @@ -1208,6 +1129,58 @@ mod tests { .unwrap(); } + #[test] + fn remember_and_recall_output_stays_stable_after_action_extraction() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli::parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "remember", + "Use action-backed CLI behavior.", + "--event-type", + "lesson", + "--scope", + "project", + "--project", + "tree-ring", + ])) + .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].summary, "Use action-backed CLI behavior."); + } + + #[test] + fn import_dry_run_still_does_not_create_store_rows_after_action_extraction() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + let source_path = dir.path().join("source.sqlite"); + let mut source = SQLiteMemoryStore::open(&source_path).unwrap(); + source + .put(&MemoryEvent::new("Dry-run import action parity.", "lesson").unwrap()) + .unwrap(); + let (jsonl, _) = source.export_jsonl(false, false).unwrap(); + let jsonl_path = dir.path().join("memories.jsonl"); + fs::write(&jsonl_path, jsonl).unwrap(); + + run(Cli::parse_from([ + "tree-ring", + "--root", + root.to_str().unwrap(), + "import", + jsonl_path.to_str().unwrap(), + "--dry-run", + ])) + .unwrap(); + + assert!(!root.join("memory.sqlite").exists()); + } + #[test] fn evidence_promotion_becomes_heartwood_with_evidence_source() { let dir = tempdir().unwrap(); From e8a9441cc8887ee751a5fd5e38caa100471b93c2 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 8 Jul 2026 23:57:50 -0400 Subject: [PATCH 08/13] Use shared actions in TUI write flows --- crates/tree-ring-memory-cli/src/tui/app.rs | 82 ++++++++++++++-------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index 67feef9..62b16b7 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -1,11 +1,12 @@ -use std::fs; use std::path::{Component, Path, PathBuf}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use tree_ring_memory_core::{now_iso, ConsolidationRequest, MemoryEvent, SensitivityGuard}; +use tree_ring_memory_core::{now_iso, ConsolidationRequest, MemoryEvent}; use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore}; use crate::integrations::{scan_integrations, IntegrationScanReport}; +use crate::actions::export_import::{export_jsonl, ExportActionRequest}; +use crate::actions::remember::{remember, RememberRequest}; use super::actions::{ActionKind, PendingAction}; use super::input::{parse_slash_command, SlashCommand}; @@ -271,21 +272,18 @@ impl App { self.status = "remember requires a summary".to_string(); return Ok(()); } - let mut event = - MemoryEvent::new(summary.trim(), "lesson").map_err(|err| err.to_string())?; - event.ring = "cambium".to_string(); - event.source.source_type = "manual".to_string(); - let guard = SensitivityGuard::default(); - let detected = guard - .detect_memory_event_sensitivity(&event) - .map_err(|err| err.to_string())?; - if detected != "normal" { - event.sensitivity = detected; - } - event.validate().map_err(|err| err.to_string())?; - let id = event.id.clone(); - self.store.put(&event).map_err(|err| err.to_string())?; - self.status = format!("remembered {id}"); + let report = remember( + &mut self.store, + RememberRequest { + summary: summary.trim().to_string(), + event_type: "lesson".to_string(), + ring: "cambium".to_string(), + scope: "project".to_string(), + project: None, + tags: Vec::new(), + }, + )?; + self.status = format!("remembered {}", report.memory.id); self.refresh_store() } @@ -371,19 +369,17 @@ impl App { 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())?; + let report = export_jsonl( + &self.store, + ExportActionRequest { + output: Some(output.clone()), + include_sensitive, + include_superseded, + }, + )?; self.status = format!( "exported {} memories to {}", - report.memory_count, + report.report.memory_count, output.display() ); } @@ -574,6 +570,21 @@ mod tests { assert_eq!(app.dashboard.ring("cambium").unwrap().total, 1); } + #[test] + fn slash_remember_uses_shared_action_and_keeps_status_shape() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + + app.execute_slash_command("/remember Use shared TUI remember action") + .unwrap(); + + assert!(app.status.starts_with("remembered mem_")); + let memories = app.store.list_all(false).unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0].summary, "Use shared TUI remember action"); + assert_eq!(memories[0].ring, "cambium"); + } + #[test] fn secret_like_remember_is_blocked() { let dir = tempdir().unwrap(); @@ -670,6 +681,21 @@ mod tests { assert_eq!(app.status, "action cancelled"); } + #[test] + fn confirmed_export_uses_shared_action_and_keeps_default_filters() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + app.execute_slash_command("/remember Export through shared TUI action") + .unwrap(); + app.execute_slash_command("/export shared.jsonl").unwrap(); + confirm(&mut app); + + let output = dir.path().join(".tree-ring/exports/shared.jsonl"); + let jsonl = fs::read_to_string(output).unwrap(); + assert!(jsonl.contains("tree_ring_memory_export")); + assert!(jsonl.contains("Export through shared TUI action")); + } + #[test] fn export_path_rejects_parent_dir_in_relative_and_absolute_targets() { let dir = tempdir().unwrap(); From 98dc641f47921951865fbb54deb40a15619f4f56 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 9 Jul 2026 00:02:34 -0400 Subject: [PATCH 09/13] Add lifecycle and integration actions --- .../src/actions/adapters.rs | 104 +++++++++ .../src/actions/integrations.rs | 39 ++++ .../src/actions/lifecycle.rs | 118 ++++++++++ .../tree-ring-memory-cli/src/actions/mod.rs | 3 + crates/tree-ring-memory-cli/src/main.rs | 204 ++++++++---------- crates/tree-ring-memory-cli/src/tui/app.rs | 11 +- 6 files changed, 359 insertions(+), 120 deletions(-) create mode 100644 crates/tree-ring-memory-cli/src/actions/adapters.rs create mode 100644 crates/tree-ring-memory-cli/src/actions/integrations.rs create mode 100644 crates/tree-ring-memory-cli/src/actions/lifecycle.rs diff --git a/crates/tree-ring-memory-cli/src/actions/adapters.rs b/crates/tree-ring-memory-cli/src/actions/adapters.rs new file mode 100644 index 0000000..2b77e8f --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/adapters.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; + +use tree_ring_memory_core::{ + collect_dox_memories, collect_revolve_memories, DoxSyncReport, DoxSyncRequest, + RevolveSyncReport, RevolveSyncRequest, +}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoxSyncActionRequest { + pub source_root: PathBuf, + pub project: Option, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DoxSyncActionReport { + pub report: DoxSyncReport, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RevolveSyncActionRequest { + pub source_root: PathBuf, + pub project: Option, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RevolveSyncActionReport { + pub report: RevolveSyncReport, + pub dry_run: bool, +} + +pub fn sync_dox( + store: Option<&mut SQLiteMemoryStore>, + request: DoxSyncActionRequest, +) -> ActionResult { + let mut dox_request = DoxSyncRequest::new(request.source_root); + dox_request.project = request.project; + let report = collect_dox_memories(&dox_request).map_err(|err| err.to_string())?; + if !request.dry_run { + let store = store.ok_or_else(|| { + "DOX sync action requires an open writable store when dry_run=false".to_string() + })?; + store + .put_many(&report.events) + .map_err(|err| err.to_string())?; + } + Ok(DoxSyncActionReport { + report, + dry_run: request.dry_run, + }) +} + +pub fn sync_revolve( + store: Option<&mut SQLiteMemoryStore>, + request: RevolveSyncActionRequest, +) -> ActionResult { + let mut revolve_request = RevolveSyncRequest::new(request.source_root); + revolve_request.project = request.project; + let report = collect_revolve_memories(&revolve_request).map_err(|err| err.to_string())?; + if !request.dry_run { + let store = store.ok_or_else(|| { + "Revolve sync action requires an open writable store when dry_run=false".to_string() + })?; + store + .put_many(&report.events) + .map_err(|err| err.to_string())?; + } + Ok(RevolveSyncActionReport { + report, + dry_run: request.dry_run, + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::tempdir; + + use super::*; + + #[test] + fn dox_action_dry_run_does_not_write_events() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Rules\n\nAlways run tests.").unwrap(); + + let report = sync_dox( + None, + DoxSyncActionRequest { + source_root: dir.path().to_path_buf(), + project: Some("tree-ring".to_string()), + dry_run: true, + }, + ) + .unwrap(); + + assert_eq!(report.report.memory_count, 1); + assert!(!dir.path().join("memory.sqlite").exists()); + } +} diff --git a/crates/tree-ring-memory-cli/src/actions/integrations.rs b/crates/tree-ring-memory-cli/src/actions/integrations.rs new file mode 100644 index 0000000..864f216 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/integrations.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +use crate::integrations::{scan_integrations, IntegrationScanReport}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrationScanRequest { + pub source_root: PathBuf, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IntegrationScanActionReport { + pub report: IntegrationScanReport, +} + +pub fn scan(request: IntegrationScanRequest) -> IntegrationScanActionReport { + IntegrationScanActionReport { + report: scan_integrations(&request.source_root), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::tempdir; + + use super::*; + + #[test] + fn integration_action_scans_project_markers() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Rules").unwrap(); + + let report = scan(IntegrationScanRequest { + source_root: dir.path().to_path_buf(), + }); + + assert!(report.report.detected_count > 0); + } +} diff --git a/crates/tree-ring-memory-cli/src/actions/lifecycle.rs b/crates/tree-ring-memory-cli/src/actions/lifecycle.rs new file mode 100644 index 0000000..0a33019 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/actions/lifecycle.rs @@ -0,0 +1,118 @@ +use std::path::Path; + +use tree_ring_memory_core::{ + consolidate_memories, plan_maintenance, ConsolidationPeriod, ConsolidationReport, + ConsolidationRequest, MaintenanceReport, MaintenanceRequest, +}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +use super::ActionResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsolidateActionRequest { + pub period_type: String, + pub period_key: Option, + pub project: Option, + pub dry_run: bool, + pub force: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaintainActionRequest { + pub project: Option, + pub include_superseded: bool, + pub apply_expired: bool, + pub apply_secret_redactions: bool, + pub repair_fts: bool, +} + +pub fn consolidation_request( + request: ConsolidateActionRequest, +) -> ActionResult { + Ok(ConsolidationRequest { + period_type: ConsolidationPeriod::parse(&request.period_type) + .map_err(|err| err.to_string())?, + period_key: request.period_key, + project: request.project, + dry_run: request.dry_run, + force: request.force, + }) +} + +pub fn consolidate( + store: &mut SQLiteMemoryStore, + request: ConsolidateActionRequest, +) -> ActionResult { + let request = consolidation_request(request)?; + store.consolidate(&request).map_err(|err| err.to_string()) +} + +pub fn consolidate_dry_run_from_path( + db_path: &Path, + request: ConsolidateActionRequest, +) -> ActionResult { + let request = consolidation_request(request)?; + if db_path.exists() { + let store = SQLiteMemoryStore::open_read_only(db_path).map_err(|err| err.to_string())?; + let events = store.list_all(false).map_err(|err| err.to_string())?; + consolidate_memories(&events, &request).map_err(|err| err.to_string()) + } else { + consolidate_memories(&[], &request).map_err(|err| err.to_string()) + } +} + +pub fn maintenance_request(request: MaintainActionRequest) -> MaintenanceRequest { + MaintenanceRequest { + dry_run: !(request.apply_expired || request.apply_secret_redactions || request.repair_fts), + apply_expired: request.apply_expired, + apply_secret_redactions: request.apply_secret_redactions, + repair_fts: request.repair_fts, + include_superseded: request.include_superseded, + project: request.project, + } +} + +pub fn maintain( + db_path: &Path, + store: Option<&mut SQLiteMemoryStore>, + request: MaintainActionRequest, +) -> ActionResult { + let request = maintenance_request(request); + if request.dry_run && !db_path.exists() { + return Ok(plan_maintenance(&[], &request)); + } + if let Some(store) = store { + return store.maintain(&request).map_err(|err| err.to_string()); + } + let mut store = SQLiteMemoryStore::open_read_only(db_path).map_err(|err| err.to_string())?; + store.maintain(&request).map_err(|err| err.to_string()) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn maintenance_action_missing_store_is_non_mutating_dry_run() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join(".tree-ring/memory.sqlite"); + + let report = maintain( + &db_path, + None, + MaintainActionRequest { + project: None, + include_superseded: false, + apply_expired: false, + apply_secret_redactions: false, + repair_fts: false, + }, + ) + .unwrap(); + + assert_eq!(report.memory_count, 0); + assert!(!db_path.exists()); + } +} diff --git a/crates/tree-ring-memory-cli/src/actions/mod.rs b/crates/tree-ring-memory-cli/src/actions/mod.rs index 518a540..00a40ac 100644 --- a/crates/tree-ring-memory-cli/src/actions/mod.rs +++ b/crates/tree-ring-memory-cli/src/actions/mod.rs @@ -1,5 +1,8 @@ +pub mod adapters; pub mod audit; pub mod export_import; +pub mod integrations; +pub mod lifecycle; pub mod recall; pub mod remember; diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 2209a6f..3fa80db 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -1,25 +1,29 @@ use clap::{Parser, Subcommand}; -use serde_json::json; use std::ffi::OsString; use std::fs; use std::path::PathBuf; -use tree_ring_memory_core::plan_maintenance; use tree_ring_memory_core::sensitivity::SensitivityGuard; use tree_ring_memory_core::{ - collect_dox_memories, collect_revolve_memories, consolidate_memories, AuditReport, - ConsolidationPeriod, ConsolidationReport, ConsolidationRequest, DoxSyncReport, DoxSyncRequest, - MaintenanceReport, MaintenanceRequest, RevolveSyncReport, RevolveSyncRequest, + AuditReport, ConsolidationPeriod, ConsolidationReport, ConsolidationRequest, DoxSyncReport, + MaintenanceReport, RevolveSyncReport, }; use tree_ring_memory_core::{MemoryEvent, MemoryLink}; use tree_ring_memory_sqlite::{MemoryRetriever, SQLiteMemoryStore}; +use actions::adapters::{sync_dox, sync_revolve, DoxSyncActionRequest, RevolveSyncActionRequest}; use actions::audit::{audit_store, AuditActionRequest}; use actions::export_import::{ export_jsonl as export_action, import_jsonl as import_action, ExportActionRequest, ImportActionRequest, }; +use actions::integrations::{scan as integration_scan_action, IntegrationScanRequest}; +use actions::lifecycle::{ + consolidate, consolidate_dry_run_from_path, maintain, ConsolidateActionRequest, + MaintainActionRequest, +}; use actions::recall::{recall as recall_action, RecallRequest}; use actions::remember::{remember as remember_action, RememberRequest}; +use serde_json::json; mod actions; mod agent_awareness; @@ -315,8 +319,10 @@ fn run(cli: Cli) -> Result<(), String> { command: IntegrationCommand::Scan { source_root }, } = &cli.command { - let report = integrations::scan_integrations(source_root); - print_integration_report(&report, cli.json)?; + let report = integration_scan_action(IntegrationScanRequest { + source_root: source_root.clone(), + }); + print_integration_report(&report.report, cli.json)?; return Ok(()); } @@ -364,25 +370,20 @@ fn run(cli: Cli) -> Result<(), String> { period_type, period_key, project, - dry_run: true, force, + dry_run: true, } = &cli.command { - let request = consolidation_request( - period_type, - period_key.clone(), - project.clone(), - true, - *force, + let report = consolidate_dry_run_from_path( + &db_path, + ConsolidateActionRequest { + period_type: period_type.clone(), + period_key: period_key.clone(), + project: project.clone(), + dry_run: true, + force: *force, + }, )?; - let report = if db_path.exists() { - let store = - SQLiteMemoryStore::open_read_only(&db_path).map_err(|err| err.to_string())?; - let events = store.list_all(false).map_err(|err| err.to_string())?; - consolidate_memories(&events, &request).map_err(|err| err.to_string())? - } else { - consolidate_memories(&[], &request).map_err(|err| err.to_string())? - }; print_consolidation_report(&report, cli.json)?; return Ok(()); } @@ -395,21 +396,15 @@ fn run(cli: Cli) -> Result<(), String> { repair_fts, } = &cli.command { - let request = maintenance_request( - project.clone(), - *include_superseded, - *apply_expired, - *apply_secret_redactions, - *repair_fts, - ); - if request.dry_run { - let report = if db_path.exists() { - let mut store = - SQLiteMemoryStore::open_read_only(&db_path).map_err(|err| err.to_string())?; - store.maintain(&request).map_err(|err| err.to_string())? - } else { - plan_maintenance(&[], &request) - }; + let request = MaintainActionRequest { + project: project.clone(), + include_superseded: *include_superseded, + apply_expired: *apply_expired, + apply_secret_redactions: *apply_secret_redactions, + repair_fts: *repair_fts, + }; + if !(request.apply_expired || request.apply_secret_redactions || request.repair_fts) { + let report = maintain(&db_path, None, request)?; print_maintenance_report(&report, cli.json)?; return Ok(()); } @@ -424,9 +419,15 @@ fn run(cli: Cli) -> Result<(), String> { }, } = &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)?; + let report = sync_dox( + None, + DoxSyncActionRequest { + source_root: source_root.clone(), + project: project.clone(), + dry_run: true, + }, + )?; + print_dox_report(&report.report, cli.json, report.dry_run)?; return Ok(()); } @@ -439,10 +440,15 @@ fn run(cli: Cli) -> Result<(), String> { }, } = &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)?; + let report = sync_revolve( + None, + RevolveSyncActionRequest { + source_root: source_root.clone(), + project: project.clone(), + dry_run: true, + }, + )?; + print_revolve_report(&report.report, cli.json, report.dry_run)?; return Ok(()); } @@ -603,8 +609,16 @@ fn run(cli: Cli) -> Result<(), String> { dry_run, force, } => { - let request = consolidation_request(&period_type, period_key, project, dry_run, force)?; - let report = store.consolidate(&request).map_err(|err| err.to_string())?; + let report = consolidate( + &mut store, + ConsolidateActionRequest { + period_type, + period_key, + project, + dry_run, + force, + }, + )?; print_consolidation_report(&report, cli.json)?; } Command::Maintain { @@ -614,14 +628,17 @@ fn run(cli: Cli) -> Result<(), String> { apply_secret_redactions, repair_fts, } => { - let request = maintenance_request( - project, - include_superseded, - apply_expired, - apply_secret_redactions, - repair_fts, - ); - let report = store.maintain(&request).map_err(|err| err.to_string())?; + let report = maintain( + &db_path, + Some(&mut store), + MaintainActionRequest { + project, + include_superseded, + apply_expired, + apply_secret_redactions, + repair_fts, + }, + )?; print_maintenance_report(&report, cli.json)?; } Command::Tui { .. } => unreachable!("tui returns before opening the scriptable store"), @@ -639,14 +656,15 @@ fn run(cli: Cli) -> Result<(), String> { 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)?; + let report = sync_dox( + if dry_run { None } else { Some(&mut store) }, + DoxSyncActionRequest { + source_root, + project, + dry_run, + }, + )?; + print_dox_report(&report.report, cli.json, report.dry_run)?; } Command::Revolve { command: @@ -656,64 +674,20 @@ fn run(cli: Cli) -> Result<(), String> { 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)?; + let report = sync_revolve( + if dry_run { None } else { Some(&mut store) }, + RevolveSyncActionRequest { + source_root, + project, + dry_run, + }, + )?; + print_revolve_report(&report.report, cli.json, report.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, - apply_expired: bool, - apply_secret_redactions: bool, - repair_fts: bool, -) -> MaintenanceRequest { - MaintenanceRequest { - dry_run: !(apply_expired || apply_secret_redactions || repair_fts), - apply_expired, - apply_secret_redactions, - repair_fts, - include_superseded, - project, - } -} - -fn consolidation_request( - period_type: &str, - period_key: Option, - project: Option, - dry_run: bool, - force: bool, -) -> Result { - Ok(ConsolidationRequest { - period_type: ConsolidationPeriod::parse(period_type).map_err(|err| err.to_string())?, - period_key, - project, - dry_run, - force, - }) -} - fn evidence_event( summary: String, outcome: String, diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index 62b16b7..aa7a43c 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -4,9 +4,10 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tree_ring_memory_core::{now_iso, ConsolidationRequest, MemoryEvent}; use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore}; -use crate::integrations::{scan_integrations, IntegrationScanReport}; use crate::actions::export_import::{export_jsonl, ExportActionRequest}; +use crate::actions::integrations::{scan as scan_integrations_action, IntegrationScanRequest}; use crate::actions::remember::{remember, RememberRequest}; +use crate::integrations::IntegrationScanReport; use super::actions::{ActionKind, PendingAction}; use super::input::{parse_slash_command, SlashCommand}; @@ -414,13 +415,13 @@ impl App { fn show_integrations(&mut self) { let root = project_root_for_memory_root(&self.root); - let report = scan_integrations(&root); + let report = scan_integrations_action(IntegrationScanRequest { source_root: root }); self.status = format!( "integration scan: {} detected under {}", - report.detected_count, - report.root.display() + report.report.detected_count, + report.report.root.display() ); - self.integration_report = Some(report); + self.integration_report = Some(report.report); self.mode = AppMode::Integrations; } From da0fefdcbeacc846138130b089df4ff66b69799a Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 9 Jul 2026 00:07:54 -0400 Subject: [PATCH 10/13] Split sqlite store internals --- crates/tree-ring-memory-sqlite/src/lib.rs | 403 +++--------------- .../tree-ring-memory-sqlite/src/lifecycle.rs | 59 +++ crates/tree-ring-memory-sqlite/src/schema.rs | 109 +++++ crates/tree-ring-memory-sqlite/src/search.rs | 40 ++ crates/tree-ring-memory-sqlite/src/write.rs | 147 +++++++ 5 files changed, 422 insertions(+), 336 deletions(-) create mode 100644 crates/tree-ring-memory-sqlite/src/lifecycle.rs create mode 100644 crates/tree-ring-memory-sqlite/src/schema.rs create mode 100644 crates/tree-ring-memory-sqlite/src/search.rs create mode 100644 crates/tree-ring-memory-sqlite/src/write.rs diff --git a/crates/tree-ring-memory-sqlite/src/lib.rs b/crates/tree-ring-memory-sqlite/src/lib.rs index f640bdb..270a0a7 100644 --- a/crates/tree-ring-memory-sqlite/src/lib.rs +++ b/crates/tree-ring-memory-sqlite/src/lib.rs @@ -1,10 +1,6 @@ -use rusqlite::{ - params, params_from_iter, types::Value, Connection, ErrorCode, OpenFlags, OptionalExtension, - Row, Transaction, -}; +use rusqlite::{params, params_from_iter, types::Value, Connection, ErrorCode, OptionalExtension}; use std::collections::HashSet; use std::path::Path; -use std::time::Duration; use tree_ring_memory_core::models::{sqlite_error, MemoryEvent, TreeRingError, TreeRingResult}; use tree_ring_memory_core::recall::{search_queries, RecallScorer}; @@ -14,9 +10,11 @@ use tree_ring_memory_core::{ MaintenanceActionType, MaintenanceFtsReport, MaintenanceReport, MaintenanceRequest, }; -const WRITE_RETRY_ATTEMPTS: usize = 8; -const WRITE_RETRY_INITIAL_DELAY_MS: u64 = 5; -const WRITE_RETRY_MAX_DELAY_MS: u64 = 100; +mod lifecycle; +mod schema; +mod search; +mod write; + const EXISTING_ID_QUERY_CHUNK: usize = 500; #[derive(Debug, Clone)] @@ -55,42 +53,14 @@ pub struct ImportReport { impl SQLiteMemoryStore { pub fn open(path: impl AsRef) -> TreeRingResult { - let path = path.as_ref(); - if let Some(parent) = parent_dir_to_create(path) { - std::fs::create_dir_all(parent).map_err(|err| sqlite_error(err.to_string()))?; - } - let connection = Connection::open(path).map_err(sqlite_error_from_rusqlite)?; - connection - .busy_timeout(std::time::Duration::from_millis(30_000)) - .map_err(sqlite_error_from_rusqlite)?; - connection - .execute_batch( - "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=30000;", - ) - .map_err(sqlite_error_from_rusqlite)?; + let connection = schema::open_connection(path.as_ref())?; let store = Self { connection }; store.migrate()?; Ok(store) } pub fn open_read_only(path: impl AsRef) -> TreeRingResult { - let path = path - .as_ref() - .canonicalize() - .map_err(|err| sqlite_error(err.to_string()))?; - let normalized_path = normalize_sqlite_uri_path(&path.to_string_lossy()); - let uri = format!( - "file:{}?mode=ro&immutable=1", - sqlite_uri_path(&normalized_path) - ); - let connection = Connection::open_with_flags( - uri, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, - ) - .map_err(sqlite_error_from_rusqlite)?; - connection - .busy_timeout(std::time::Duration::from_millis(30_000)) - .map_err(sqlite_error_from_rusqlite)?; + let connection = schema::open_read_only_connection(path.as_ref())?; Ok(Self { connection }) } @@ -150,19 +120,19 @@ impl SQLiteMemoryStore { } pub fn put(&mut self, event: &MemoryEvent) -> TreeRingResult<()> { - retry_locked(|| { + write::retry_locked(|| { let transaction = self .connection .transaction() .map_err(sqlite_error_from_rusqlite)?; - put_in_transaction(&transaction, event)?; + write::put_in_transaction(&transaction, event)?; transaction.commit().map_err(sqlite_error_from_rusqlite)?; Ok(()) }) } pub fn put_many(&mut self, events: &[MemoryEvent]) -> TreeRingResult<()> { - retry_locked(|| { + write::retry_locked(|| { let transaction = self .connection .transaction() @@ -188,7 +158,7 @@ impl SQLiteMemoryStore { .map_err(sqlite_error_from_rusqlite)?; for event in events { - put_with_statements( + write::put_with_statements( event, &mut insert_memory, &mut delete_fts, @@ -206,7 +176,7 @@ impl SQLiteMemoryStore { .query_row( "SELECT raw_json FROM memories WHERE id = ?", params![memory_id], - event_from_row, + search::event_from_row, ) .optional() .map_err(sqlite_error_from_rusqlite)? @@ -224,9 +194,9 @@ impl SQLiteMemoryStore { .prepare(sql) .map_err(sqlite_error_from_rusqlite)?; let rows = statement - .query_map([], event_from_row) + .query_map([], search::event_from_row) .map_err(sqlite_error_from_rusqlite)?; - collect_rows(rows) + search::collect_rows(rows) } pub fn search_text( @@ -277,14 +247,14 @@ impl SQLiteMemoryStore { .map_err(sqlite_error_from_rusqlite)?; let rows = if let Some(limit) = limit { statement - .query_map(params![fts_query, limit as i64], event_from_row) + .query_map(params![fts_query, limit as i64], search::event_from_row) .map_err(sqlite_error_from_rusqlite)? } else { statement - .query_map(params![fts_query], event_from_row) + .query_map(params![fts_query], search::event_from_row) .map_err(sqlite_error_from_rusqlite)? }; - collect_rows(rows) + search::collect_rows(rows) } #[allow(clippy::too_many_arguments)] @@ -338,10 +308,10 @@ impl SQLiteMemoryStore { parameters.push(Value::Text(scope.to_string())); } if let Some(rings) = rings { - push_in_filter(&mut sql, &mut parameters, "memories.ring", rings); + search::push_in_filter(&mut sql, &mut parameters, "memories.ring", rings); } if let Some(event_types) = event_types { - push_in_filter( + search::push_in_filter( &mut sql, &mut parameters, "memories.event_type", @@ -362,9 +332,9 @@ impl SQLiteMemoryStore { .prepare(&sql) .map_err(sqlite_error_from_rusqlite)?; let rows = statement - .query_map(params_from_iter(parameters), event_from_row) + .query_map(params_from_iter(parameters), search::event_from_row) .map_err(sqlite_error_from_rusqlite)?; - collect_rows(rows) + search::collect_rows(rows) } pub fn supersede(&mut self, old_id: &str, new_id: &str) -> TreeRingResult<()> { @@ -373,7 +343,7 @@ impl SQLiteMemoryStore { }; old.superseded_by = Some(new_id.to_string()); let raw_json = serde_json::to_string(&old)?; - retry_locked(|| { + write::retry_locked(|| { self.connection .execute( "UPDATE memories SET superseded_by = ?, raw_json = ? WHERE id = ?", @@ -385,7 +355,7 @@ impl SQLiteMemoryStore { } pub fn delete(&mut self, memory_id: &str) -> TreeRingResult<()> { - retry_locked(|| { + write::retry_locked(|| { let transaction = self .connection .transaction() @@ -545,7 +515,7 @@ impl SQLiteMemoryStore { && apply_secret_redactions }); if needs_transaction { - let (applied_indexes, fts_repaired) = retry_locked(|| { + let (applied_indexes, fts_repaired) = write::retry_locked(|| { let transaction = self .connection .transaction() @@ -555,7 +525,7 @@ impl SQLiteMemoryStore { for (index, action) in report.actions.iter().enumerate() { if action.action_type == MaintenanceActionType::RedactSecret && apply_secret_redactions - && redact_in_transaction(&transaction, &action.memory_id)? + && write::redact_in_transaction(&transaction, &action.memory_id)? { transaction_applied.push(index); } @@ -564,14 +534,14 @@ impl SQLiteMemoryStore { for (index, action) in report.actions.iter().enumerate() { if action.action_type == MaintenanceActionType::DeleteExpired && apply_expired - && delete_in_transaction(&transaction, &action.memory_id)? + && write::delete_in_transaction(&transaction, &action.memory_id)? { transaction_applied.push(index); } } if repair_fts { - rebuild_fts_in_transaction(&transaction)?; + lifecycle::rebuild_fts_in_transaction(&transaction)?; } transaction.commit().map_err(sqlite_error_from_rusqlite)?; @@ -614,7 +584,7 @@ impl SQLiteMemoryStore { LIMIT 1 "#, params![period_type, period_key, source_ids_json], - stored_consolidation_from_row, + lifecycle::stored_consolidation_from_row, ) .optional() .map_err(sqlite_error_from_rusqlite)? @@ -670,13 +640,13 @@ impl SQLiteMemoryStore { serde_json::to_string(&report.source_memory_ids).map_err(TreeRingError::Json)?; let output_ids_json = serde_json::to_string(&report.output_memory_ids).map_err(TreeRingError::Json)?; - retry_locked(|| { + write::retry_locked(|| { let transaction = self .connection .transaction() .map_err(sqlite_error_from_rusqlite)?; for event in output_events { - put_in_transaction(&transaction, event)?; + write::put_in_transaction(&transaction, event)?; } for (old, new_id) in supersession_pairs { let mut updated = old.clone(); @@ -749,9 +719,9 @@ impl SQLiteMemoryStore { fn fts_report(&self, repaired: bool) -> TreeRingResult { Ok(MaintenanceFtsReport { - memory_rows: count_query(&self.connection, "SELECT count(*) FROM memories")?, - fts_rows: count_query(&self.connection, "SELECT count(*) FROM memory_fts")?, - missing_fts_rows: count_query( + memory_rows: lifecycle::count_query(&self.connection, "SELECT count(*) FROM memories")?, + fts_rows: lifecycle::count_query(&self.connection, "SELECT count(*) FROM memory_fts")?, + missing_fts_rows: lifecycle::count_query( &self.connection, r#" SELECT count(*) @@ -760,7 +730,7 @@ impl SQLiteMemoryStore { WHERE memory_fts.id IS NULL "#, )?, - orphan_fts_rows: count_query( + orphan_fts_rows: lifecycle::count_query( &self.connection, r#" SELECT count(*) @@ -886,11 +856,6 @@ fn matches_filters( true } -fn event_from_row(row: &Row<'_>) -> rusqlite::Result> { - let raw_json: String = row.get(0)?; - Ok(serde_json::from_str::(&raw_json).map_err(Into::into)) -} - fn consolidation_supersession_pairs( previous_outputs: &[MemoryEvent], new_outputs: &[MemoryEvent], @@ -952,112 +917,6 @@ fn maintenance_status(report: &MaintenanceReport) -> String { } } -fn count_query(connection: &Connection, sql: &str) -> TreeRingResult { - let count: i64 = connection - .query_row(sql, [], |row| row.get(0)) - .map_err(sqlite_error_from_rusqlite)?; - Ok(count as usize) -} - -fn delete_in_transaction(transaction: &Transaction<'_>, memory_id: &str) -> TreeRingResult { - let deleted = transaction - .execute("DELETE FROM memories WHERE id = ?", params![memory_id]) - .map_err(sqlite_error_from_rusqlite)?; - transaction - .execute("DELETE FROM memory_fts WHERE id = ?", params![memory_id]) - .map_err(sqlite_error_from_rusqlite)?; - Ok(deleted > 0) -} - -fn redact_in_transaction(transaction: &Transaction<'_>, memory_id: &str) -> TreeRingResult { - let Some(mut event) = transaction - .query_row( - "SELECT raw_json FROM memories WHERE id = ?", - params![memory_id], - event_from_row, - ) - .optional() - .map_err(sqlite_error_from_rusqlite)? - .transpose()? - else { - return Ok(false); - }; - event.redact(); - put_in_transaction(transaction, &event)?; - Ok(true) -} - -fn rebuild_fts_in_transaction(transaction: &Transaction<'_>) -> TreeRingResult<()> { - let events = { - let mut statement = transaction - .prepare("SELECT raw_json FROM memories ORDER BY created_at DESC") - .map_err(sqlite_error_from_rusqlite)?; - let rows = statement - .query_map([], event_from_row) - .map_err(sqlite_error_from_rusqlite)?; - collect_rows(rows)? - }; - transaction - .execute("DELETE FROM memory_fts", []) - .map_err(sqlite_error_from_rusqlite)?; - let mut insert_fts = transaction - .prepare("INSERT INTO memory_fts (id, summary, details, tags, source_ref) VALUES (?, ?, ?, ?, ?)") - .map_err(sqlite_error_from_rusqlite)?; - for event in events { - insert_fts - .execute(params![ - &event.id, - &event.summary, - &event.details, - event.tags.join(" "), - &event.source.ref_, - ]) - .map_err(sqlite_error_from_rusqlite)?; - } - Ok(()) -} - -fn stored_consolidation_from_row( - row: &Row<'_>, -) -> rusqlite::Result> { - let id: String = row.get(0)?; - let created_at: String = row.get(1)?; - let output_ids_json: String = row.get(2)?; - Ok(serde_json::from_str::>(&output_ids_json) - .map(|output_memory_ids| StoredConsolidation { - id, - created_at, - output_memory_ids, - }) - .map_err(Into::into)) -} - -fn parent_dir_to_create(path: &Path) -> Option<&Path> { - path.parent() - .filter(|parent| !parent.as_os_str().is_empty()) -} - -fn normalize_sqlite_uri_path(path: &str) -> String { - if let Some(rest) = path.strip_prefix("\\\\?\\UNC\\") { - format!("\\\\{rest}").replace('\\', "/") - } else if let Some(rest) = path.strip_prefix("\\\\?\\") { - rest.replace('\\', "/") - } else { - path.replace('\\', "/") - } -} - -fn sqlite_uri_path(path: &str) -> String { - path.bytes() - .flat_map(|byte| match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'.' | b'_' | b'~' => { - vec![byte as char] - } - _ => format!("%{byte:02X}").chars().collect(), - }) - .collect() -} - fn sqlite_error_from_rusqlite(error: rusqlite::Error) -> TreeRingError { let is_locked = matches!( &error, @@ -1071,133 +930,6 @@ fn sqlite_error_from_rusqlite(error: rusqlite::Error) -> TreeRingError { } } -fn retry_locked(mut operation: impl FnMut() -> TreeRingResult) -> TreeRingResult { - let mut delay = Duration::from_millis(WRITE_RETRY_INITIAL_DELAY_MS); - for attempt in 0..WRITE_RETRY_ATTEMPTS { - match operation() { - Ok(value) => return Ok(value), - Err(error) if is_sqlite_lock_error(&error) && attempt + 1 < WRITE_RETRY_ATTEMPTS => { - std::thread::sleep(delay); - delay = (delay * 2).min(Duration::from_millis(WRITE_RETRY_MAX_DELAY_MS)); - } - Err(error) => return Err(error), - } - } - unreachable!("retry loop either returns a value or the final error") -} - -fn is_sqlite_lock_error(error: &TreeRingError) -> bool { - matches!(error, TreeRingError::StorageLocked(_)) -} - -fn push_in_filter( - sql: &mut String, - parameters: &mut Vec, - column_name: &str, - values: &[String], -) { - sql.push_str(" AND "); - sql.push_str(column_name); - sql.push_str(" IN ("); - sql.push_str( - &std::iter::repeat_n("?", values.len()) - .collect::>() - .join(", "), - ); - sql.push(')'); - parameters.extend(values.iter().cloned().map(Value::Text)); -} - -fn put_in_transaction(transaction: &Transaction<'_>, event: &MemoryEvent) -> TreeRingResult<()> { - let mut insert_memory = transaction - .prepare( - r#" - INSERT OR REPLACE INTO memories ( - id, created_at, updated_at, project, agent_profile, scope, ring, - event_type, summary, details, source_json, tags_json, salience, - confidence, sensitivity, retention, expires_at, supersedes_json, - superseded_by, links_json, review_json, raw_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - "#, - ) - .map_err(sqlite_error_from_rusqlite)?; - let mut delete_fts = transaction - .prepare("DELETE FROM memory_fts WHERE id = ?") - .map_err(sqlite_error_from_rusqlite)?; - let mut insert_fts = transaction - .prepare("INSERT INTO memory_fts (id, summary, details, tags, source_ref) VALUES (?, ?, ?, ?, ?)") - .map_err(sqlite_error_from_rusqlite)?; - put_with_statements(event, &mut insert_memory, &mut delete_fts, &mut insert_fts) -} - -fn put_with_statements( - event: &MemoryEvent, - insert_memory: &mut rusqlite::Statement<'_>, - delete_fts: &mut rusqlite::Statement<'_>, - insert_fts: &mut rusqlite::Statement<'_>, -) -> TreeRingResult<()> { - event.validate()?; - let source_json = serde_json::to_string(&event.source)?; - let tags_json = serde_json::to_string(&event.tags)?; - let supersedes_json = serde_json::to_string(&event.supersedes)?; - let links_json = serde_json::to_string(&event.links)?; - let review_json = serde_json::to_string(&event.review)?; - let raw_json = serde_json::to_string(event)?; - - insert_memory - .execute(params![ - &event.id, - &event.created_at, - &event.updated_at, - event.project.as_deref(), - event.agent_profile.as_deref(), - &event.scope, - &event.ring, - &event.event_type, - &event.summary, - &event.details, - source_json, - tags_json, - event.salience, - event.confidence, - &event.sensitivity, - &event.retention, - event.expires_at.as_deref(), - supersedes_json, - event.superseded_by.as_deref(), - links_json, - review_json, - raw_json, - ]) - .map_err(sqlite_error_from_rusqlite)?; - - delete_fts - .execute(params![&event.id]) - .map_err(sqlite_error_from_rusqlite)?; - insert_fts - .execute(params![ - &event.id, - &event.summary, - &event.details, - event.tags.join(" "), - &event.source.ref_, - ]) - .map_err(sqlite_error_from_rusqlite)?; - Ok(()) -} - -fn collect_rows(rows: I) -> TreeRingResult> -where - I: IntoIterator>>, -{ - rows.into_iter() - .map(|row| { - row.map_err(sqlite_error_from_rusqlite) - .and_then(|event| event) - }) - .collect() -} - fn format_plain_text_fts_query(query: &str) -> Option { let terms: Vec = tree_ring_memory_core::recall::terms(query) .into_iter() @@ -1222,6 +954,38 @@ mod tests { use tempfile::tempdir; use tree_ring_memory_core::models::MemorySource; + #[test] + fn public_store_facade_still_covers_write_search_export_import_and_maintenance() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("memory.sqlite"); + let mut store = SQLiteMemoryStore::open(&db_path).unwrap(); + let event = MemoryEvent::new("Facade preservation memory.", "lesson").unwrap(); + store.put(&event).unwrap(); + + assert!(store.get(&event.id).unwrap().is_some()); + assert_eq!( + store + .search_text("facade preservation", false) + .unwrap() + .len(), + 1 + ); + + let (jsonl, export_report) = store.export_jsonl(false, false).unwrap(); + assert_eq!(export_report.memory_count, 1); + + let target_dir = tempdir().unwrap(); + let mut target = SQLiteMemoryStore::open(target_dir.path().join("memory.sqlite")).unwrap(); + let import_report = target.import_jsonl(&jsonl, false, false).unwrap(); + assert_eq!(import_report.inserted_count, 1); + + let audit = store.audit("all").unwrap(); + assert_eq!(audit.memory_count, 1); + + let maintenance = store.maintain(&MaintenanceRequest::default()).unwrap(); + assert_eq!(maintenance.memory_count, 1); + } + #[test] fn store_inserts_and_gets_memory() { let dir = tempdir().unwrap(); @@ -1259,39 +1023,6 @@ mod tests { assert!(busy_timeout >= 30_000); } - #[test] - fn plain_relative_sqlite_path_has_no_parent_to_create() { - assert!(parent_dir_to_create(Path::new("memory.sqlite")).is_none()); - assert_eq!( - parent_dir_to_create(Path::new("relative/memory.sqlite")), - Some(Path::new("relative")) - ); - } - - #[test] - fn normalizes_windows_paths_for_sqlite_uri_open() { - assert_eq!( - normalize_sqlite_uri_path(r"\\?\C:\Users\lazy\memory.sqlite"), - "C:/Users/lazy/memory.sqlite" - ); - assert_eq!( - normalize_sqlite_uri_path(r"\\?\UNC\server\share\memory.sqlite"), - "//server/share/memory.sqlite" - ); - assert_eq!( - normalize_sqlite_uri_path(r"C:\Users\lazy\memory.sqlite"), - "C:/Users/lazy/memory.sqlite" - ); - } - - #[test] - fn sqlite_uri_path_percent_encodes_only_unsafe_bytes() { - assert_eq!( - sqlite_uri_path("/tmp/tree ring/mémoire.sqlite"), - "/tmp/tree%20ring/m%C3%A9moire.sqlite" - ); - } - #[test] fn store_searches_fts() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-sqlite/src/lifecycle.rs b/crates/tree-ring-memory-sqlite/src/lifecycle.rs new file mode 100644 index 0000000..d505e6d --- /dev/null +++ b/crates/tree-ring-memory-sqlite/src/lifecycle.rs @@ -0,0 +1,59 @@ +use rusqlite::{params, Connection, Row, Transaction}; + +use tree_ring_memory_core::models::TreeRingResult; + +use crate::search; +use crate::sqlite_error_from_rusqlite; +use crate::StoredConsolidation; + +pub(crate) fn count_query(connection: &Connection, sql: &str) -> TreeRingResult { + let count: i64 = connection + .query_row(sql, [], |row| row.get(0)) + .map_err(sqlite_error_from_rusqlite)?; + Ok(count as usize) +} + +pub(crate) fn rebuild_fts_in_transaction(transaction: &Transaction<'_>) -> TreeRingResult<()> { + let events = { + let mut statement = transaction + .prepare("SELECT raw_json FROM memories ORDER BY created_at DESC") + .map_err(sqlite_error_from_rusqlite)?; + let rows = statement + .query_map([], search::event_from_row) + .map_err(sqlite_error_from_rusqlite)?; + search::collect_rows(rows)? + }; + transaction + .execute("DELETE FROM memory_fts", []) + .map_err(sqlite_error_from_rusqlite)?; + let mut insert_fts = transaction + .prepare("INSERT INTO memory_fts (id, summary, details, tags, source_ref) VALUES (?, ?, ?, ?, ?)") + .map_err(sqlite_error_from_rusqlite)?; + for event in events { + insert_fts + .execute(params![ + &event.id, + &event.summary, + &event.details, + event.tags.join(" "), + &event.source.ref_, + ]) + .map_err(sqlite_error_from_rusqlite)?; + } + Ok(()) +} + +pub(crate) fn stored_consolidation_from_row( + row: &Row<'_>, +) -> rusqlite::Result> { + let id: String = row.get(0)?; + let created_at: String = row.get(1)?; + let output_ids_json: String = row.get(2)?; + Ok(serde_json::from_str::>(&output_ids_json) + .map(|output_memory_ids| StoredConsolidation { + id, + created_at, + output_memory_ids, + }) + .map_err(Into::into)) +} diff --git a/crates/tree-ring-memory-sqlite/src/schema.rs b/crates/tree-ring-memory-sqlite/src/schema.rs new file mode 100644 index 0000000..6da2858 --- /dev/null +++ b/crates/tree-ring-memory-sqlite/src/schema.rs @@ -0,0 +1,109 @@ +use rusqlite::{Connection, OpenFlags}; +use std::path::Path; + +use tree_ring_memory_core::models::{sqlite_error, TreeRingResult}; + +use crate::sqlite_error_from_rusqlite; + +pub(crate) fn open_connection(path: &Path) -> TreeRingResult { + if let Some(parent) = parent_dir_to_create(path) { + std::fs::create_dir_all(parent).map_err(|err| sqlite_error(err.to_string()))?; + } + let connection = Connection::open(path).map_err(sqlite_error_from_rusqlite)?; + configure_connection(&connection)?; + Ok(connection) +} + +pub(crate) fn open_read_only_connection(path: &Path) -> TreeRingResult { + let path = path + .canonicalize() + .map_err(|err| sqlite_error(err.to_string()))?; + let normalized_path = normalize_sqlite_uri_path(&path.to_string_lossy()); + let uri = format!( + "file:{}?mode=ro&immutable=1", + sqlite_uri_path(&normalized_path) + ); + let connection = Connection::open_with_flags( + uri, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(sqlite_error_from_rusqlite)?; + configure_connection(&connection)?; + Ok(connection) +} + +fn configure_connection(connection: &Connection) -> TreeRingResult<()> { + connection + .busy_timeout(std::time::Duration::from_millis(30_000)) + .map_err(sqlite_error_from_rusqlite)?; + connection + .execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=30000;", + ) + .map_err(sqlite_error_from_rusqlite)?; + Ok(()) +} + +pub(crate) fn parent_dir_to_create(path: &Path) -> Option<&Path> { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) +} + +pub(crate) fn normalize_sqlite_uri_path(path: &str) -> String { + if let Some(rest) = path.strip_prefix("\\\\?\\UNC\\") { + format!("\\\\{rest}").replace('\\', "/") + } else if let Some(rest) = path.strip_prefix("\\\\?\\") { + rest.replace('\\', "/") + } else { + path.replace('\\', "/") + } +} + +pub(crate) fn sqlite_uri_path(path: &str) -> String { + path.bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'.' | b'_' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_relative_sqlite_path_has_no_parent_to_create() { + assert!(parent_dir_to_create(Path::new("memory.sqlite")).is_none()); + assert_eq!( + parent_dir_to_create(Path::new("relative/memory.sqlite")), + Some(Path::new("relative")) + ); + } + + #[test] + fn normalizes_windows_paths_for_sqlite_uri_open() { + assert_eq!( + normalize_sqlite_uri_path(r"\\?\C:\Users\lazy\memory.sqlite"), + "C:/Users/lazy/memory.sqlite" + ); + assert_eq!( + normalize_sqlite_uri_path(r"\\?\UNC\server\share\memory.sqlite"), + "//server/share/memory.sqlite" + ); + assert_eq!( + normalize_sqlite_uri_path(r"C:\Users\lazy\memory.sqlite"), + "C:/Users/lazy/memory.sqlite" + ); + } + + #[test] + fn sqlite_uri_path_percent_encodes_only_unsafe_bytes() { + assert_eq!( + sqlite_uri_path("/tmp/tree ring/mémoire.sqlite"), + "/tmp/tree%20ring/m%C3%A9moire.sqlite" + ); + } +} diff --git a/crates/tree-ring-memory-sqlite/src/search.rs b/crates/tree-ring-memory-sqlite/src/search.rs new file mode 100644 index 0000000..170500c --- /dev/null +++ b/crates/tree-ring-memory-sqlite/src/search.rs @@ -0,0 +1,40 @@ +use rusqlite::{types::Value, Result as SqliteResult, Row}; + +use tree_ring_memory_core::models::{MemoryEvent, TreeRingResult}; + +use crate::sqlite_error_from_rusqlite; + +pub(crate) fn event_from_row(row: &Row<'_>) -> SqliteResult> { + let raw_json: String = row.get(0)?; + Ok(serde_json::from_str::(&raw_json).map_err(Into::into)) +} + +pub(crate) fn collect_rows(rows: I) -> TreeRingResult> +where + I: IntoIterator>>, +{ + rows.into_iter() + .map(|row| { + row.map_err(sqlite_error_from_rusqlite) + .and_then(|event| event) + }) + .collect() +} + +pub(crate) fn push_in_filter( + sql: &mut String, + parameters: &mut Vec, + column_name: &str, + values: &[String], +) { + sql.push_str(" AND "); + sql.push_str(column_name); + sql.push_str(" IN ("); + sql.push_str( + &std::iter::repeat_n("?", values.len()) + .collect::>() + .join(", "), + ); + sql.push(')'); + parameters.extend(values.iter().cloned().map(Value::Text)); +} diff --git a/crates/tree-ring-memory-sqlite/src/write.rs b/crates/tree-ring-memory-sqlite/src/write.rs new file mode 100644 index 0000000..5771404 --- /dev/null +++ b/crates/tree-ring-memory-sqlite/src/write.rs @@ -0,0 +1,147 @@ +use rusqlite::{params, OptionalExtension, Transaction}; +use std::time::Duration; + +use tree_ring_memory_core::models::{MemoryEvent, TreeRingError, TreeRingResult}; + +use crate::search; +use crate::sqlite_error_from_rusqlite; + +const WRITE_RETRY_ATTEMPTS: usize = 8; +const WRITE_RETRY_INITIAL_DELAY_MS: u64 = 5; +const WRITE_RETRY_MAX_DELAY_MS: u64 = 100; + +pub(crate) fn delete_in_transaction( + transaction: &Transaction<'_>, + memory_id: &str, +) -> TreeRingResult { + let deleted = transaction + .execute("DELETE FROM memories WHERE id = ?", params![memory_id]) + .map_err(sqlite_error_from_rusqlite)?; + transaction + .execute("DELETE FROM memory_fts WHERE id = ?", params![memory_id]) + .map_err(sqlite_error_from_rusqlite)?; + Ok(deleted > 0) +} + +pub(crate) fn redact_in_transaction( + transaction: &Transaction<'_>, + memory_id: &str, +) -> TreeRingResult { + let Some(mut event) = transaction + .query_row( + "SELECT raw_json FROM memories WHERE id = ?", + params![memory_id], + search::event_from_row, + ) + .optional() + .map_err(sqlite_error_from_rusqlite)? + .transpose()? + else { + return Ok(false); + }; + event.redact(); + put_in_transaction(transaction, &event)?; + Ok(true) +} + +pub(crate) fn put_in_transaction( + transaction: &Transaction<'_>, + event: &MemoryEvent, +) -> TreeRingResult<()> { + let mut insert_memory = transaction + .prepare( + r#" + INSERT OR REPLACE INTO memories ( + id, created_at, updated_at, project, agent_profile, scope, ring, + event_type, summary, details, source_json, tags_json, salience, + confidence, sensitivity, retention, expires_at, supersedes_json, + superseded_by, links_json, review_json, raw_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .map_err(sqlite_error_from_rusqlite)?; + let mut delete_fts = transaction + .prepare("DELETE FROM memory_fts WHERE id = ?") + .map_err(sqlite_error_from_rusqlite)?; + let mut insert_fts = transaction + .prepare("INSERT INTO memory_fts (id, summary, details, tags, source_ref) VALUES (?, ?, ?, ?, ?)") + .map_err(sqlite_error_from_rusqlite)?; + put_with_statements(event, &mut insert_memory, &mut delete_fts, &mut insert_fts) +} + +pub(crate) fn put_with_statements( + event: &MemoryEvent, + insert_memory: &mut rusqlite::Statement<'_>, + delete_fts: &mut rusqlite::Statement<'_>, + insert_fts: &mut rusqlite::Statement<'_>, +) -> TreeRingResult<()> { + event.validate()?; + let source_json = serde_json::to_string(&event.source)?; + let tags_json = serde_json::to_string(&event.tags)?; + let supersedes_json = serde_json::to_string(&event.supersedes)?; + let links_json = serde_json::to_string(&event.links)?; + let review_json = serde_json::to_string(&event.review)?; + let raw_json = serde_json::to_string(event)?; + + insert_memory + .execute(params![ + &event.id, + &event.created_at, + &event.updated_at, + event.project.as_deref(), + event.agent_profile.as_deref(), + &event.scope, + &event.ring, + &event.event_type, + &event.summary, + &event.details, + source_json, + tags_json, + event.salience, + event.confidence, + &event.sensitivity, + &event.retention, + event.expires_at.as_deref(), + supersedes_json, + event.superseded_by.as_deref(), + links_json, + review_json, + raw_json, + ]) + .map_err(sqlite_error_from_rusqlite)?; + + delete_fts + .execute(params![&event.id]) + .map_err(sqlite_error_from_rusqlite)?; + insert_fts + .execute(params![ + &event.id, + &event.summary, + &event.details, + event.tags.join(" "), + &event.source.ref_, + ]) + .map_err(sqlite_error_from_rusqlite)?; + Ok(()) +} + +pub(crate) fn retry_locked( + mut operation: impl FnMut() -> TreeRingResult, +) -> TreeRingResult { + let mut delay = Duration::from_millis(WRITE_RETRY_INITIAL_DELAY_MS); + for attempt in 0..WRITE_RETRY_ATTEMPTS { + match operation() { + Ok(value) => return Ok(value), + Err(error) if is_sqlite_lock_error(&error) && attempt + 1 < WRITE_RETRY_ATTEMPTS => { + std::thread::sleep(delay); + delay = (delay * 2).min(Duration::from_millis(WRITE_RETRY_MAX_DELAY_MS)); + } + Err(error) => return Err(error), + } + } + unreachable!("retry loop either returns a value or the final error") +} + +fn is_sqlite_lock_error(error: &TreeRingError) -> bool { + matches!(error, TreeRingError::StorageLocked(_)) +} From 6ce09bc01ded03eeed460a84c8a140f6eb398c7e Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 9 Jul 2026 00:15:17 -0400 Subject: [PATCH 11/13] Document shared action foundation --- .../src/actions/recall.rs | 5 +--- crates/tree-ring-memory-cli/src/main.rs | 9 +++---- docs/architecture/rust-core-status.md | 24 +++++++++++-------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/actions/recall.rs b/crates/tree-ring-memory-cli/src/actions/recall.rs index 1d7416b..bd2a6ec 100644 --- a/crates/tree-ring-memory-cli/src/actions/recall.rs +++ b/crates/tree-ring-memory-cli/src/actions/recall.rs @@ -17,10 +17,7 @@ pub struct RecallReport { pub results: Vec, } -pub fn recall( - store: &SQLiteMemoryStore, - request: RecallRequest, -) -> ActionResult { +pub fn recall(store: &SQLiteMemoryStore, request: RecallRequest) -> ActionResult { let results = MemoryRetriever::new(store) .recall( &request.query, diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 3fa80db..778175d 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -1,14 +1,12 @@ use clap::{Parser, Subcommand}; use std::ffi::OsString; -use std::fs; use std::path::PathBuf; use tree_ring_memory_core::sensitivity::SensitivityGuard; use tree_ring_memory_core::{ - AuditReport, ConsolidationPeriod, ConsolidationReport, ConsolidationRequest, DoxSyncReport, - MaintenanceReport, RevolveSyncReport, + AuditReport, ConsolidationReport, DoxSyncReport, MaintenanceReport, RevolveSyncReport, }; use tree_ring_memory_core::{MemoryEvent, MemoryLink}; -use tree_ring_memory_sqlite::{MemoryRetriever, SQLiteMemoryStore}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; use actions::adapters::{sync_dox, sync_revolve, DoxSyncActionRequest, RevolveSyncActionRequest}; use actions::audit::{audit_store, AuditActionRequest}; @@ -1021,7 +1019,10 @@ fn print_agent_awareness_summary(report: &agent_awareness::AgentAwarenessReport) #[cfg(test)] mod tests { use super::*; + use std::fs; use tempfile::tempdir; + use tree_ring_memory_core::{ConsolidationPeriod, ConsolidationRequest}; + use tree_ring_memory_sqlite::MemoryRetriever; #[test] fn cli_init_creates_store() { diff --git a/docs/architecture/rust-core-status.md b/docs/architecture/rust-core-status.md index 5858c0d..4717cbd 100644 --- a/docs/architecture/rust-core-status.md +++ b/docs/architecture/rust-core-status.md @@ -18,6 +18,10 @@ v0.11 Rust-native source adapters plus framework discovery. recall, forget, import/export, audit, consolidate, maintain, DOX sync, Revolve sync, framework discovery, welcome onboarding, and TUI operation. - Rust CLI has JSON output for machine-readable adapter use. +- CLI and TUI durable operations now share action request/report contracts for + behavior-preserving command execution. This keeps CLI output ownership, TUI + state/render ownership, and storage ownership separate while preparing the + TUI cockpit and integration-link workflows. - The repository no longer tracks a root Python package, Python wrapper layer, pytest suite, Python smoke scripts, PyO3 crate, or CPython extension. - The v0.4 Rust core and SQLite store own portable JSONL import/export. @@ -108,18 +112,18 @@ sh scripts/certify-tree-ring.sh conservative synthetic-workload thresholds of at least 500 inserts/sec and max recall latency of 250 ms. -Latest local certification run generated at `2026-07-09T02:42:24Z` with Agent -Zero plugin smoke enabled: +Latest local certification run generated at `2026-07-09T04:14:34Z`: -- Release binary: 6,104,272 bytes. -- Project install with init: 6,032 KB. -- Global install: 5,988 KB. +- Release binary: 6,137,088 bytes. +- Project install with init: 6,064 KB. +- Global install: 6,020 KB. - CLI import: 10,000 memories in 5 seconds, about 2,000/sec. -- 10k performance smoke: 2,059.6 inserts/sec, recall average 3.887 ms, - recall max 6.740 ms. -- 30k performance smoke: 667.8 inserts/sec, recall average 8.300 ms, recall - max 14.705 ms. -- Agent Zero plugin smoke: passed. +- 10k performance smoke: 2,182.3 inserts/sec, recall average 3.640 ms, + recall max 6.556 ms. +- 30k performance smoke: 717.5 inserts/sec, recall average 8.004 ms, recall + max 14.412 ms. +- Agent Zero plugin smoke: skipped because `TREE_RING_AGENT_ZERO_ROOT` was not + set. - Extended 50k smoke was skipped; enable it with `TREE_RING_CERT_EXTENDED=1`. ## Compatibility Rule From 3d9f35de85d099a6cfbf86d082aa5e9c182a709b Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 9 Jul 2026 00:20:15 -0400 Subject: [PATCH 12/13] Preserve TUI remember scope --- crates/tree-ring-memory-cli/src/tui/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index aa7a43c..a06cc3a 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -279,7 +279,7 @@ impl App { summary: summary.trim().to_string(), event_type: "lesson".to_string(), ring: "cambium".to_string(), - scope: "project".to_string(), + scope: "global".to_string(), project: None, tags: Vec::new(), }, @@ -584,6 +584,7 @@ mod tests { assert_eq!(memories.len(), 1); assert_eq!(memories[0].summary, "Use shared TUI remember action"); assert_eq!(memories[0].ring, "cambium"); + assert_eq!(memories[0].scope, "global"); } #[test] From 6e1250097ea54d0d1a431834679770becd0f92c5 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Thu, 9 Jul 2026 00:23:07 -0400 Subject: [PATCH 13/13] Refresh certification evidence --- docs/architecture/rust-core-status.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/architecture/rust-core-status.md b/docs/architecture/rust-core-status.md index 4717cbd..d2a2244 100644 --- a/docs/architecture/rust-core-status.md +++ b/docs/architecture/rust-core-status.md @@ -112,16 +112,16 @@ sh scripts/certify-tree-ring.sh conservative synthetic-workload thresholds of at least 500 inserts/sec and max recall latency of 250 ms. -Latest local certification run generated at `2026-07-09T04:14:34Z`: +Latest local certification run generated at `2026-07-09T04:22:38Z`: - Release binary: 6,137,088 bytes. - Project install with init: 6,064 KB. - Global install: 6,020 KB. - CLI import: 10,000 memories in 5 seconds, about 2,000/sec. -- 10k performance smoke: 2,182.3 inserts/sec, recall average 3.640 ms, - recall max 6.556 ms. -- 30k performance smoke: 717.5 inserts/sec, recall average 8.004 ms, recall - max 14.412 ms. +- 10k performance smoke: 2,146.6 inserts/sec, recall average 3.729 ms, + recall max 6.539 ms. +- 30k performance smoke: 711.5 inserts/sec, recall average 7.978 ms, recall + max 14.444 ms. - Agent Zero plugin smoke: skipped because `TREE_RING_AGENT_ZERO_ROOT` was not set. - Extended 50k smoke was skipped; enable it with `TREE_RING_CERT_EXTENDED=1`.