diff --git a/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/.openspec.yaml b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/.openspec.yaml new file mode 100644 index 0000000..a2168c3 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/design.md b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/design.md new file mode 100644 index 0000000..bca72c1 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/design.md @@ -0,0 +1,88 @@ +## Context + +Parent change `abstract-memory-entry-lifecycle` added a pure +`memory_entry` module for generation and use decisions. Runtime code still has +no `[memory]` config, no generated memory file layout, no prompt loading, and +no end-of-turn generation pass. Existing session trajectories are saved under +`RA_HOME/sessions//`; generated memories should live under the same +Ra home/state root as inspectable generated state, but they must not become the +primary place for required project guidance. + +## Goals / Non-Goals + +**Goals:** + +- Wire a disabled-by-default memory feature through config and generated JSON + schema. +- Persist, list, and load durable local memories from the Ra home/state root. +- Add per-session controls for using existing memories and contributing future + memories without mutating global config. +- Inject eligible durable memories into system prompt context while respecting + external-context suppression. +- Run a deterministic background-style generation pass after saved sessions, + gated by the existing `memory_entry` lifecycle decisions. +- Redact likely secrets from generated memory fields. + +**Non-Goals:** + +- Implement high-quality model-backed extraction in this change. +- Make memory files the authoritative control surface for team rules. +- Add UI for browsing or editing memories. +- Add cross-machine sync or remote memory services. + +## Decisions + +1. Add a new `memory` runtime module rather than expanding + `memory_entry.rs`. + + The lifecycle module remains pure policy. The new module owns storage, + redaction, prompt rendering, and generation orchestration, and calls + `decide_generation` / `decide_use` for the policy decision. Alternative: + fold runtime behavior into `memory_entry.rs`; rejected because it would + blur the parent abstraction and make lifecycle unit tests depend on IO. + +2. Store generated files as JSON under `/memories//`. + + Reusing the cwd bucket shape keeps local project memories separated like + trajectories while avoiding path leakage. Each file is inspectable and + atomic-write persisted. Alternative: append to a single TOML/Markdown file; + rejected because generated state should not look like the user's primary + hand-edited config surface. + +3. Use a narrow `MemoryExtractor` trait with a deterministic local extractor. + + The trait leaves room for model-backed extraction later. The default + extractor creates conservative facts from stable-looking user turns and is + sufficient to test the generation pipeline. Alternative: call the LLM during + save; rejected for this scoped change because model extraction quality is + explicitly allowed to be stubbed. + +4. Attach memory runtime options to `Session` and generation to + `RunnerHost::save_session`. + + Existing ACP/A2A/TUI paths already save through `SessionRunner`, so adding a + hook near save keeps generation tied to completed turns. CLI print/resume + paths save inline and will call the same helper after persistence. + Alternative: spawn an always-on scheduler; rejected because active-session + tracking and shutdown semantics would be larger than necessary. + +5. Treat external context suppression as a caller-provided boolean with config + aliases. + + This gives ACP/A2A/TUI/CLI a simple control point and maps existing + suppression aliases into `MemoryPolicy::disable_on_external_context`. + Current callers default to no external context unless they explicitly know + otherwise. + +## Risks / Trade-offs + +- Deterministic extraction may generate fewer memories than a model-backed + extractor -> keep the interface narrow and the pipeline testable so model + extraction can replace it later. +- End-of-turn generation is synchronous enough to run during session save -> + keep it lightweight, skip when rate-limit headroom is too low, and avoid LLM + calls in this change. +- Secret redaction is heuristic -> redact common key/token/password shapes and + record `redaction_applied`; do not claim perfect data-loss prevention. +- Memory context may compete with other system prompt resources -> render a + small bounded section and allow thread-level/global suppression. diff --git a/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/proposal.md b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/proposal.md new file mode 100644 index 0000000..023546a --- /dev/null +++ b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/proposal.md @@ -0,0 +1,41 @@ +## Why + +Ra already models Codex-style memoryEntry lifecycle decisions, but it does not +yet persist memories, load them into prompts, or run the session-level gates +that make the policy useful. This change turns the existing abstraction into a +small runtime memory system while keeping generated memories local and +non-authoritative. + +## What Changes + +- Add config wiring for a globally disabled-by-default `[memory]` section and + per-thread/session use and generation controls. +- Persist generated memory artifacts under the Ra home/state directory and load + durable memories into the system prompt when eligible. +- Add a generation pipeline that evaluates idle, active-session, short-lived + session, rate-limit, external-context, and redaction gates through the + existing `memory_entry` lifecycle abstraction. +- Keep model extraction quality behind a narrow interface with a deterministic + local extractor suitable for tests and future model-backed extraction. +- Document that required team guidance belongs in `AGENTS.md` or checked-in + docs, not only generated memory files. + +## Capabilities + +### New Capabilities + +- `codex-style-memory-system`: runtime storage, loading, thread controls, and + generation behavior for Codex-style local memories. + +### Modified Capabilities + +- None. + +## Impact + +- Affected code: config parsing/schema, a new memory runtime module, system + prompt composition, session runner end-of-turn integration, CLI/TUI/ACP/A2A + session construction, and tests. +- Affected state: generated memory JSON files under the Ra home/state root. +- Affected docs: PRD/issue notes and generated-state guidance. +- No new external dependency is expected. diff --git a/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/specs/codex-style-memory-system/spec.md b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/specs/codex-style-memory-system/spec.md new file mode 100644 index 0000000..96cf621 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/specs/codex-style-memory-system/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: Memory Configuration + +Ra SHALL expose a disabled-by-default memory configuration that can enable local +memory use and generation without requiring code changes. + +#### Scenario: Memories disabled by default + +- **WHEN** Ra loads a minimal config +- **THEN** the memory policy disables generated memories globally + +#### Scenario: Config enables memories and thresholds + +- **WHEN** Ra loads `[memory]` settings for use, generation, idle delay, + minimum session duration, rate-limit threshold, storage path, and external + context suppression +- **THEN** Ra maps those settings into the runtime memory policy + +#### Scenario: External context suppression alias + +- **WHEN** Ra loads the alias for disabling memory when external context is + present +- **THEN** Ra treats it the same as the canonical external-context suppression + setting + +### Requirement: Local Memory Storage + +Ra SHALL persist generated durable memories as inspectable generated JSON state +under the Ra home/state directory or a configured memory directory. + +#### Scenario: Default storage path + +- **WHEN** memories are stored for a working directory and no explicit memory + directory is configured +- **THEN** Ra writes them under `/memories//` + +#### Scenario: Persist and load durable memories + +- **WHEN** Ra persists a generated durable memory artifact +- **THEN** a later runtime can load the same artifact and preserve its metadata, + redaction flag, and content fields + +#### Scenario: Generated state guidance + +- **WHEN** Ra exposes or renders memory guidance +- **THEN** it states that memory files are generated local state and that + required team guidance belongs in `AGENTS.md` or checked-in documentation + +### Requirement: Thread Memory Controls + +Ra SHALL allow each session/thread to control whether it can use existing +memories and whether it can contribute future memories without changing global +settings. + +#### Scenario: Thread disables memory use + +- **WHEN** global memories are enabled and a session disables memory use +- **THEN** Ra suppresses prompt memory context for that session + +#### Scenario: Thread disables memory generation + +- **WHEN** global memories are enabled and a session disables memory generation +- **THEN** Ra skips future memory generation for that session but can still use + existing memories if use is enabled + +### Requirement: Prompt Memory Context + +Ra SHALL load eligible durable memories into prompt context when memory use is +enabled and suppression settings allow it. + +#### Scenario: Durable memories render into system prompt + +- **WHEN** memory use is globally enabled, thread use is enabled, and durable + memories exist for the current working directory +- **THEN** Ra appends a bounded local memory context section to the system prompt + +#### Scenario: External context suppresses prompt memories + +- **WHEN** external context is present and external-context suppression is + enabled +- **THEN** Ra omits memory context from the system prompt + +### Requirement: Memory Generation Pipeline + +Ra SHALL evaluate completed prior sessions for generated memories using the +existing memoryEntry lifecycle policy before writing artifacts. + +#### Scenario: Eligible completed session generates memory + +- **WHEN** a completed session is long enough, idle long enough, inactive, above + the rate-limit threshold, and allowed by global and thread policy +- **THEN** Ra writes a generated durable memory artifact + +#### Scenario: Active, short-lived, idle-pending, and rate-limited sessions skip + +- **WHEN** a session is active, too short-lived, still within the idle delay, or + below the configured rate-limit percentage +- **THEN** Ra does not write a memory artifact and reports the lifecycle + decision reason + +#### Scenario: Secrets are redacted before storage + +- **WHEN** generated memory fields contain likely secrets +- **THEN** Ra redacts those fields before writing and records that redaction was + applied diff --git a/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/tasks.md b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/tasks.md new file mode 100644 index 0000000..c31bdc5 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-implement-codex-style-memory-system/tasks.md @@ -0,0 +1,18 @@ +## 1. Config And Storage + +- [x] 1.1 Add `[memory]` config fields, defaults, aliases, and schema/example coverage. +- [x] 1.2 Implement local memory artifact types, cwd-bucketed storage paths, atomic persistence, and loading. +- [x] 1.3 Implement redaction and generated-state guidance helpers. + +## 2. Runtime Integration + +- [x] 2.1 Map config and thread controls into `MemoryPolicy` using the existing lifecycle abstraction. +- [x] 2.2 Load eligible durable memories into system prompt context with global, thread, and external-context suppression. +- [x] 2.3 Add the generation pipeline with idle, active, short-lived, rate-limit, and redaction gates. +- [x] 2.4 Wire memory runtime into CLI, resume, TUI, ACP, and A2A session save/load paths. + +## 3. Tests And Validation + +- [x] 3.1 Add focused tests for config gating, aliases, storage paths, persistence, and prompt loading. +- [x] 3.2 Add focused tests for thread use/generation controls, idle/rate-limit skipping, active/short-lived skipping, and redaction. +- [x] 3.3 Run formatting, targeted tests, OpenSpec validation, and full available validation. diff --git a/openspec/specs/codex-style-memory-system/spec.md b/openspec/specs/codex-style-memory-system/spec.md new file mode 100644 index 0000000..426c139 --- /dev/null +++ b/openspec/specs/codex-style-memory-system/spec.md @@ -0,0 +1,110 @@ +# codex-style-memory-system Specification + +## Purpose +TBD - created by archiving change implement-codex-style-memory-system. Update Purpose after archive. +## Requirements +### Requirement: Memory Configuration + +Ra SHALL expose a disabled-by-default memory configuration that can enable local +memory use and generation without requiring code changes. + +#### Scenario: Memories disabled by default + +- **WHEN** Ra loads a minimal config +- **THEN** the memory policy disables generated memories globally + +#### Scenario: Config enables memories and thresholds + +- **WHEN** Ra loads `[memory]` settings for use, generation, idle delay, + minimum session duration, rate-limit threshold, storage path, and external + context suppression +- **THEN** Ra maps those settings into the runtime memory policy + +#### Scenario: External context suppression alias + +- **WHEN** Ra loads the alias for disabling memory when external context is + present +- **THEN** Ra treats it the same as the canonical external-context suppression + setting + +### Requirement: Local Memory Storage + +Ra SHALL persist generated durable memories as inspectable generated JSON state +under the Ra home/state directory or a configured memory directory. + +#### Scenario: Default storage path + +- **WHEN** memories are stored for a working directory and no explicit memory + directory is configured +- **THEN** Ra writes them under `/memories//` + +#### Scenario: Persist and load durable memories + +- **WHEN** Ra persists a generated durable memory artifact +- **THEN** a later runtime can load the same artifact and preserve its metadata, + redaction flag, and content fields + +#### Scenario: Generated state guidance + +- **WHEN** Ra exposes or renders memory guidance +- **THEN** it states that memory files are generated local state and that + required team guidance belongs in `AGENTS.md` or checked-in documentation + +### Requirement: Thread Memory Controls + +Ra SHALL allow each session/thread to control whether it can use existing +memories and whether it can contribute future memories without changing global +settings. + +#### Scenario: Thread disables memory use + +- **WHEN** global memories are enabled and a session disables memory use +- **THEN** Ra suppresses prompt memory context for that session + +#### Scenario: Thread disables memory generation + +- **WHEN** global memories are enabled and a session disables memory generation +- **THEN** Ra skips future memory generation for that session but can still use + existing memories if use is enabled + +### Requirement: Prompt Memory Context + +Ra SHALL load eligible durable memories into prompt context when memory use is +enabled and suppression settings allow it. + +#### Scenario: Durable memories render into system prompt + +- **WHEN** memory use is globally enabled, thread use is enabled, and durable + memories exist for the current working directory +- **THEN** Ra appends a bounded local memory context section to the system prompt + +#### Scenario: External context suppresses prompt memories + +- **WHEN** external context is present and external-context suppression is + enabled +- **THEN** Ra omits memory context from the system prompt + +### Requirement: Memory Generation Pipeline + +Ra SHALL evaluate completed prior sessions for generated memories using the +existing memoryEntry lifecycle policy before writing artifacts. + +#### Scenario: Eligible completed session generates memory + +- **WHEN** a completed session is long enough, idle long enough, inactive, above + the rate-limit threshold, and allowed by global and thread policy +- **THEN** Ra writes a generated durable memory artifact + +#### Scenario: Active, short-lived, idle-pending, and rate-limited sessions skip + +- **WHEN** a session is active, too short-lived, still within the idle delay, or + below the configured rate-limit percentage +- **THEN** Ra does not write a memory artifact and reports the lifecycle + decision reason + +#### Scenario: Secrets are redacted before storage + +- **WHEN** generated memory fields contain likely secrets +- **THEN** Ra redacts those fields before writing and records that redaction was + applied + diff --git a/spec/ra-config.schema.json b/spec/ra-config.schema.json index fb89113..1fcf8ad 100644 --- a/spec/ra-config.schema.json +++ b/spec/ra-config.schema.json @@ -19,6 +19,9 @@ "mcp": { "$ref": "#/definitions/McpSection" }, + "memory": { + "$ref": "#/definitions/MemorySection" + }, "model": { "$ref": "#/definitions/ModelSelectorSection" }, @@ -331,6 +334,74 @@ }, "additionalProperties": false }, + "MemorySection": { + "description": "`[memory]` — Codex-style local generated memories. The feature is globally disabled by default; enabling it lets Ra load durable memories into prompt context and generate new local artifacts after eligible sessions.", + "type": "object", + "properties": { + "dir": { + "description": "Optional root for generated memory state. Defaults to `/memories`.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "disable_on_external_context": { + "description": "Disable memory use/generation when external context is present. Accepted aliases cover common wording in upstream and older Ra config drafts.", + "default": false, + "type": "boolean" + }, + "enabled": { + "description": "Global master switch. Default false.", + "default": false, + "type": "boolean" + }, + "generate_memories": { + "description": "Config-level default for contributing future memories. Thread/session controls can further suppress generation without mutating this setting.", + "default": true, + "type": "boolean" + }, + "max_prompt_memories": { + "description": "Max number of memory artifacts rendered into prompt context.", + "default": 20, + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "min_idle_before_generation_secs": { + "description": "Idle delay before background generation, in seconds.", + "default": 600, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "min_rate_limit_remaining_percent": { + "description": "Skip generation when remaining context/rate-limit headroom is below this percentage.", + "default": 0, + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "min_session_duration_secs": { + "description": "Minimum session duration before generation, in seconds.", + "default": 60, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "region_available": { + "description": "Region availability gate. Exposed for parity with Codex-style policy; default true for local Ra.", + "default": true, + "type": "boolean" + }, + "use_memories": { + "description": "Config-level default for using existing memories. Thread/session controls can further suppress use without mutating this setting.", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, "ModelEntrySection": { "description": "One entry in `[[models]]`. Mirrors HCP's `[model]` shape but lifted into an array so Ra can advertise a catalog at session/new time.", "type": "object", diff --git a/spec/ra.toml.example b/spec/ra.toml.example index 8758351..4546195 100644 --- a/spec/ra.toml.example +++ b/spec/ra.toml.example @@ -180,6 +180,23 @@ mode = "default" # default | plan | ask # system_prompt_path = "./prompts/system.md" # append_system_prompt_paths = ["./prompts/style.md"] +# ─── Memory (Codex-style generated local recall) ──────────────────── +# Globally disabled by default. When enabled, Ra stores generated, +# inspectable memory artifacts under /memories// and +# loads eligible durable memories into prompt context. Required team +# rules still belong in AGENTS.md or checked-in docs, not only memory +# state. +# [memory] +# enabled = false +# use_memories = true +# generate_memories = true +# disable_on_external_context = false +# min_idle_before_generation_secs = 600 +# min_session_duration_secs = 60 +# min_rate_limit_remaining_percent = 0 +# dir = "~/.local/share/ra/memories" # optional override +# max_prompt_memories = 20 + # ─── RTK (Rust Token Killer) ─────────────────────────────────────── # Native integration with https://github.com/rtk-ai/rtk: when enabled, # every bash command is offered to `rtk rewrite` first; if RTK has a diff --git a/src/a2a_server.rs b/src/a2a_server.rs index b01fe17..0456baa 100644 --- a/src/a2a_server.rs +++ b/src/a2a_server.rs @@ -69,18 +69,33 @@ pub struct A2aState { hooks: Option>, /// RTK rewriter applied to every Session built from this state. rtk: crate::tools::RtkRewriter, + /// Codex-style local memory runtime loaded from config. + memory: Arc, +} + +struct A2aStateConfig { + model: Arc, + model_factory: Arc, + extra_tools: Vec>, + system_prompt: Option, + prompt_templates: Arc>, + hooks: Option>, + rtk: crate::tools::RtkRewriter, + memory: Arc, } impl A2aState { - pub fn new( - model: Arc, - model_factory: Arc, - extra_tools: Vec>, - system_prompt: Option, - prompt_templates: Arc>, - hooks: Option>, - rtk: crate::tools::RtkRewriter, - ) -> Self { + fn new(config: A2aStateConfig) -> Self { + let A2aStateConfig { + model, + model_factory, + extra_tools, + system_prompt, + prompt_templates, + hooks, + rtk, + memory, + } = config; // Full tool catalog supplied by the caller (main.rs); see // `tools::default_builtins` for the allow-list filter. let tools = extra_tools; @@ -94,6 +109,7 @@ impl A2aState { prompt_templates, hooks, rtk, + memory, } } @@ -111,6 +127,11 @@ impl A2aState { if let Some(sp) = &self.system_prompt { s.set_system_prompt(sp.clone()).await; } + if let Some(prompt) = + crate::memory::load_prompt_for_cwd(Some(&self.memory), &self.cwd).await + { + s.set_memory_prompt(Some(prompt)).await; + } // Resume: if an ATIF trajectory exists on disk for this task id, // hydrate the message log from it. Lets A2A clients reconnect to // a prior task and keep the model's context, or pick up where a @@ -161,6 +182,14 @@ impl RunnerHost for A2aState { if let Err(e) = store.save(&traj).await { eprintln!("[ra::a2a] save trajectory {session_id}: {e:#}"); } + crate::memory::generate_for_session( + Some(&self.memory), + &self.cwd, + session_id, + session.clone(), + None, + ) + .await; } fn default_ctx_window(&self) -> u64 { @@ -488,10 +517,11 @@ pub async fn run( hooks: Option>, bearer_token: Option, rtk: crate::tools::RtkRewriter, + memory: Arc, ) -> Result<()> { nemo_obs::init(); - let state = Arc::new(A2aState::new( + let state = Arc::new(A2aState::new(A2aStateConfig { model, model_factory, extra_tools, @@ -499,7 +529,8 @@ pub async fn run( prompt_templates, hooks, rtk, - )); + memory, + })); let executor = RaExecutor { state: state.clone(), }; diff --git a/src/acp_server.rs b/src/acp_server.rs index af561ba..92fa1ea 100644 --- a/src/acp_server.rs +++ b/src/acp_server.rs @@ -155,6 +155,18 @@ fn ra_config_options() -> Vec { ], ) .description("Whether tool outputs are inlined verbatim or trimmed."), + SessionConfigOption::boolean( + SessionConfigId::from("memory_use".to_string()), + "Use memories".to_string(), + true, + ) + .description("Allow this session to use existing local memories."), + SessionConfigOption::boolean( + SessionConfigId::from("memory_generate".to_string()), + "Generate memories".to_string(), + true, + ) + .description("Allow this session to contribute future local memories."), ] } @@ -183,6 +195,30 @@ struct SharedState { hooks: Option>, /// RTK rewriter applied to every Session built from this state. rtk: crate::tools::RtkRewriter, + /// Codex-style local memory runtime loaded from config. + memory: Arc, +} + +struct SharedStateConfig { + model: Arc, + model_factory: Arc, + extra_tools: Vec>, + system_prompt: Option, + prompt_templates: Arc>, + hooks: Option>, + rtk: crate::tools::RtkRewriter, + memory: Arc, +} + +pub struct AcpServerConfig { + pub model: Arc, + pub model_factory: Arc, + pub extra_tools: Vec>, + pub system_prompt: Option, + pub prompt_templates: Arc>, + pub hooks: Option>, + pub rtk: crate::tools::RtkRewriter, + pub memory: Arc, } /// Resolve a model id (sent by the client over `session/set_model`) to a `Model` @@ -196,15 +232,17 @@ pub trait ModelFactory: Send + Sync { } impl SharedState { - fn new( - model: Arc, - model_factory: Arc, - extra_tools: Vec>, - system_prompt: Option, - prompt_templates: Arc>, - hooks: Option>, - rtk: crate::tools::RtkRewriter, - ) -> Self { + fn new(config: SharedStateConfig) -> Self { + let SharedStateConfig { + model, + model_factory, + extra_tools, + system_prompt, + prompt_templates, + hooks, + rtk, + memory, + } = config; let available_models = model_factory.available(); let default_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); // The full tool catalog is passed in by the caller (main.rs) so the @@ -223,6 +261,7 @@ impl SharedState { prompt_templates, hooks, rtk, + memory, } } @@ -243,6 +282,9 @@ impl SharedState { if let Some(sp) = &self.system_prompt { s.set_system_prompt(sp.clone()).await; } + if let Some(prompt) = crate::memory::load_prompt_for_cwd(Some(&self.memory), &cwd).await { + s.set_memory_prompt(Some(prompt)).await; + } self.sessions.insert(id.to_string(), s.clone()); self.session_cwds.insert(id.to_string(), cwd); s @@ -281,6 +323,14 @@ impl SharedState { if let Err(e) = store.save(&traj).await { eprintln!("[ra::acp] save trajectory {id}: {e:#}"); } + crate::memory::generate_for_session( + Some(&self.memory), + self.cwd_for(id), + id, + session.clone(), + None, + ) + .await; } } @@ -480,20 +530,23 @@ impl ClientHandle for AcpClientHandle { /// Run the ACP server on stdio. Blocks until the client closes stdin. /// -/// `model` is the initial model used for sessions until the client overrides -/// it via `session/set_model`. `model_factory` is consulted on those overrides -/// and to advertise the list of available models in `NewSessionResponse`. -pub async fn run( - model: Arc, - model_factory: Arc, - extra_tools: Vec>, - system_prompt: Option, - prompt_templates: Arc>, - hooks: Option>, - rtk: crate::tools::RtkRewriter, -) -> AcpResult<()> { +/// `config.model` is the initial model used for sessions until the client +/// overrides it via `session/set_model`. `config.model_factory` is consulted on +/// those overrides and to advertise the list of available models in +/// `NewSessionResponse`. +pub async fn run(config: AcpServerConfig) -> AcpResult<()> { crate::nemo_obs::init(); - let state = Arc::new(SharedState::new( + let AcpServerConfig { + model, + model_factory, + extra_tools, + system_prompt, + prompt_templates, + hooks, + rtk, + memory, + } = config; + let state = Arc::new(SharedState::new(SharedStateConfig { model, model_factory, extra_tools, @@ -501,7 +554,8 @@ pub async fn run( prompt_templates, hooks, rtk, - )); + memory, + })); // Each handler closure is FnMut, so we clone the Arc into each one. let s_init = state.clone(); @@ -575,6 +629,9 @@ pub async fn run( "unknown session id: {session_id}" ))); }; + session + .set_memory_external_context(has_external_context(&req.prompt)) + .await; // Build a SessionRunner with the shared host. The runner // owns the spawn body, slash dispatch, observability scope, @@ -904,6 +961,19 @@ pub async fn run( _ => serde_json::Value::Null, }; session.set_config(&config_id, stored).await; + match config_id.as_str() { + "memory_use" => { + if let SessionConfigOptionValue::Boolean { value } = req.value { + session.set_memory_use_enabled(value).await; + } + } + "memory_generate" => { + if let SessionConfigOptionValue::Boolean { value } = req.value { + session.set_memory_generation_enabled(value).await; + } + } + _ => {} + } // Echo the full advertised catalogue back. This is what ACP // clients consume to rebuild their config UI; for now the @@ -942,6 +1012,12 @@ fn collect_text(blocks: &[ContentBlock]) -> String { out } +fn has_external_context(blocks: &[ContentBlock]) -> bool { + blocks + .iter() + .any(|block| !matches!(block, ContentBlock::Text(_))) +} + /// Translate one `RunnerEvent` into ACP `SessionUpdate` notifications. /// /// Called from inside the prompt-handler's spawned task. Owns its own copy diff --git a/src/config.rs b/src/config.rs index 6c8769b..a44f953 100644 --- a/src/config.rs +++ b/src/config.rs @@ -59,6 +59,8 @@ pub struct RaConfig { #[serde(default)] pub resources: ResourcesSection, #[serde(default)] + pub memory: MemorySection, + #[serde(default)] pub rtk: RtkSection, #[serde(default)] pub openlsp: OpenlspSection, @@ -414,6 +416,84 @@ pub struct ResourcesSection { pub append_system_prompt_paths: Vec, } +/// `[memory]` — Codex-style local generated memories. The feature is globally +/// disabled by default; enabling it lets Ra load durable memories into prompt +/// context and generate new local artifacts after eligible sessions. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MemorySection { + /// Global master switch. Default false. + #[serde(default)] + pub enabled: bool, + /// Config-level default for using existing memories. Thread/session + /// controls can further suppress use without mutating this setting. + #[serde(default = "default_true", alias = "use")] + pub use_memories: bool, + /// Config-level default for contributing future memories. Thread/session + /// controls can further suppress generation without mutating this setting. + #[serde(default = "default_true", alias = "generate")] + pub generate_memories: bool, + /// Region availability gate. Exposed for parity with Codex-style policy; + /// default true for local Ra. + #[serde(default = "default_true")] + pub region_available: bool, + /// Disable memory use/generation when external context is present. Accepted + /// aliases cover common wording in upstream and older Ra config drafts. + #[serde( + default, + alias = "suppress_on_external_context", + alias = "disable_when_external_context", + alias = "suppress_when_external_context" + )] + pub disable_on_external_context: bool, + /// Idle delay before background generation, in seconds. + #[serde(default = "default_memory_idle_secs", alias = "min_idle_secs")] + pub min_idle_before_generation_secs: u64, + /// Minimum session duration before generation, in seconds. + #[serde(default = "default_memory_session_secs", alias = "min_duration_secs")] + pub min_session_duration_secs: u64, + /// Skip generation when remaining context/rate-limit headroom is below + /// this percentage. + #[serde(default)] + pub min_rate_limit_remaining_percent: u8, + /// Optional root for generated memory state. Defaults to + /// `/memories`. + #[serde(default, alias = "path")] + pub dir: Option, + /// Max number of memory artifacts rendered into prompt context. + #[serde(default = "default_memory_prompt_limit")] + pub max_prompt_memories: usize, +} + +impl Default for MemorySection { + fn default() -> Self { + Self { + enabled: false, + use_memories: true, + generate_memories: true, + region_available: true, + disable_on_external_context: false, + min_idle_before_generation_secs: default_memory_idle_secs(), + min_session_duration_secs: default_memory_session_secs(), + min_rate_limit_remaining_percent: 0, + dir: None, + max_prompt_memories: default_memory_prompt_limit(), + } + } +} + +fn default_memory_idle_secs() -> u64 { + 10 * 60 +} + +fn default_memory_session_secs() -> u64 { + 60 +} + +fn default_memory_prompt_limit() -> usize { + 20 +} + /// `[rtk]` — native [RTK (Rust Token Killer)](https://github.com/rtk-ai/rtk) /// integration. When enabled, the BashTool runs every command through /// `rtk rewrite` before executing it. RTK rewrites well-known dev @@ -689,6 +769,41 @@ timeout = 2.0 assert_eq!(cfg.hooks.pre_tool_use[0].matcher, "bash"); } + #[test] + fn memory_defaults_disabled_and_parses_aliases() { + let cfg: RaConfig = toml::from_str("version = 1\n").unwrap(); + assert!(!cfg.memory.enabled); + assert!(cfg.memory.use_memories); + assert!(cfg.memory.generate_memories); + assert_eq!(cfg.memory.min_idle_before_generation_secs, 600); + assert_eq!(cfg.memory.min_session_duration_secs, 60); + + let toml_doc = r#" +version = 1 + +[memory] +enabled = true +use = false +generate = true +suppress_on_external_context = true +min_idle_secs = 5 +min_duration_secs = 2 +min_rate_limit_remaining_percent = 25 +path = "./.ra/memory" +max_prompt_memories = 3 +"#; + let cfg: RaConfig = toml::from_str(toml_doc).unwrap(); + assert!(cfg.memory.enabled); + assert!(!cfg.memory.use_memories); + assert!(cfg.memory.generate_memories); + assert!(cfg.memory.disable_on_external_context); + assert_eq!(cfg.memory.min_idle_before_generation_secs, 5); + assert_eq!(cfg.memory.min_session_duration_secs, 2); + assert_eq!(cfg.memory.min_rate_limit_remaining_percent, 25); + assert_eq!(cfg.memory.dir.as_deref(), Some("./.ra/memory")); + assert_eq!(cfg.memory.max_prompt_memories, 3); + } + #[test] fn parse_legacy_hook_aliases() { // Existing user configs use the snake_case names — they must diff --git a/src/lib.rs b/src/lib.rs index d2814b9..4ce8cb6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod hooks; pub mod init; pub mod llm_model; pub mod mcp; +pub mod memory; pub mod memory_entry; pub mod model; pub mod nemo_obs; diff --git a/src/main.rs b/src/main.rs index 5208767..dfcb6f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -449,15 +449,17 @@ async fn run_acp(config: &ra::config::RaConfig) -> anyhow::Result<()> { load_skills_and_prompts(config, graphify_workflow.clone()); let hooks = build_hooks(config); let rtk = ra::RtkRewriter::from_config(&config.rtk); - ra::acp_server::run( + let memory = Arc::new(ra::memory::MemorySystem::from_config(config)); + ra::acp_server::run(ra::acp_server::AcpServerConfig { model, - factory, + model_factory: factory, extra_tools, system_prompt, prompt_templates, hooks, rtk, - ) + memory, + }) .await .map_err(|e| anyhow::anyhow!("{e:?}"))?; Ok(()) @@ -505,6 +507,7 @@ async fn run_serve( hooks, bearer, ra::RtkRewriter::from_config(&config.rtk), + Arc::new(ra::memory::MemorySystem::from_config(config)), ) .await } @@ -561,6 +564,12 @@ async fn run_print(prompt: Option, config: &ra::config::RaConfig) -> any if let Some(sp) = system_prompt { session.set_system_prompt(sp).await; } + let memory = ra::memory::MemorySystem::from_config(config); + if let Some(prompt) = + ra::memory::load_prompt_for_cwd(Some(&memory), &std::env::current_dir()?).await + { + session.set_memory_prompt(Some(prompt)).await; + } let printer = spawn_event_printer(session.subscribe()); @@ -581,6 +590,8 @@ async fn run_print(prompt: Option, config: &ra::config::RaConfig) -> any ), Err(e) => eprintln!("[ra] warning: could not save session: {e:#}"), } + ra::memory::generate_for_session(Some(&memory), &cwd, &session_id, session.clone(), None) + .await; } Ok(()) } @@ -679,6 +690,10 @@ async fn run_resume(id: &str, prompt: String, config: &ra::config::RaConfig) -> if let Some(sp) = system_prompt { session.set_system_prompt(sp).await; } + let memory = ra::memory::MemorySystem::from_config(config); + if let Some(prompt) = ra::memory::load_prompt_for_cwd(Some(&memory), &cwd).await { + session.set_memory_prompt(Some(prompt)).await; + } session.restore_messages(messages).await; let printer = spawn_event_printer(session.subscribe()); @@ -693,6 +708,7 @@ async fn run_resume(id: &str, prompt: String, config: &ra::config::RaConfig) -> if let Err(e) = store.save(&traj).await { eprintln!("[ra] warning: failed to save resumed trajectory: {e:#}"); } + ra::memory::generate_for_session(Some(&memory), &cwd, id, session.clone(), None).await; Ok(()) } diff --git a/src/memory.rs b/src/memory.rs new file mode 100644 index 0000000..90f947c --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,661 @@ +//! Codex-style local memory runtime. +//! +//! `memory_entry` remains the pure lifecycle/policy model. This module owns +//! generated memory files, redaction, prompt rendering, and the lightweight +//! generation pipeline that calls the lifecycle decisions before writing state. + +use crate::config::{MemorySection, RaConfig}; +use crate::memory_entry::{ + decide_generation, decide_use, GenerationDecision, MemoryCandidate, MemoryEntry, MemoryPolicy, + SuppressionReason, UseDecision, +}; +use crate::model::Message; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +const MEMORY_SCHEMA_VERSION: &str = "ra.memory.v1"; + +#[derive(Debug, Clone)] +pub struct MemorySystem { + section: MemorySection, +} + +impl MemorySystem { + pub fn from_config(config: &RaConfig) -> Self { + Self { + section: config.memory.clone(), + } + } + + pub fn is_enabled(&self) -> bool { + self.section.enabled + } + + pub fn runtime_for_cwd(&self, cwd: impl AsRef) -> Result { + let store = MemoryStore::for_cwd(cwd, self.section.dir.as_deref())?; + Ok(MemoryRuntime { + policy: policy_from_config(&self.section), + store, + max_prompt_memories: self.section.max_prompt_memories, + }) + } +} + +#[derive(Debug, Clone)] +pub struct MemoryRuntime { + policy: MemoryPolicy, + store: MemoryStore, + max_prompt_memories: usize, +} + +impl MemoryRuntime { + pub fn policy(&self) -> &MemoryPolicy { + &self.policy + } + + pub fn store(&self) -> &MemoryStore { + &self.store + } + + pub async fn load_prompt(&self) -> Result> { + let artifacts = self.store.load_all().await?; + if artifacts.is_empty() { + return Ok(None); + } + Ok(Some(MemoryPrompt { + policy: self.policy.clone(), + artifacts, + max_entries: self.max_prompt_memories, + })) + } + + pub async fn generate_from_session( + &self, + input: MemoryGenerationInput, + ) -> Result { + if self.store.source_session_exists(&input.session_id).await? { + return Ok(MemoryGenerationOutcome { + decision: GenerationDecision::Skipped { + reason: SuppressionReason::EntryNotDurable, + }, + artifact: None, + path: None, + }); + } + let mut policy = policy_for_thread(&self.policy, &input.controls); + policy.use_memories = self.policy.use_memories; + + let candidate = MemoryCandidate { + session_duration: input.session_duration, + idle_for: input.idle_for, + is_active: input.is_active, + has_external_context: input.controls.has_external_context, + rate_limit_remaining_percent: input.rate_limit_remaining_percent, + redaction_applied: false, + }; + let decision = decide_generation(&policy, &candidate); + if !matches!(decision, GenerationDecision::Allowed { .. }) { + return Ok(MemoryGenerationOutcome { + decision, + artifact: None, + path: None, + }); + } + + let Some(draft) = LocalMemoryExtractor.extract(&input.session_id, &input.messages) else { + return Ok(MemoryGenerationOutcome { + decision, + artifact: None, + path: None, + }); + }; + let (content, redaction_applied) = redact_draft(draft); + let entry = MemoryEntry::generated(redaction_applied); + let artifact = MemoryArtifact::generated( + input.session_id, + self.store.cwd_hash().to_string(), + entry, + content, + ); + let path = self.store.save(&artifact).await?; + Ok(MemoryGenerationOutcome { + decision: GenerationDecision::Allowed { + entry: MemoryEntry::generated(redaction_applied), + }, + artifact: Some(artifact), + path: Some(path), + }) + } +} + +pub async fn load_prompt_for_cwd( + system: Option<&MemorySystem>, + cwd: impl AsRef, +) -> Option { + let system = system?; + if !system.is_enabled() { + return None; + } + let runtime = match system.runtime_for_cwd(cwd) { + Ok(runtime) => runtime, + Err(e) => { + eprintln!("[ra::memory] runtime: {e:#}"); + return None; + } + }; + match runtime.load_prompt().await { + Ok(prompt) => prompt, + Err(e) => { + eprintln!("[ra::memory] load prompt: {e:#}"); + None + } + } +} + +pub async fn generate_for_session( + system: Option<&MemorySystem>, + cwd: impl AsRef, + session_id: &str, + session: Arc, + rate_limit_remaining_percent: Option, +) { + let Some(system) = system else { return }; + if !system.is_enabled() { + return; + } + let cwd = cwd.as_ref().to_path_buf(); + let session_id = session_id.to_string(); + let outcome = generate_for_session_once( + system, + &cwd, + &session_id, + &session, + rate_limit_remaining_percent, + ) + .await; + if let Some(GenerationDecision::Pending { remaining_idle, .. }) = outcome { + let system = system.clone(); + let session = session.clone(); + tokio::spawn(async move { + tokio::time::sleep(remaining_idle).await; + let _ = generate_for_session_once( + &system, + &cwd, + &session_id, + &session, + rate_limit_remaining_percent, + ) + .await; + }); + } +} + +async fn generate_for_session_once( + system: &MemorySystem, + cwd: &Path, + session_id: &str, + session: &Arc, + rate_limit_remaining_percent: Option, +) -> Option { + let runtime = match system.runtime_for_cwd(cwd) { + Ok(runtime) => runtime, + Err(e) => { + eprintln!("[ra::memory] runtime: {e:#}"); + return None; + } + }; + let timing = session.memory_timing().await; + let messages = session.snapshot_messages().await; + if messages.is_empty() { + return None; + } + let input = MemoryGenerationInput { + session_id: session_id.to_string(), + messages, + session_duration: timing.session_duration, + idle_for: timing.idle_for, + is_active: timing.is_active, + rate_limit_remaining_percent, + controls: session.memory_controls().await, + }; + match runtime.generate_from_session(input).await { + Ok(outcome) => { + if let Some(path) = outcome.path { + eprintln!("[ra::memory] saved {}", path.display()); + } + Some(outcome.decision) + } + Err(e) => { + eprintln!("[ra::memory] generate: {e:#}"); + None + } + } +} + +pub fn policy_from_config(section: &MemorySection) -> MemoryPolicy { + MemoryPolicy { + memories_enabled: section.enabled, + region_available: section.region_available, + generate_memories: section.generate_memories, + use_memories: section.use_memories, + disable_on_external_context: section.disable_on_external_context, + min_idle_before_generation: Duration::from_secs(section.min_idle_before_generation_secs), + min_session_duration: Duration::from_secs(section.min_session_duration_secs), + min_rate_limit_remaining_percent: section.min_rate_limit_remaining_percent, + } +} + +pub fn policy_for_thread(base: &MemoryPolicy, controls: &MemoryThreadControls) -> MemoryPolicy { + let mut policy = base.clone(); + policy.use_memories = base.use_memories && controls.use_memories; + policy.generate_memories = base.generate_memories && controls.generate_memories; + policy +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryThreadControls { + pub use_memories: bool, + pub generate_memories: bool, + pub has_external_context: bool, +} + +impl Default for MemoryThreadControls { + fn default() -> Self { + Self { + use_memories: true, + generate_memories: true, + has_external_context: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct MemoryPrompt { + policy: MemoryPolicy, + artifacts: Vec, + max_entries: usize, +} + +impl MemoryPrompt { + pub fn render(&self, controls: &MemoryThreadControls) -> Option { + let policy = policy_for_thread(&self.policy, controls); + let mut rendered = Vec::new(); + for artifact in self.artifacts.iter().filter(|a| a.entry.durable) { + let entry = MemoryEntry { + durable: artifact.entry.durable, + redaction_applied: artifact.entry.redaction_applied, + }; + match decide_use(&policy, &entry, controls.has_external_context) { + UseDecision::Active => rendered.push(render_artifact(artifact)), + UseDecision::Suppressed { .. } => {} + } + if rendered.len() >= self.max_entries { + break; + } + } + if rendered.is_empty() { + return None; + } + + let guidance = MemoryEntry::generated(false).guidance(); + let mut out = String::from("# Local Memories\n\n"); + out.push_str( + "These are generated local memories for stable preferences, recurring workflows, \ + stacks, project conventions, and known pitfalls. They are inspectable generated \ + state, not the primary control surface.\n\n", + ); + out.push_str(&format!( + "Required team guidance remains authoritative in {}.\n\n", + guidance.authoritative_team_guidance + )); + out.push_str(&rendered.join("\n\n")); + Some(out) + } + + pub fn artifacts(&self) -> &[MemoryArtifact] { + &self.artifacts + } +} + +fn render_artifact(artifact: &MemoryArtifact) -> String { + let mut out = format!("## Memory {}\n", artifact.id); + if let Some(summary) = &artifact.content.summary { + out.push_str(&format!("- summary: {}\n", summary.trim())); + } + append_list(&mut out, "facts", &artifact.content.facts); + append_list(&mut out, "preferences", &artifact.content.preferences); + append_list(&mut out, "workflows", &artifact.content.workflows); + append_list(&mut out, "pitfalls", &artifact.content.pitfalls); + if artifact.entry.redaction_applied { + out.push_str("- note: secret-like values were redacted before storage\n"); + } + out.trim_end().to_string() +} + +fn append_list(out: &mut String, label: &str, values: &[String]) { + for value in values { + out.push_str(&format!("- {label}: {}\n", value.trim())); + } +} + +#[derive(Debug, Clone)] +pub struct MemoryGenerationInput { + pub session_id: String, + pub messages: Vec, + pub session_duration: Duration, + pub idle_for: Duration, + pub is_active: bool, + pub rate_limit_remaining_percent: Option, + pub controls: MemoryThreadControls, +} + +#[derive(Debug, Clone)] +pub struct MemoryGenerationOutcome { + pub decision: GenerationDecision, + pub artifact: Option, + pub path: Option, +} + +impl MemoryGenerationOutcome { + pub fn suppression_reason(&self) -> Option { + match &self.decision { + GenerationDecision::Skipped { reason } => Some(*reason), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryArtifact { + pub schema_version: String, + pub id: String, + pub created_at: String, + pub cwd_hash: String, + pub source_session_id: String, + pub entry: StoredMemoryEntry, + pub content: MemoryContent, +} + +impl MemoryArtifact { + pub fn generated( + source_session_id: String, + cwd_hash: String, + entry: MemoryEntry, + content: MemoryContent, + ) -> Self { + Self { + schema_version: MEMORY_SCHEMA_VERSION.to_string(), + id: ulid::Ulid::new().to_string(), + created_at: crate::atif::now_iso8601(), + cwd_hash, + source_session_id, + entry: StoredMemoryEntry { + durable: entry.durable, + redaction_applied: entry.redaction_applied, + }, + content, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredMemoryEntry { + pub durable: bool, + pub redaction_applied: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub facts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub preferences: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workflows: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pitfalls: Vec, +} + +impl MemoryContent { + fn is_empty(&self) -> bool { + self.summary.as_deref().unwrap_or("").trim().is_empty() + && self.facts.is_empty() + && self.preferences.is_empty() + && self.workflows.is_empty() + && self.pitfalls.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct MemoryStore { + bucket: PathBuf, + cwd_hash: String, +} + +impl MemoryStore { + pub fn for_cwd(cwd: impl AsRef, configured_dir: Option<&str>) -> Result { + let cwd = cwd.as_ref(); + let cwd_hash = crate::store::cwd_hash(cwd); + let root = match configured_dir { + Some(dir) => RaConfig::expand_path(dir, cwd), + None => ra_home()?.join("memories"), + }; + let bucket = root.join(&cwd_hash); + std::fs::create_dir_all(&bucket) + .with_context(|| format!("create_dir_all {}", bucket.display()))?; + Ok(Self { bucket, cwd_hash }) + } + + pub fn bucket(&self) -> &Path { + &self.bucket + } + + pub fn cwd_hash(&self) -> &str { + &self.cwd_hash + } + + pub fn path_for(&self, id: &str) -> PathBuf { + self.bucket.join(format!("{id}.json")) + } + + pub async fn save(&self, artifact: &MemoryArtifact) -> Result { + let bucket = self.bucket.clone(); + let target = self.path_for(&artifact.id); + let json = serde_json::to_vec_pretty(artifact).context("serialize memory artifact")?; + tokio::task::spawn_blocking(move || -> Result { + let mut tmp = tempfile::NamedTempFile::new_in(&bucket) + .with_context(|| format!("tempfile in {}", bucket.display()))?; + std::io::Write::write_all(tmp.as_file_mut(), &json)?; + tmp.as_file_mut().sync_all().ok(); + tmp.persist(&target) + .map_err(|e| anyhow::anyhow!("persist: {e}"))?; + Ok(target) + }) + .await + .context("save memory spawn_blocking")? + } + + pub async fn load_all(&self) -> Result> { + let bucket = self.bucket.clone(); + tokio::task::spawn_blocking(move || -> Result> { + let mut out = Vec::new(); + let dir = match std::fs::read_dir(&bucket) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e).context("read_dir"), + }; + let mut files = Vec::new(); + for entry in dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let modified = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + files.push((modified, path)); + } + files.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified)); + for (_, path) in files { + let bytes = + std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let artifact: MemoryArtifact = serde_json::from_slice(&bytes) + .with_context(|| format!("parse {}", path.display()))?; + if artifact.schema_version == MEMORY_SCHEMA_VERSION { + out.push(artifact); + } + } + Ok(out) + }) + .await + .context("load memories spawn_blocking")? + } + + pub async fn source_session_exists(&self, session_id: &str) -> Result { + Ok(self + .load_all() + .await? + .iter() + .any(|artifact| artifact.source_session_id == session_id)) + } +} + +fn ra_home() -> Result { + match std::env::var_os("RA_HOME") { + Some(p) => Ok(PathBuf::from(p)), + None => Ok(dirs::data_local_dir() + .context("dirs::data_local_dir returned None")? + .join("ra")), + } +} + +trait MemoryExtractor { + fn extract(&self, session_id: &str, messages: &[Message]) -> Option; +} + +struct LocalMemoryExtractor; + +impl MemoryExtractor for LocalMemoryExtractor { + fn extract(&self, _session_id: &str, messages: &[Message]) -> Option { + let mut content = MemoryContent::default(); + for message in messages { + let Message::User { content: text } = message else { + continue; + }; + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + let lower = line.to_ascii_lowercase(); + if lower.contains("password") + || lower.contains("api key") + || lower.contains("token") + || lower.contains("secret") + { + push_unique(&mut content.facts, line); + } else if lower.contains("prefer") + || lower.contains("preference") + || lower.contains("remember") + { + push_unique(&mut content.preferences, line); + } else if lower.contains("workflow") + || lower.contains("always run") + || lower.contains("usually run") + { + push_unique(&mut content.workflows, line); + } else if lower.contains("stack") + || lower.contains("convention") + || lower.contains("project uses") + { + push_unique(&mut content.facts, line); + } else if lower.contains("pitfall") + || lower.contains("gotcha") + || lower.contains("avoid") + { + push_unique(&mut content.pitfalls, line); + } + } + } + if content.is_empty() { + None + } else { + let mut summary_parts = Vec::new(); + if !content.preferences.is_empty() { + summary_parts.push("stable preferences"); + } + if !content.workflows.is_empty() { + summary_parts.push("recurring workflows"); + } + if !content.facts.is_empty() { + summary_parts.push("project facts"); + } + if !content.pitfalls.is_empty() { + summary_parts.push("known pitfalls"); + } + content.summary = Some(format!("Captured {}.", summary_parts.join(", "))); + Some(content) + } + } +} + +fn push_unique(values: &mut Vec, value: &str) { + let value = value.trim(); + if value.is_empty() || values.iter().any(|existing| existing == value) { + return; + } + values.push(value.to_string()); +} + +fn redact_draft(draft: MemoryContent) -> (MemoryContent, bool) { + let mut redacted = false; + let summary = draft.summary.map(|s| { + let (value, did) = redact_text(&s); + redacted |= did; + value + }); + let mut redact_vec = |values: Vec| { + values + .into_iter() + .map(|value| { + let (value, did) = redact_text(&value); + redacted |= did; + value + }) + .collect() + }; + ( + MemoryContent { + summary, + facts: redact_vec(draft.facts), + preferences: redact_vec(draft.preferences), + workflows: redact_vec(draft.workflows), + pitfalls: redact_vec(draft.pitfalls), + }, + redacted, + ) +} + +pub fn redact_text(input: &str) -> (String, bool) { + let mut out = input.to_string(); + let mut changed = false; + let patterns = [ + ( + r"(?i)(api[_ -]?key|access[_ -]?token|auth[_ -]?token|token|secret|password)\s*[:=]\s*([^\s,;]+)", + "$1=", + ), + (r"sk-[A-Za-z0-9_-]{12,}", ""), + (r"ghp_[A-Za-z0-9_]{12,}", ""), + ]; + for (pattern, replacement) in patterns { + let re = regex::Regex::new(pattern).expect("valid memory redaction regex"); + let next = re.replace_all(&out, replacement).to_string(); + if next != out { + changed = true; + out = next; + } + } + (out, changed) +} diff --git a/src/session.rs b/src/session.rs index 885a497..eeec543 100644 --- a/src/session.rs +++ b/src/session.rs @@ -6,6 +6,7 @@ use anyhow::{anyhow, Result}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::sync::{broadcast, Mutex, RwLock}; use tokio_stream::StreamExt; use tokio_util::sync::CancellationToken; @@ -43,6 +44,12 @@ pub struct Session { /// Optional system-style preamble prepended to every turn's history. /// Populated from skill bodies; not part of the persisted message log. system_prompt: RwLock>, + /// Optional generated local memory context. Rendered per turn so + /// thread-level controls and external-context suppression can apply. + memory_prompt: RwLock>, + /// Per-session memory controls. ACP config options can change these + /// without mutating global config. + memory_controls: RwLock, /// Optional hook engine that fires PreToolUse / PostToolUse around /// every tool execution. None = no hooks configured. hooks: Option>, @@ -58,6 +65,10 @@ pub struct Session { runtime_scope: Arc>>, /// Serializes prompt invocations so scoped overrides cannot overlap. prompt_lock: Arc>, + /// Monotonic timing used by memory generation gates. + created_at: Instant, + last_activity_at: Mutex, + active: Mutex, } /// Temporary runtime controls used for a single `prompt()` invocation. @@ -69,6 +80,13 @@ pub struct SessionRuntimeScope { pub hooks: Option>, } +#[derive(Debug, Clone, Copy)] +pub struct MemoryTiming { + pub session_duration: Duration, + pub idle_for: Duration, + pub is_active: bool, +} + /// Result of one `prompt()` call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PromptOutcome { @@ -97,11 +115,16 @@ impl Session { mode: RwLock::new("default".into()), config: RwLock::new(HashMap::new()), system_prompt: RwLock::new(None), + memory_prompt: RwLock::new(None), + memory_controls: RwLock::new(crate::memory::MemoryThreadControls::default()), hooks: None, file_approver: None, rtk: crate::tools::RtkRewriter::default(), runtime_scope: Arc::new(Mutex::new(None)), prompt_lock: Arc::new(Mutex::new(())), + created_at: Instant::now(), + last_activity_at: Mutex::new(Instant::now()), + active: Mutex::new(false), } } @@ -164,6 +187,30 @@ impl Session { self.system_prompt.read().await.clone() } + pub async fn set_memory_prompt(&self, prompt: Option) { + *self.memory_prompt.write().await = prompt; + } + + pub async fn set_memory_controls(&self, controls: crate::memory::MemoryThreadControls) { + *self.memory_controls.write().await = controls; + } + + pub async fn memory_controls(&self) -> crate::memory::MemoryThreadControls { + self.memory_controls.read().await.clone() + } + + pub async fn set_memory_use_enabled(&self, enabled: bool) { + self.memory_controls.write().await.use_memories = enabled; + } + + pub async fn set_memory_generation_enabled(&self, enabled: bool) { + self.memory_controls.write().await.generate_memories = enabled; + } + + pub async fn set_memory_external_context(&self, has_external_context: bool) { + self.memory_controls.write().await.has_external_context = has_external_context; + } + /// Hot-swap the active model. Used by `session/set_model` (ACP unstable). pub async fn set_model(&self, model: Arc) { *self.model.write().await = model; @@ -273,6 +320,17 @@ impl Session { self.messages.lock().await.clone() } + pub async fn memory_timing(&self) -> MemoryTiming { + let now = Instant::now(); + let last_activity_at = *self.last_activity_at.lock().await; + let is_active = *self.active.lock().await; + MemoryTiming { + session_duration: now.saturating_duration_since(self.created_at), + idle_for: now.saturating_duration_since(last_activity_at), + is_active, + } + } + /// Signal the active prompt to abort. Idempotent; safe to call from any task. pub async fn cancel(&self) { self.cancel.lock().await.cancel(); @@ -337,6 +395,10 @@ impl Session { *guard = CancellationToken::new(); guard.clone() }; + { + *self.active.lock().await = true; + *self.last_activity_at.lock().await = Instant::now(); + } self.messages .lock() @@ -353,6 +415,10 @@ impl Session { } }; + { + *self.active.lock().await = false; + *self.last_activity_at.lock().await = Instant::now(); + } let _ = self.tx.send(Event::AgentEnd); Ok(outcome) } @@ -377,7 +443,14 @@ impl Session { // message tagged with [SYSTEM]. graniet/llm's ChatRole only has // User/Assistant, so we route system content through user with a // marker; most providers cooperate. - let history = if let Some(sp) = self.system_prompt.read().await.clone() { + let system_prompt = self.system_prompt.read().await.clone(); + let memory_section = { + let prompt = self.memory_prompt.read().await.clone(); + let controls = self.memory_controls.read().await.clone(); + prompt.and_then(|p| p.render(&controls)) + }; + let combined_prompt = combine_system_prompt(system_prompt, memory_section); + let history = if let Some(sp) = combined_prompt { let mut h = Vec::with_capacity(history.len() + 1); h.push(Message::User { content: format!("[SYSTEM]\n{sp}"), @@ -576,6 +649,18 @@ fn tool_decl_matches(decl: &str, name: &str) -> bool { decl.eq_ignore_ascii_case(name) } +fn combine_system_prompt(base: Option, memory: Option) -> Option { + match (base, memory) { + (Some(base), Some(memory)) if !base.trim().is_empty() => { + Some(format!("{}\n\n{}", base.trim_end(), memory.trim())) + } + (Some(base), None) if !base.trim().is_empty() => Some(base), + (None, Some(memory)) if !memory.trim().is_empty() => Some(memory), + (Some(_), Some(memory)) if !memory.trim().is_empty() => Some(memory), + _ => None, + } +} + fn final_assistant_text(messages: &[Message]) -> Option { messages.iter().rev().find_map(|msg| match msg { Message::Assistant { content, .. } if !content.is_empty() => Some(content.clone()), diff --git a/src/store.rs b/src/store.rs index 773a3d4..374d5f3 100644 --- a/src/store.rs +++ b/src/store.rs @@ -144,7 +144,7 @@ impl SessionStore { } /// 16-hex-char prefix of `sha256(cwd_str)`. Stable, filesystem-safe. -fn cwd_hash(cwd: &Path) -> String { +pub(crate) fn cwd_hash(cwd: &Path) -> String { let canon = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); let mut h = Sha256::new(); h.update(canon.to_string_lossy().as_bytes()); diff --git a/src/tui.rs b/src/tui.rs index 9b1343c..5136c91 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -196,6 +196,10 @@ async fn build_session( if let Some(sp) = system_prompt { session.set_system_prompt(sp).await; } + let memory = crate::memory::MemorySystem::from_config(config); + if let Some(prompt) = crate::memory::load_prompt_for_cwd(Some(&memory), &cwd).await { + session.set_memory_prompt(Some(prompt)).await; + } Ok((session, prompt_templates)) } @@ -281,6 +285,7 @@ struct TuiRunnerHost { session_id: String, config_model_name: Option, ctx_window: u64, + memory: crate::memory::MemorySystem, } #[async_trait] @@ -303,6 +308,14 @@ impl RunnerHost for TuiRunnerHost { if let Err(e) = store.save(&traj).await { eprintln!("[ra::tui] failed to save session: {e:#}"); } + crate::memory::generate_for_session( + Some(&self.memory), + &cwd, + &self.session_id, + self.session.clone(), + None, + ) + .await; } fn default_ctx_window(&self) -> u64 { @@ -435,6 +448,7 @@ impl TuiApp { session_id: session_id.clone(), config_model_name, ctx_window: 200_000, + memory: crate::memory::MemorySystem::from_config(config), }); let runner = Arc::new( SessionRunner::new(session.clone(), session_id.clone(), host) diff --git a/tests/memory_system.rs b/tests/memory_system.rs new file mode 100644 index 0000000..859fe8d --- /dev/null +++ b/tests/memory_system.rs @@ -0,0 +1,313 @@ +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::stream::{self, BoxStream, StreamExt}; +use ra::config::RaConfig; +use ra::memory::{ + load_prompt_for_cwd, policy_for_thread, policy_from_config, redact_text, MemoryArtifact, + MemoryContent, MemoryGenerationInput, MemoryStore, MemorySystem, MemoryThreadControls, +}; +use ra::memory_entry::{GenerationDecision, MemoryEntry, SuppressionReason}; +use ra::model::{Message, Model, ModelChunk, StopReason, ToolSpec}; +use ra::Session; +use tokio::sync::Mutex as AsyncMutex; + +fn env_lock() -> &'static AsyncMutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| AsyncMutex::new(())) +} + +fn enabled_config() -> RaConfig { + toml::from_str( + r#" +version = 1 + +[memory] +enabled = true +min_idle_before_generation_secs = 0 +min_session_duration_secs = 0 +min_rate_limit_remaining_percent = 20 +max_prompt_memories = 5 +"#, + ) + .unwrap() +} + +struct SeenModel { + seen: std::sync::Arc>>>, +} + +#[async_trait] +impl Model for SeenModel { + async fn stream( + &self, + messages: &[Message], + _tools: &[ToolSpec], + ) -> anyhow::Result> { + self.seen.lock().unwrap().push(messages.to_vec()); + Ok(stream::iter([ModelChunk::End { + stop_reason: StopReason::EndTurn, + }]) + .boxed()) + } +} + +#[test] +fn memory_config_maps_to_lifecycle_policy_and_thread_controls() { + let cfg = enabled_config(); + let policy = policy_from_config(&cfg.memory); + assert!(policy.memories_enabled); + assert!(policy.use_memories); + assert!(policy.generate_memories); + assert_eq!(policy.min_rate_limit_remaining_percent, 20); + + let thread_policy = policy_for_thread( + &policy, + &MemoryThreadControls { + use_memories: false, + generate_memories: true, + has_external_context: false, + }, + ); + assert!(!thread_policy.use_memories); + assert!(thread_policy.generate_memories); +} + +#[tokio::test] +async fn memory_store_defaults_under_ra_home_and_roundtrips_artifact() { + let _guard = env_lock().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("RA_HOME", tmp.path()); + } + + let cwd = tmp.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let store = MemoryStore::for_cwd(&cwd, None).unwrap(); + assert!(store.bucket().starts_with(tmp.path().join("memories"))); + + let artifact = MemoryArtifact::generated( + "session-1".into(), + store.cwd_hash().to_string(), + MemoryEntry::generated(true), + MemoryContent { + summary: Some("Captured stable preferences.".into()), + preferences: vec!["Prefer cargo test before review.".into()], + ..MemoryContent::default() + }, + ); + let path = store.save(&artifact).await.unwrap(); + assert!(path.exists()); + + let loaded = store.load_all().await.unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].source_session_id, "session-1"); + assert!(loaded[0].entry.redaction_applied); + assert_eq!(loaded[0].content.preferences.len(), 1); +} + +#[tokio::test] +async fn disabled_memory_does_not_create_state_when_loading_prompt() { + let _guard = env_lock().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("RA_HOME", tmp.path()); + } + let cfg: RaConfig = toml::from_str("version = 1\n").unwrap(); + let system = MemorySystem::from_config(&cfg); + assert!(load_prompt_for_cwd(Some(&system), tmp.path()) + .await + .is_none()); + assert!(!tmp.path().join("memories").exists()); +} + +#[tokio::test] +async fn prompt_context_respects_use_and_external_context_suppression() { + let _guard = env_lock().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("RA_HOME", tmp.path()); + } + + let cfg: RaConfig = toml::from_str( + r#" +version = 1 + +[memory] +enabled = true +disable_on_external_context = true +"#, + ) + .unwrap(); + let cwd = tmp.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let system = MemorySystem::from_config(&cfg); + let runtime = system.runtime_for_cwd(&cwd).unwrap(); + let artifact = MemoryArtifact::generated( + "session-1".into(), + runtime.store().cwd_hash().to_string(), + MemoryEntry::generated(false), + MemoryContent { + summary: Some("Captured project facts.".into()), + facts: vec!["Project uses Rust stable.".into()], + ..MemoryContent::default() + }, + ); + runtime.store().save(&artifact).await.unwrap(); + + let prompt = runtime.load_prompt().await.unwrap().unwrap(); + let rendered = prompt.render(&MemoryThreadControls::default()).unwrap(); + assert!(rendered.contains("# Local Memories")); + assert!(rendered.contains("Project uses Rust stable.")); + assert!(rendered.contains("AGENTS.md or checked-in documentation")); + + assert!(prompt + .render(&MemoryThreadControls { + use_memories: false, + ..MemoryThreadControls::default() + }) + .is_none()); + assert!(prompt + .render(&MemoryThreadControls { + has_external_context: true, + ..MemoryThreadControls::default() + }) + .is_none()); +} + +#[tokio::test] +async fn generation_pipeline_applies_gates_and_redaction() { + let _guard = env_lock().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("RA_HOME", tmp.path()); + } + + let cfg = enabled_config(); + let cwd = tmp.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let runtime = MemorySystem::from_config(&cfg) + .runtime_for_cwd(&cwd) + .unwrap(); + let messages = vec![Message::User { + content: "Remember prefer concise Rust tests. api_key=sk-1234567890abcdef".into(), + }]; + + let low_rate = runtime + .generate_from_session(MemoryGenerationInput { + session_id: "low-rate".into(), + messages: messages.clone(), + session_duration: Duration::from_secs(120), + idle_for: Duration::from_secs(120), + is_active: false, + rate_limit_remaining_percent: Some(19), + controls: MemoryThreadControls::default(), + }) + .await + .unwrap(); + assert_eq!( + low_rate.suppression_reason(), + Some(SuppressionReason::RateLimitTooLow) + ); + assert!(low_rate.path.is_none()); + + let active = runtime + .generate_from_session(MemoryGenerationInput { + session_id: "active".into(), + messages: messages.clone(), + session_duration: Duration::from_secs(120), + idle_for: Duration::from_secs(120), + is_active: true, + rate_limit_remaining_percent: Some(80), + controls: MemoryThreadControls::default(), + }) + .await + .unwrap(); + assert_eq!( + active.suppression_reason(), + Some(SuppressionReason::SessionActive) + ); + + let generated = runtime + .generate_from_session(MemoryGenerationInput { + session_id: "eligible".into(), + messages, + session_duration: Duration::from_secs(120), + idle_for: Duration::from_secs(120), + is_active: false, + rate_limit_remaining_percent: Some(80), + controls: MemoryThreadControls::default(), + }) + .await + .unwrap(); + assert!(matches!( + generated.decision, + GenerationDecision::Allowed { .. } + )); + let artifact = generated.artifact.unwrap(); + assert!(artifact.entry.redaction_applied); + assert!(artifact + .content + .preferences + .iter() + .any(|value| value.contains(""))); + assert!(generated.path.unwrap().exists()); +} + +#[test] +fn redaction_masks_common_secret_shapes() { + let (out, changed) = redact_text("token=ghp_abcdefghijklmnopqrstuvwxyz password=hunter2"); + assert!(changed); + assert!(!out.contains("hunter2")); + assert!(!out.contains("ghp_abcdefghijklmnopqrstuvwxyz")); +} + +#[tokio::test] +async fn session_injects_memory_context_and_thread_can_suppress_it() { + let _guard = env_lock().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("RA_HOME", tmp.path()); + } + + let cfg = enabled_config(); + let cwd = tmp.path().join("project"); + std::fs::create_dir_all(&cwd).unwrap(); + let runtime = MemorySystem::from_config(&cfg) + .runtime_for_cwd(&cwd) + .unwrap(); + let artifact = MemoryArtifact::generated( + "session-1".into(), + runtime.store().cwd_hash().to_string(), + MemoryEntry::generated(false), + MemoryContent { + facts: vec!["Project convention: run cargo test --test memory_system.".into()], + ..MemoryContent::default() + }, + ); + runtime.store().save(&artifact).await.unwrap(); + let prompt = runtime.load_prompt().await.unwrap(); + + let seen = std::sync::Arc::new(Mutex::new(Vec::new())); + let session = + Session::new(Arc::new(SeenModel { seen: seen.clone() }), Vec::new()).with_cwd(&cwd); + session.set_memory_prompt(prompt).await; + session.prompt("first".to_string()).await.unwrap(); + + let first = seen.lock().unwrap()[0].clone(); + assert!(matches!( + &first[0], + Message::User { content } + if content.contains("# Local Memories") + && content.contains("Project convention: run cargo test --test memory_system.") + )); + + session.set_memory_use_enabled(false).await; + session.prompt("second".to_string()).await.unwrap(); + let second = seen.lock().unwrap()[1].clone(); + assert!(matches!( + &second[0], + Message::User { content } if !content.contains("# Local Memories") + )); +}