Skip to content

feat(memory): episodic memory — cross-session summaries + recall - #5974

Closed
tmartin2113 wants to merge 11 commits into
nearai:reborn-integrationfrom
tmartin2113:pr/episodic-memory
Closed

tmartin2113 wants to merge 11 commits into
nearai:reborn-integrationfrom
tmartin2113:pr/episodic-memory

Conversation

@tmartin2113

Copy link
Copy Markdown

Episodic memory — cross-session summaries + recall

Automatic cross-session continuity: distill each conversation into a durable, searchable summary when it ends, and silently inject a terse digest of recent ones into new conversations.

Two-channel recall

  • Push: a capped memory/recent.md digest (last N=5 / ~6000 chars, open-thread-weighted) injected into the system prompt (group-chat-excluded, graceful when absent).
  • Pull: per-conversation memory/sessions/YYYY-MM-DD-<id>.md files, searchable via the existing hybrid FTS+vector RRF search.

Write paths — all fail-soft, idempotent on conversation_id

  • SessionSummarizer (turns → structured JSON via the LLM) with a tolerant JSON parse + fallback.
  • SessionMemory::summarize_and_store — skip-trivial, idempotent, writes the session file + updates recent.md.
  • Auto-write on session idle-prune + a heartbeat/startup backstop sweep.

Memory never blocks a turn (log-and-continue everywhere). Cherry-picked from an integration branch; PR CI validates standalone.

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@ironloopai

ironloopai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

🔎 IronLoop Review Status

Head: 676ebad044c108b175873464b60ff25b01d03677
Result: Reviewer planning is blocked by the trusted agent configuration.
Next: Fix the target repository's .ironloop/agents.yaml and agent instruction files, then re-run @ironloopai review or update the pull request.
Updated: 2026-07-11T01:43:42.090Z

Stage Status Detail
received reached GitHub delivered the event to IronLoop.
admitted reached The event passed routing and authorization.
config blocked Unable to load trusted agent config from .ironloop/agents.yaml.
reviewer jobs pending No reviewer jobs were queued because configuration loading failed.
Configuration error

Message: Unable to load trusted agent config from .ironloop/agents.yaml.

Available commands
  • @ironloopai help
  • @ironloopai agents
  • @ironloopai review
  • @ironloopai review --agent <agent>
  • @ironloopai status
Run metadata

Origin: auto
Event: pull_request.opened
Config path: .ironloop/agents.yaml
Trusted ref: a492857b6ec11e09b7f22c0e3e309c00d6a3f526
Delivery: f13d8e90-7cc9-11f1-9944-79355f1c2ee7

@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: workspace Persistent memory / workspace scope: docs Documentation size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: new First-time contributor labels Jul 11, 2026
@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added episodic memory that summarizes completed conversations into durable workspace documents.
    • Recent conversation context is automatically recalled in new one-on-one conversations.
    • Added background recovery to capture summaries missed during normal session cleanup.
    • Memory updates are capped, deduplicated, and designed to avoid interrupting conversations.
  • Documentation

    • Added design, implementation, and workspace documentation for episodic memory behavior.

Walkthrough

Adds episodic memory for durable conversation summaries, a capped recent digest, prompt-time recall, idle-prune persistence, and startup/heartbeat backfill sweeps. Documentation and tests cover storage formats, parsing, capping, idempotency, prompt injection, and recovery behavior.

Changes

Episodic memory

Layer / File(s) Summary
Design and implementation plan
docs/superpowers/...
Documents summary formats, storage rules, prompt recall, idle-prune writes, and backstop sweeps.
Summary generation and storage
src/agent/session_memory.rs, src/agent/mod.rs
Adds structured summaries, tolerant LLM parsing, capped digest construction, durable workspace writes, sweep support, and tests.
Prompt recall and idle-prune integration
src/workspace/mod.rs, src/agent/session_manager.rs, src/workspace/README.md
Injects memory/recent.md into eligible prompts and schedules fail-soft summarization for pruned conversations.
Startup and heartbeat backstop
src/agent/agent_loop.rs, src/agent/heartbeat.rs
Creates session-memory dependencies and runs asynchronous 24-hour recovery sweeps during startup and heartbeat ticks.

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

Possibly related issues

Suggested labels: contributor: experienced

Sequence Diagram(s)

sequenceDiagram
  participant Agent
  participant SessionManager
  participant SessionMemory
  participant Workspace
  participant HeartbeatRunner

  Agent->>SessionManager: register SessionMemory
  SessionManager->>SessionMemory: summarize_and_store stale conversation
  SessionMemory->>Workspace: write session markdown and recent.md
  Workspace->>Agent: inject recent digest into new prompt
  HeartbeatRunner->>SessionMemory: sweep ended conversations
  SessionMemory->>Workspace: backfill missing summaries
Loading

Poem

A bunny stores each tale with care,
In markdown burrows, neat and fair.
Recent hops return at dawn,
While quiet threads are safely drawn.
The heartbeat checks what time may hide—
Memory keeps the past beside.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the feature well, but it omits required template sections like Linked Issue, Validation, and the impact/checklist sections. Add the template sections, especially an approved linked issue, validation steps, and the required impact/checklist entries.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately captures the main change: cross-session episodic memory summaries and recall.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agent/session_manager.rs (1)

355-403: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Idle-prune timestamps summaries with Utc::now() (prune time) while the backstop sweep uses the conversation's last_activity — this breaks the idempotency guarantee summarize_and_store relies on.

sessions_path's file stem is "{date}-{conversation_id}". If a stale session is pruned on a different calendar day than its last actual activity (e.g. pruned shortly after midnight for a conversation that ended just before), the idle-prune path and the backstop sweep will compute two different stems/paths for the same conversation_id. The file_exists check in summarize_and_store therefore won't find the "other" path's file, and both paths will independently summarize and write — producing a duplicate session file and a duplicate recent.md digest entry for the same conversation. This is a deterministic bug (not just a race), on top of the fact that concurrently-spawned tasks here (one tokio::spawn per stale conversation) also race on the shared recent.md update (see the related comment on session_memory.rs::summarize_and_store).

sess.last_active_at is already read a few lines above in this same locked block and is the correct source of truth to thread through instead of chrono::Utc::now().

🐛 Proposed fix — carry the session's actual last-active timestamp
-type StaleConversation = (String, String, Vec<(String, Option<String>)>);
+type StaleConversation = (String, String, Vec<(String, Option<String>)>, chrono::DateTime<chrono::Utc>);
...
-                    stale_thread_turns.push((thread.id.to_string(), channel, turns));
+                    stale_thread_turns.push((thread.id.to_string(), channel, turns, sess.last_active_at));
...
-            for (conversation_id, channel, turns) in stale_thread_turns {
+            for (conversation_id, channel, turns, ts) in stale_thread_turns {
                 let memory = Arc::clone(memory);
-                let ts = chrono::Utc::now();
                 tokio::spawn(async move {
                     memory
                         .summarize_and_store(&conversation_id, &channel, ts, &turns)
                         .await;
                 });
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/session_manager.rs` around lines 355 - 403, Use each stale
session’s actual last-activity timestamp when scheduling episodic summaries
instead of assigning chrono::Utc::now() at prune time. Extend StaleConversation
and the stale_thread_turns collection to carry sess.last_active_at (or the
corresponding per-conversation timestamp), then pass that timestamp to
summarize_and_store in the spawned task. Also avoid spawning concurrent
summaries that race on recent.md by processing the stale conversations
sequentially or otherwise serializing the shared update.
🧹 Nitpick comments (4)
docs/superpowers/plans/2026-07-09-episodic-memory.md (2)

27-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fix the markdownlint violations.

Add a language identifier to the fenced block at Line 27 and remove the spaces inside the code span at Line 204.

Also applies to: 204-204

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

In `@docs/superpowers/plans/2026-07-09-episodic-memory.md` at line 27, Fix the
markdownlint violations in the plan: add an appropriate language identifier to
the fenced code block around line 27, and remove the extra spaces inside the
inline code span around line 204.

Source: Linters/SAST tools


668-674: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the agent’s cheap_llm() accessor.

The plan reaches through self.deps.cheap_llm directly. Use agent.cheap_llm() so provider selection and fallback behavior remain centralized.

As per coding guidelines, code under src/agent/ must use agent.cheap_llm() rather than accessing deps.cheap_llm directly.

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

In `@docs/superpowers/plans/2026-07-09-episodic-memory.md` around lines 668 - 674,
Update the `Agent::new` session-memory initialization to obtain the model
through the agent’s `cheap_llm()` accessor instead of reading
`self.deps.cheap_llm` directly, while preserving the existing fallback behavior
and `SessionMemory` setup.

Source: Coding guidelines

docs/superpowers/specs/2026-07-09-episodic-memory-design.md (1)

83-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a language tag to the diagram fence.

Use ```text to keep markdownlint clean.

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

In `@docs/superpowers/specs/2026-07-09-episodic-memory-design.md` at line 83,
Update the diagram code fence in the episodic memory design document to specify
the text language by changing the opening fence to ```text, while preserving the
diagram content and closing fence.

Source: Linters/SAST tools

src/agent/session_memory.rs (1)

229-253: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

No cap on conversation size sent to the summarizer LLM call.

turns is serialized into convo unconditionally, regardless of conversation length. A very long-running thread (many turns before it goes idle) could exceed the model's context window, causing the complete() call to fail — silently swallowed by the fail-soft Err branch in summarize_and_store, so the conversation would simply never get summarized. Since the doc comment says this "mirrors ContextCompactor::generate_summary", worth confirming whether that reference implementation applies any truncation/windowing that should be mirrored here too.

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

In `@src/agent/session_memory.rs` around lines 229 - 253, The summarize method
sends the entire turns history to the LLM without limiting context size. Mirror
the truncation or windowing behavior used by ContextCompactor::generate_summary
when building convo, preserving the most relevant recent turns within a safe
model-context budget before calling self.complete. Verify summarize_and_store
continues handling the bounded result correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-07-09-episodic-memory.md`:
- Around line 715-717: Update sweep to use the success/created boolean returned
by summarize_and_store, incrementing n only when the operation returns true;
ensure skipped trivial conversations, failures, and duplicates return false and
are not counted.
- Around line 412-417: Bound the conversation payload constructed in the
turn-building loop by reusing existing compaction/truncation logic where
available, or by enforcing maximum turn and byte limits before appending to
`convo`; stop or truncate once either limit is reached so the summarizer request
stays within provider and cost constraints.
- Around line 695-696: The SessionMemory::sweep design must not use updated_at
alone to identify completed conversations. Change the conversation enumeration
to require an explicit ended/idle state, or exclude conversations currently
marked active, before calling summarize_and_store; ensure idle-prune can still
create the final summary for active sessions.
- Around line 530-532: Make the idempotency guard in the file-writing flow
atomic: replace the separate self.file_exists(&path).await check and subsequent
write with an atomic create-if-absent operation, or protect the check-and-write
sequence with a per-conversation lock/transaction. Ensure concurrent session-end
and sweep tasks cannot both summarize or overwrite recent.md.
- Around line 662-664: Replace the untracked tokio::spawn in summarize_and_store
scheduling with a bounded background worker mechanism using a semaphore or
queue, track in-flight conversation IDs to prevent duplicate heartbeat/prune
work, and support cancellation and graceful shutdown so each conversation
produces at most one active LLM request.
- Around line 353-360: Update RawSummary deserialization and the persistence
path to enforce hard bounds on title, gist, every string in decisions,
open_threads, and user_notes, plus the total serialized summary size before
writing session files or recent-prompt content. Truncate or reject oversized
values and lists using shared constants, and ensure limits are applied before
any accumulation or serialization.
- Around line 614-623: The episodic recall path in the non-group-chat branch
injects recent.md without safety validation, delimiting, or explicit read-error
handling. Before appending in the recent-memory block, scan the content using
the applicable memory/injection safety path and only include approved content
within clearly labeled untrusted-memory delimiters; treat NotFound as absence
but explicitly handle or log other read failures.
- Around line 556-562: The workspace helpers file_exists and read_or_empty must
distinguish missing files from permission, corruption, and I/O errors. Handle
workspace.read errors explicitly: return false only for NotFound, log other
file_exists failures, and have read_or_empty preserve the existing
digest/content on non-NotFound failures instead of returning an empty string;
avoid unwrap_or_default and equivalent silent error handling.
- Around line 149-161: Update the serialization logic containing the
conversation Markdown format! block to build frontmatter from a metadata struct
or map and serialize it with a YAML serializer, rather than interpolating
self.conversation_id, self.channel, self.timestamp, and self.title directly.
Explicitly delimit or escape the Markdown body fields, including self.gist and
bullet sections, so embedded newlines, headings, colons, or --- cannot corrupt
frontmatter or document structure.
- Around line 545-552: Update the session persistence flow around the
session-file write and recent digest update so a failed RECENT_PATH write
remains retryable rather than being treated as complete. Add an atomic or
serialized read-modify-write mechanism for build_recent updates, and ensure
retries can repair an existing stale digest instead of being skipped by
idempotency checks; preserve clear warning/error handling for failures.
- Around line 654-665: Add a caller-level regression test for the idle-prune
loop’s session-memory wiring, replacing the “no new unit test” or
skip-regression guidance. Mock or spy on summarize_and_store to capture and
assert the computed conversation_id, channel, timestamp, and mapped turn pairs
for a stale thread with at least one turn; ensure the test exercises the wrapper
around OnSessionEnd and spawn behavior rather than only testing
summarize_and_store directly.
- Around line 134-137: Update `Session::file_stem` so `conversation_id` cannot
introduce path separators or traversal, using validation or a safe encoding, and
make the returned filename stable solely from the conversation ID rather than
the date. Preserve the formatted date as session metadata instead of including
it in the idempotency key.
- Around line 742-747: Update the “Step 1: Full lint + test gate” command so
Clippy failures cannot be masked by the grep pipeline. Add `set -o pipefail`
before the pipeline and retain the intended “clippy clean” fallback, or run
`cargo clippy` separately while explicitly preserving and checking its exit
status.

In `@src/agent/heartbeat.rs`:
- Around line 534-536: Move the `// arch-exempt:` justification comment in
`spawn_heartbeat` to immediately precede the
`#[allow(clippy::too_many_arguments)]` attribute, leaving the attribute directly
above the function signature.

In `@src/agent/session_manager.rs`:
- Around line 63-69: Add a public getter beside
SessionManager::set_session_memory that returns the already-wired
Arc<SessionMemory>, preserving the first instance rather than creating another.
Update agent_loop.rs::run to retrieve and reuse this instance for heartbeat
wiring, and handle the not-yet-initialized case consistently with the existing
OnceLock behavior.

In `@src/agent/session_memory.rs`:
- Around line 82-99: Update split_entries to discard all content before the
first "### " entry marker, including blank lines following "# Recent
conversations"; only append lines to cur after the first entry has started.
Preserve the existing header skip and entry-boundary behavior while ensuring no
leading newline is included in the first parsed entry.
- Around line 294-332: The RECENT_PATH read/build/write sequence in
summarize_and_store races with prune_stale_sessions and startup sweep across
separate SessionMemory instances. Serialize all recent.md updates with a
process-wide/shared lock, or replace the sequence with an atomic update
mechanism, and ensure every code path that modifies RECENT_PATH uses the same
coordination.

In `@src/workspace/mod.rs`:
- Around line 1810-1821: Add RECENT_PATH to SYSTEM_PROMPT_FILES so writes to
memory/recent.md are validated by reject_if_injected before injection. Update
the episodic recall logic in the system-prompt construction to read using
RECENT_PATH instead of the hardcoded "memory/recent.md" string.

---

Outside diff comments:
In `@src/agent/session_manager.rs`:
- Around line 355-403: Use each stale session’s actual last-activity timestamp
when scheduling episodic summaries instead of assigning chrono::Utc::now() at
prune time. Extend StaleConversation and the stale_thread_turns collection to
carry sess.last_active_at (or the corresponding per-conversation timestamp),
then pass that timestamp to summarize_and_store in the spawned task. Also avoid
spawning concurrent summaries that race on recent.md by processing the stale
conversations sequentially or otherwise serializing the shared update.

---

Nitpick comments:
In `@docs/superpowers/plans/2026-07-09-episodic-memory.md`:
- Line 27: Fix the markdownlint violations in the plan: add an appropriate
language identifier to the fenced code block around line 27, and remove the
extra spaces inside the inline code span around line 204.
- Around line 668-674: Update the `Agent::new` session-memory initialization to
obtain the model through the agent’s `cheap_llm()` accessor instead of reading
`self.deps.cheap_llm` directly, while preserving the existing fallback behavior
and `SessionMemory` setup.

In `@docs/superpowers/specs/2026-07-09-episodic-memory-design.md`:
- Line 83: Update the diagram code fence in the episodic memory design document
to specify the text language by changing the opening fence to ```text, while
preserving the diagram content and closing fence.

In `@src/agent/session_memory.rs`:
- Around line 229-253: The summarize method sends the entire turns history to
the LLM without limiting context size. Mirror the truncation or windowing
behavior used by ContextCompactor::generate_summary when building convo,
preserving the most relevant recent turns within a safe model-context budget
before calling self.complete. Verify summarize_and_store continues handling the
bounded result correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b546db4c-f007-4085-8ce7-f8c2ddf458b4

📥 Commits

Reviewing files that changed from the base of the PR and between a492857 and 676ebad.

📒 Files selected for processing (9)
  • docs/superpowers/plans/2026-07-09-episodic-memory.md
  • docs/superpowers/specs/2026-07-09-episodic-memory-design.md
  • src/agent/agent_loop.rs
  • src/agent/heartbeat.rs
  • src/agent/mod.rs
  • src/agent/session_manager.rs
  • src/agent/session_memory.rs
  • src/workspace/README.md
  • src/workspace/mod.rs

Comment on lines +134 to +137
/// `YYYY-MM-DD-<conversation_id>` — the per-session file stem.
pub fn file_stem(&self) -> String {
format!("{}-{}", self.timestamp.format("%Y-%m-%d"), self.conversation_id)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Make the session filename safe and stable per conversation ID.

conversation_id is inserted directly into a path, so separators or .. can escape memory/sessions/. Also, the date is part of the idempotency path; the same conversation summarized across midnight can create multiple files. Validate/encode the identifier and use a stable conversation-keyed path, keeping the date as metadata.

Suggested direction
- format!("{}-{}", self.timestamp.format("%Y-%m-%d"), self.conversation_id)
+ format!("{}-{}", self.timestamp.format("%Y-%m-%d"), self.conversation_id.safe_file_component())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-09-episodic-memory.md` around lines 134 - 137,
Update `Session::file_stem` so `conversation_id` cannot introduce path
separators or traversal, using validation or a safe encoding, and make the
returned filename stable solely from the conversation ID rather than the date.
Preserve the formatted date as session metadata instead of including it in the
idempotency key.

Source: Coding guidelines

Comment on lines +149 to +161
format!(
"---\nconversation_id: {}\nchannel: {}\ntimestamp: {}\ntitle: {}\n---\n\n\
# {}\n\n{}\n\n## Decisions\n{}\n## Open threads\n{}\n## User notes\n{}",
self.conversation_id,
self.channel,
self.timestamp.to_rfc3339(),
self.title,
self.title,
self.gist,
Self::bullets(&self.decisions),
Self::bullets(&self.open_threads),
Self::bullets(&self.user_notes),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize frontmatter instead of interpolating unescaped values.

Titles, channels, and model-generated text can contain newlines, :, ---, or markdown headings. Direct format! interpolation can corrupt YAML frontmatter and the searchable document format. Use a YAML serializer for metadata and explicitly delimit/escape the markdown body.

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

In `@docs/superpowers/plans/2026-07-09-episodic-memory.md` around lines 149 - 161,
Update the serialization logic containing the conversation Markdown format!
block to build frontmatter from a metadata struct or map and serialize it with a
YAML serializer, rather than interpolating self.conversation_id, self.channel,
self.timestamp, and self.title directly. Explicitly delimit or escape the
Markdown body fields, including self.gist and bullet sections, so embedded
newlines, headings, colons, or --- cannot corrupt frontmatter or document
structure.

Comment on lines +353 to +360
#[derive(Deserialize, Default)]
struct RawSummary {
#[serde(default)] title: String,
#[serde(default)] gist: String,
#[serde(default)] decisions: Vec<String>,
#[serde(default)] open_threads: Vec<String>,
#[serde(default)] user_notes: Vec<String>,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound parsed summary fields before persistence.

#[serde(default)] handles missing fields but does not limit list length or string size. A large model response can create oversized session files and recent-prompt content. Enforce limits for title, gist, each list, and total serialized size before writing.

As per coding guidelines, user-controlled inputs and accumulators require hard resource limits.

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

In `@docs/superpowers/plans/2026-07-09-episodic-memory.md` around lines 353 - 360,
Update RawSummary deserialization and the persistence path to enforce hard
bounds on title, gist, every string in decisions, open_threads, and user_notes,
plus the total serialized summary size before writing session files or
recent-prompt content. Truncate or reject oversized values and lists using
shared constants, and ensure limits are applied before any accumulation or
serialization.

Source: Coding guidelines

Comment on lines +412 to +417
let mut convo = String::new();
for (u, a) in turns {
convo.push_str(&format!("User: {u}\n"));
if let Some(a) = a {
convo.push_str(&format!("Assistant: {a}\n"));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound the conversation payload sent to the summarizer.

This loop concatenates every turn into one unbounded String. Long conversations can exceed provider context limits or create expensive background requests. Reuse compaction/truncation logic or enforce a maximum number of turns and bytes before building the request.

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

In `@docs/superpowers/plans/2026-07-09-episodic-memory.md` around lines 412 - 417,
Bound the conversation payload constructed in the turn-building loop by reusing
existing compaction/truncation logic where available, or by enforcing maximum
turn and byte limits before appending to `convo`; stop or truncate once either
limit is reached so the summarizer request stays within provider and cost
constraints.

Source: Coding guidelines

Comment on lines +530 to +532
// idempotency: if a file already exists, skip (per confirm-first read semantics)
if self.file_exists(&path).await {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make idempotency atomic.

The existence check is a read-then-write race: concurrent session-end and sweep tasks can both observe absence, summarize twice, and overwrite/duplicate recent.md. Use an atomic create-if-absent operation or a per-conversation lock/transaction.

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

In `@docs/superpowers/plans/2026-07-09-episodic-memory.md` around lines 530 - 532,
Make the idempotency guard in the file-writing flow atomic: replace the separate
self.file_exists(&path).await check and subsequent write with an atomic
create-if-absent operation, or protect the check-and-write sequence with a
per-conversation lock/transaction. Ensure concurrent session-end and sweep tasks
cannot both summarize or overwrite recent.md.

Comment thread src/agent/heartbeat.rs
Comment on lines +534 to 536
#[allow(clippy::too_many_arguments)]
// arch-exempt: too_many_args, heartbeat spawn threads optional deps individually; a bundle struct is a future cleanup, plan 2026-07-09-episodic-memory
pub fn spawn_heartbeat(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

arch-exempt comment is placed after #[allow(clippy::too_many_arguments)] instead of directly above it.

Per the retrieved learning/coding guideline, the exemption comment must sit immediately above the #[allow(...)] attribute. Here it's between the attribute and the function signature.

✏️ Fix ordering
-#[allow(clippy::too_many_arguments)]
-// arch-exempt: too_many_args, heartbeat spawn threads optional deps individually; a bundle struct is a future cleanup, plan 2026-07-09-episodic-memory
+// arch-exempt: too_many_args, heartbeat spawn threads optional deps individually; a bundle struct is a future cleanup, plan 2026-07-09-episodic-memory
+#[allow(clippy::too_many_arguments)]
 pub fn spawn_heartbeat(

Based on learnings, an "architecture exemption" comment must be placed immediately above the #[allow(...)] attribute explaining the justification. As per coding guidelines, "place // arch-exempt: ... directly above it."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[allow(clippy::too_many_arguments)]
// arch-exempt: too_many_args, heartbeat spawn threads optional deps individually; a bundle struct is a future cleanup, plan 2026-07-09-episodic-memory
pub fn spawn_heartbeat(
// arch-exempt: too_many_args, heartbeat spawn threads optional deps individually; a bundle struct is a future cleanup, plan 2026-07-09-episodic-memory
#[allow(clippy::too_many_arguments)]
pub fn spawn_heartbeat(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/heartbeat.rs` around lines 534 - 536, Move the `// arch-exempt:`
justification comment in `spawn_heartbeat` to immediately precede the
`#[allow(clippy::too_many_arguments)]` attribute, leaving the attribute directly
above the function signature.

Sources: Coding guidelines, Learnings

Comment on lines +63 to +69
/// Wire the episodic-memory coordinator. Callable on a shared `Arc`
/// (takes `&self`); the first call wins and later calls are ignored.
pub fn set_session_memory(&self, memory: Arc<SessionMemory>) {
// Ignore a redundant second wiring — the store is idempotent anyway.
let _ = self.session_memory.set(memory);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Consider exposing a getter so callers can reuse the wired SessionMemory instance instead of constructing a second one.

Agent::new builds a SessionMemory and wires it here via set_session_memory, but there's no way for agent_loop.rs::run() to retrieve it later — which is why it currently constructs a second, independent SessionMemory for heartbeat wiring (see the corresponding comment on agent_loop.rs). A simple accessor avoids that duplication and is a prerequisite for making any future per-instance lock on the recent.md critical section actually effective across both call sites.

♻️ Proposed getter
+    /// Get the wired episodic-memory coordinator, if any.
+    pub fn session_memory(&self) -> Option<Arc<SessionMemory>> {
+        self.session_memory.get().cloned()
+    }
+
     /// Get or create a session for a user.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Wire the episodic-memory coordinator. Callable on a shared `Arc`
/// (takes `&self`); the first call wins and later calls are ignored.
pub fn set_session_memory(&self, memory: Arc<SessionMemory>) {
// Ignore a redundant second wiring — the store is idempotent anyway.
let _ = self.session_memory.set(memory);
}
/// Wire the episodic-memory coordinator. Callable on a shared `Arc`
/// (takes `&self`); the first call wins and later calls are ignored.
pub fn set_session_memory(&self, memory: Arc<SessionMemory>) {
// Ignore a redundant second wiring — the store is idempotent anyway.
let _ = self.session_memory.set(memory);
}
/// Get the wired episodic-memory coordinator, if any.
pub fn session_memory(&self) -> Option<Arc<SessionMemory>> {
self.session_memory.get().cloned()
}
/// Get or create a session for a user.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/session_manager.rs` around lines 63 - 69, Add a public getter
beside SessionManager::set_session_memory that returns the already-wired
Arc<SessionMemory>, preserving the first instance rather than creating another.
Update agent_loop.rs::run to retrieve and reuse this instance for heartbeat
wiring, and handle the not-yet-initialized case consistently with the existing
OnceLock behavior.

Comment on lines +82 to +99
fn split_entries(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for line in body.lines() {
if line.starts_with("### ") && !cur.trim().is_empty() {
out.push(std::mem::take(&mut cur));
}
if line.starts_with("# Recent conversations") {
continue;
}
cur.push_str(line);
cur.push('\n');
}
if !cur.trim().is_empty() {
out.push(cur);
}
out
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

split_entries leaks the header's blank line into the first parsed entry, causing whitespace to accumulate on every round-trip.

After the "# Recent conversations" line is skipped via continue, the following blank line from RECENT_HEADER ("\n\n") falls through into cur before the first "### " line is seen. Since cur.trim().is_empty() is still true at that point, the push-on-boundary branch never fires, so the leading "\n" sticks to whatever entry currently sits right after the header. On the next build_recent call that entry is written back with e.trim_end() (which only trims the end), so the extra leading newline (and, on subsequent rounds, extra blank lines) is retained and grows with each round-trip that entry survives — silently eating into the max_chars budget and causing premature eviction of otherwise-wanted entries.

🐛 Proposed fix — don't accumulate anything before the first entry marker
 fn split_entries(body: &str) -> Vec<String> {
     let mut out = Vec::new();
     let mut cur = String::new();
+    let mut started = false;
     for line in body.lines() {
-        if line.starts_with("### ") && !cur.trim().is_empty() {
-            out.push(std::mem::take(&mut cur));
-        }
         if line.starts_with("# Recent conversations") {
             continue;
         }
+        if line.starts_with("### ") {
+            if started && !cur.trim().is_empty() {
+                out.push(std::mem::take(&mut cur));
+            }
+            started = true;
+        }
+        if !started {
+            continue;
+        }
         cur.push_str(line);
         cur.push('\n');
     }
     if !cur.trim().is_empty() {
         out.push(cur);
     }
     out
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn split_entries(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for line in body.lines() {
if line.starts_with("### ") && !cur.trim().is_empty() {
out.push(std::mem::take(&mut cur));
}
if line.starts_with("# Recent conversations") {
continue;
}
cur.push_str(line);
cur.push('\n');
}
if !cur.trim().is_empty() {
out.push(cur);
}
out
}
fn split_entries(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut started = false;
for line in body.lines() {
if line.starts_with("# Recent conversations") {
continue;
}
if line.starts_with("### ") {
if started && !cur.trim().is_empty() {
out.push(std::mem::take(&mut cur));
}
started = true;
}
if !started {
continue;
}
cur.push_str(line);
cur.push('\n');
}
if !cur.trim().is_empty() {
out.push(cur);
}
out
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/session_memory.rs` around lines 82 - 99, Update split_entries to
discard all content before the first "### " entry marker, including blank lines
following "# Recent conversations"; only append lines to cur after the first
entry has started. Preserve the existing header skip and entry-boundary behavior
while ensuring no leading newline is included in the first parsed entry.

Comment on lines +294 to +332
pub async fn summarize_and_store(
&self,
conversation_id: &str,
channel: &str,
timestamp: DateTime<Utc>,
turns: &[(String, Option<String>)],
) {
// Skip trivial: nothing worth remembering if every user turn is empty.
if turns.iter().all(|(u, _)| u.trim().is_empty()) {
return;
}
let stem = format!("{}-{}", timestamp.format("%Y-%m-%d"), conversation_id);
let path = sessions_path(&stem);
// Idempotency: a file already existing for this conversation means we
// already summarized it — skip (guards session-end vs backstop races).
if self.file_exists(&path).await {
return;
}
let summary = match self
.summarizer
.summarize(conversation_id, channel, timestamp, turns)
.await
{
Ok(s) => s,
Err(e) => {
tracing::warn!("session summary failed for {conversation_id}: {e}");
return;
}
};
if let Err(e) = self.workspace.write(&path, &summary.to_markdown()).await {
tracing::warn!("write session file failed for {conversation_id}: {e}");
return;
}
let existing = self.read_or_empty(RECENT_PATH).await;
let recent = build_recent(&summary.digest_entry(), &existing, 5, 6000);
if let Err(e) = self.workspace.write(RECENT_PATH, &recent).await {
tracing::warn!("update recent.md failed for {conversation_id}: {e}");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## session_memory.rs outline\n'
ast-grep outline src/agent/session_memory.rs --view expanded | sed -n '1,220p'

printf '\n## session_manager.rs outline\n'
ast-grep outline src/agent/session_manager.rs --view expanded | sed -n '1,260p'

printf '\n## agent_loop.rs outline\n'
ast-grep outline src/agent/agent_loop.rs --view expanded | sed -n '1,260p'

printf '\n## search for RECENT_PATH and recent.md writers\n'
rg -n "RECENT_PATH|recent\.md|build_recent\(" src/agent src/workspace crates -S

printf '\n## search for SessionMemory construction and sharing\n'
rg -n "SessionMemory::new|SessionMemory \{" src/agent -S

printf '\n## inspect workspace append docs / implementation references\n'
rg -n "Uses a read-modify-write pattern that is not concurrency-safe|append\(" src/workspace -S

Repository: nearai/ironclaw

Length of output: 17492


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## session_memory.rs around summarize_and_store and sweep\n'
sed -n '274,360p' src/agent/session_memory.rs | cat -n

printf '\n## session_memory.rs around read_or_empty / tests\n'
sed -n '389,635p' src/agent/session_memory.rs | cat -n

printf '\n## session_manager.rs around prune_stale_sessions\n'
sed -n '321,420p' src/agent/session_manager.rs | cat -n

printf '\n## agent_loop.rs around SessionMemory construction sites\n'
sed -n '560,620p' src/agent/agent_loop.rs | cat -n
printf '\n---\n'
sed -n '1300,1355p' src/agent/agent_loop.rs | cat -n

printf '\n## workspace write / append behavior\n'
sed -n '1180,1215p' src/workspace/mod.rs | cat -n

Repository: nearai/ironclaw

Length of output: 28651


Serialize memory/recent.md updates
summary_and_store does a read → build → write on RECENT_PATH, and prune_stale_sessions plus the startup sweep can run it concurrently. A per-instance mutex won’t close the gap because Agent::new creates separate SessionMemory instances for the idle-prune and heartbeat paths. Use a shared lock for this file or make the update atomic.

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

In `@src/agent/session_memory.rs` around lines 294 - 332, The RECENT_PATH
read/build/write sequence in summarize_and_store races with prune_stale_sessions
and startup sweep across separate SessionMemory instances. Serialize all
recent.md updates with a process-wide/shared lock, or replace the sequence with
an atomic update mechanism, and ensure every code path that modifies RECENT_PATH
uses the same coordination.

Comment thread src/workspace/mod.rs
Comment on lines +1810 to +1821
// Episodic recall: inject the recent-conversations digest (memory/recent.md).
// This is personal continuity context, so it is excluded in group chats the
// same way MEMORY.md is. Missing/empty is graceful — the section is omitted.
if !is_group_chat
&& let Ok(doc) = self.read("memory/recent.md").await
{
let recent = doc.content.trim();
if !recent.is_empty() {
parts.push(recent.to_string());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n -B2 -A20 'fn is_system_prompt_file|SYSTEM_PROMPT_FILES' src/workspace

Repository: nearai/ironclaw

Length of output: 4599


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the write path and recent-memory constants to see whether memory/recent.md
# is covered by the same prompt-injection scan as other system-prompt files.
rg -n -A80 -B20 'reject_if_injected|is_system_prompt_file|RECENT_PATH|MEMORY' src/workspace/mod.rs src/agent/session_memory.rs

Repository: nearai/ironclaw

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact definitions for the paths used by workspace injection and recent recall.
cat -n src/agent/session_memory.rs | sed -n '1,220p'

Repository: nearai/ironclaw

Length of output: 8639


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the workspace write logic around prompt-injection scanning and the new recall injection block.
cat -n src/workspace/mod.rs | sed -n '150,240p;1680,1845p'

Repository: nearai/ironclaw

Length of output: 11816


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any other references to memory/recent.md or RECENT_PATH in the workspace.
rg -n 'memory/recent\.md|RECENT_PATH|recent\.md' src

Repository: nearai/ironclaw

Length of output: 2343


Add memory/recent.md to the prompt-injection scan. memory/recent.md is injected into the system prompt, but it is not in SYSTEM_PROMPT_FILES, so writes to it skip reject_if_injected. Any malicious content that lands in the digest will be replayed into later conversations. Reuse RECENT_PATH here instead of hardcoding the path.

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

In `@src/workspace/mod.rs` around lines 1810 - 1821, Add RECENT_PATH to
SYSTEM_PROMPT_FILES so writes to memory/recent.md are validated by
reject_if_injected before injection. Update the episodic recall logic in the
system-prompt construction to read using RECENT_PATH instead of the hardcoded
"memory/recent.md" string.

@serrrfirat

Copy link
Copy Markdown
Collaborator

Thank you for the thoughtful episodic-memory design and for focusing on cross-session continuity.\n\nWe are closing this legacy implementation because the Reborn memory lifecycle is now represented by #5327, which provides host-managed long-term/thread-local retrieval, safe prompt injection, and idempotent after-turn interaction recording.\n\nReborn references:\n- https://github.com/nearai/ironclaw/pull/5327\n- https://github.com/nearai/ironclaw/blob/main/crates/ironclaw_memory/src/service.rs\n- https://github.com/nearai/ironclaw/blob/main/crates/ironclaw_memory_native/src/service.rs\n\nDurable distilled on-run-end summaries are explicitly tracked as the deferred Phase 3 of #5327. That is the right place to continue this idea rather than porting the v1 session-prune and heartbeat implementation. We would be very happy to have you contribute to that Reborn follow-up, with this PR preserved as design context and attribution.\n\nThis is an architecture-transition closure, not a reflection on the quality or value of your contribution.

@serrrfirat serrrfirat closed this Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: new First-time contributor risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: docs Documentation scope: workspace Persistent memory / workspace size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants