Skip to content

feat(memory): per-session memory controls - #101

Merged
DevFlex-AI merged 2 commits into
devfrom
memory-session-controls
Jul 31, 2026
Merged

feat(memory): per-session memory controls#101
DevFlex-AI merged 2 commits into
devfrom
memory-session-controls

Conversation

@DevFlex-AI

@DevFlex-AI DevFlex-AI commented Jul 31, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #100

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds Codex-style per-session memory controls on top of the existing project memory feature: a session can now opt out of USING saved memories (injection and recall) and/or of CONTRIBUTING to future memory generation. Both default to on whenever memory is enabled.

The controls live in the session's metadata record (the same per-session flag store the /sandbox toggle uses) under memoryUse / memoryContribute, with helpers in the new @opencode-ai/memory/controls module. Only an explicit false opts a session out, so existing sessions keep full memory behavior:

/** True when the session may use saved memories: index injection and targeted recall. */
export function use(metadata: Record<string, unknown> | null | undefined) {
  return metadata?.[USE] !== false
}

/** True when the session may contribute to future memory generation: turn-close capture and saves. */
export function contribute(metadata: Record<string, unknown> | null | undefined) {
  return metadata?.[CONTRIBUTE] !== false
}

Enforcement follows the existing pipeline shape without restructuring it:

  • Generation: MemoryHost.close (the turn-close capture hook in packages/opencode/src/memory/host.ts) returns before MemoryTurn.close when the session opted out of contribution, and the memory_save tool reports the session as off instead of writing.
  • Injection: the V2 runner (packages/core/src/session/runner/llm.ts) drops the core/memory system-context source (new SystemContext.omit) before the context epoch snapshots the baseline, and the memory_recall tool reports the session as off instead of recalling. The tools read SessionTable.metadata the same way the bash tool reads the sandbox flag.

TUI surface: the /memory dialog gains two "Session" toggle rows with checkbox-style state that flip in place (persisted via session.update metadata, mirroring the sandbox toggle), and /memory use on|off / /memory contribute on|off typed commands are added to the shared command catalog and parser.

How did you verify your code works?

  • bun typecheck in packages/memory, packages/core, packages/opencode, and packages/tui (all clean).
  • bun test in packages/memory (168 pass, including new parser fixtures for use/contribute and a new controls.test.ts).
  • bun test test/system-context in packages/core (29 pass) and bun test test/memory in packages/opencode (4 pass).

Screenshots / recordings

TUI dialog change: two toggle rows ("Use saved memories in this session" / "Save new memories from this session") appear at the top of the /memory dialog when a session is active; no recording available from this environment.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

View with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is enabled.

Summary by CodeRabbit

  • New Features
    • Added session-level controls for enabling or disabling memory use and memory contributions.
    • Added /memory use on|off and /memory contribute on|off commands.
    • Memory settings can be changed from the memory help dialog, with status feedback and error notifications.
  • Bug Fixes
    • Disabled memory settings now prevent recalling, saving, or generating memory for the selected session.
    • Added validation and usage guidance for incomplete or invalid memory commands.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/adevloper152s-projects?upgradeToPro=build-rate-limit

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@DevFlex-AI, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c0692b3-c205-4e76-8591-daf796ec0ad0

📥 Commits

Reviewing files that changed from the base of the PR and between 3d464f0 and 3185d46.

📒 Files selected for processing (1)
  • packages/tui/src/component/dialog-memory.tsx
📝 Walkthrough

Walkthrough

Per-session memory controls now support use and contribute toggles. Metadata controls memory context loading, recall, saving, and turn-close processing. The TUI exposes session-scoped toggles and commands.

Changes

Per-session memory controls

Layer / File(s) Summary
Control and command contracts
packages/memory/src/controls.ts, packages/memory/src/commands.ts, packages/memory/src/index.ts, packages/memory/package.json, packages/memory/test/*
Memory use and contribution default to enabled. The command parser accepts use on|off and contribute on|off, with validation and test coverage.
Core memory enforcement
packages/core/src/system-context/*, packages/core/src/session/runner/llm.ts, packages/core/src/tool/memory-recall.ts, packages/core/src/tool/memory-save.ts
Session metadata controls memory context injection, recall results, and memory-save behavior.
Turn-close contribution enforcement
packages/opencode/src/memory/host.ts
Turn-close memory processing stops when session contribution is disabled.
TUI control integration
packages/tui/src/component/dialog-memory.tsx, packages/tui/src/component/prompt/index.tsx, packages/tui/src/feature-plugins/system/memory.tsx, packages/tui/src/util/memory-command.ts
The TUI reads session metadata, updates control flags, shows status toasts, and supports session-scoped toggle commands.

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

Possibly related PRs

Suggested labels: needs:issue

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: per-session memory controls.
Description check ✅ Passed The description follows the template, explains the implementation, documents verification, and identifies the UI limitation.
Linked Issues check ✅ Passed The changes implement per-session use and contribute controls, defaults, metadata persistence, TUI toggles, and commands required by issue [#100].
Out of Scope Changes check ✅ Passed The changes are limited to implementing and testing the per-session memory controls described in issue [#100].
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch memory-session-controls

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

@github-actions

Copy link
Copy Markdown

The following comment was made by an LLM, it may be inaccurate:

@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: 2

🧹 Nitpick comments (1)
packages/tui/src/component/dialog-memory.tsx (1)

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

Consider centralizing the metadata-merge logic for memory controls.

The { ...meta, [key]: value } merge pattern for MemoryControls.USE/CONTRIBUTE is duplicated here and in packages/tui/src/util/memory-command.ts (Lines 84-99). Move this into a shared helper in packages/memory/src/controls.ts (for example MemoryControls.merge(metadata, key, value)), so both call sites share one implementation and any future fix, such as the refetch-before-write change above, applies in one place.

As per coding guidelines, "Keep code in one function unless it is composable or reusable; do not extract single-use helpers unless they hide a genuinely complex boundary or have a clear independent concept" — this logic is used in two places, so extraction is warranted.

🤖 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 `@packages/tui/src/component/dialog-memory.tsx` around lines 120 - 127,
Centralize the memory metadata merge used by toggle and command flows by adding
a reusable helper under MemoryControls in controls.ts, such as merge(metadata,
key, value), that preserves existing metadata while updating the selected
control. Replace the inline merge logic in toggle and the corresponding logic in
memory-command.ts with this shared helper so future write-related fixes apply
consistently.

Source: Coding guidelines

🤖 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 `@packages/core/src/session/runner/llm.ts`:
- Around line 172-194: Update the session metadata update flow to reset the
context epoch whenever memory-control metadata changes, so subsequent
preparation rebuilds the baseline with the current setting. Locate the metadata
update handler used by initialize and ensure it invalidates the existing epoch
before prepare reuses its stored baseline; preserve existing behavior for
unrelated metadata updates.

In `@packages/tui/src/component/dialog-memory.tsx`:
- Around line 120-127: Update toggle in dialog-memory.tsx to fetch the current
session with sdk.client.session.get immediately before
sdk.client.session.update, and build the metadata payload from that fresh
session metadata instead of the cached metadata() snapshot. Preserve the
existing key flip and error-toast behavior while narrowing the staleness window.

---

Nitpick comments:
In `@packages/tui/src/component/dialog-memory.tsx`:
- Around line 120-127: Centralize the memory metadata merge used by toggle and
command flows by adding a reusable helper under MemoryControls in controls.ts,
such as merge(metadata, key, value), that preserves existing metadata while
updating the selected control. Replace the inline merge logic in toggle and the
corresponding logic in memory-command.ts with this shared helper so future
write-related fixes apply consistently.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b57b317b-5686-42a1-b989-920d6945823e

📥 Commits

Reviewing files that changed from the base of the PR and between a06cf86 and 3d464f0.

📒 Files selected for processing (17)
  • packages/core/src/session/runner/llm.ts
  • packages/core/src/system-context/builtins.ts
  • packages/core/src/system-context/index.ts
  • packages/core/src/tool/memory-recall.ts
  • packages/core/src/tool/memory-save.ts
  • packages/memory/package.json
  • packages/memory/src/commands.ts
  • packages/memory/src/controls.ts
  • packages/memory/src/index.ts
  • packages/memory/test/command-cases.json
  • packages/memory/test/commands.test.ts
  • packages/memory/test/controls.test.ts
  • packages/opencode/src/memory/host.ts
  • packages/tui/src/component/dialog-memory.tsx
  • packages/tui/src/component/prompt/index.tsx
  • packages/tui/src/feature-plugins/system/memory.tsx
  • packages/tui/src/util/memory-command.ts

Comment on lines +172 to +194
// Sessions that opted out of memory use via the /memory controls drop the injected
// memory source before the context epoch snapshots the baseline.
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
Effect.all(
[
systemContext.load(),
skillGuidance.load(agent),
referenceGuidance.load(),
db
.select({ metadata: SessionTable.metadata })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie),
],
{ concurrency: "unbounded" },
).pipe(
Effect.map(([registered, skills, references, row]) => {
const combined = SystemContext.combine([registered, skills, references])
if (MemoryControls.use(row?.metadata)) return combined
return SystemContext.omit(combined, SystemContextBuiltIns.memoryKey)
}),
)

@coderabbitai coderabbitai Bot Jul 31, 2026

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the epoch implementation and inspect its initialization and persistence behavior.
fd -t f -e ts -e tsx . packages/core/src/session | while IFS= read -r file; do
  if rg -q '\bSessionContextEpoch\b' "$file"; then
    ast-grep outline "$file" --items all
    rg -n -C 8 '\b(SessionContextEpoch|initialize|prepare|baseline|metadata)\b' "$file"
  fi
done

# Inspect all core call sites that can refresh, replace, or reuse an epoch.
rg -n -C 6 '\bSessionContextEpoch\.(initialize|prepare)\b' packages/core/src --glob '*.ts'

Repository: bolt-builder/bolt-cli

Length of output: 24913


Reset the context epoch when session metadata changes.

When initialize is called on a session with an existing epoch, it returns undefined. The code then falls back to prepare, which reuses the stored baseline (line 64 in context-epoch.ts). The memory control is applied only when the context is first loaded. If a user changes the memory control via /memory, the epoch baseline does not update; the old baseline persists until the session is renamed or reverted. When memory is enabled after being disabled, memory is not injected. When memory is disabled after being enabled, memory remains.

Reset the epoch in the session metadata update handler (or add a check in prepare to re-evaluate the memory control against the stored baseline before reuse).

🤖 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 `@packages/core/src/session/runner/llm.ts` around lines 172 - 194, Update the
session metadata update flow to reset the context epoch whenever memory-control
metadata changes, so subsequent preparation rebuilds the baseline with the
current setting. Locate the metadata update handler used by initialize and
ensure it invalidates the existing epoch before prepare reuses its stored
baseline; preserve existing behavior for unrelated metadata updates.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

False positive: prepare() runs every turn with a freshly loaded context (loadSystemContext reads current session metadata from the DB), and reconcile handles both directions of the toggle. Since the memory source defines no removed renderer, omitting it triggers a full replacement generation without memory, and re-adding it emits its baseline as a context update, so no epoch reset on metadata change is needed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped: comment is from another GitHub bot.

Comment thread packages/tui/src/component/dialog-memory.tsx
Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
@deepsource-io

deepsource-io Bot commented Jul 31, 2026

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in a06cf86...3185d46 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

Important

Some issues found as part of this review are outside of the diff in this pull request and aren't shown in the inline review comments due to GitHub's API limitations. You can see those issues on the DeepSource dashboard.

PR Report Card

Overall Grade  

Focus Area: Reliability
Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
JavaScript Jul 31, 2026 5:06p.m. Review ↗
Shell Jul 31, 2026 5:06p.m. Review ↗
Secrets Jul 31, 2026 5:06p.m. Review ↗
Docker Jul 31, 2026 5:06p.m. Review ↗
Python Jul 31, 2026 5:06p.m. Review ↗
CSS Jul 31, 2026 5:06p.m. Review ↗
Rust Jul 31, 2026 5:06p.m. Review ↗
Ruby Jul 31, 2026 5:06p.m. Review ↗
Swift Jul 31, 2026 5:06p.m. Review ↗
PHP Jul 31, 2026 5:06p.m. Review ↗
Lua Jul 31, 2026 5:06p.m. Review ↗
Java Jul 31, 2026 5:06p.m. Review ↗
Go Jul 31, 2026 5:06p.m. Review ↗
C & C++ Jul 31, 2026 5:06p.m. Review ↗
Ansible Jul 31, 2026 5:06p.m. Review ↗
Apex Jul 31, 2026 5:06p.m. Review ↗
Elixir Jul 31, 2026 5:06p.m. Review ↗
Groovy Jul 31, 2026 5:06p.m. Review ↗
Objective-C Jul 31, 2026 5:06p.m. Review ↗
PowerShell Jul 31, 2026 5:06p.m. Review ↗
Terraform Jul 31, 2026 5:06p.m. Review ↗
VB.NET Jul 31, 2026 5:06p.m. Review ↗
SQL Jul 31, 2026 5:06p.m. Review ↗
Scala Jul 31, 2026 5:06p.m. Review ↗
Perl Jul 31, 2026 5:06p.m. Review ↗
Kotlin Jul 31, 2026 5:06p.m. Review ↗
Helm Jul 31, 2026 5:06p.m. Review ↗
Erlang Jul 31, 2026 5:06p.m. Review ↗
Dart Jul 31, 2026 5:06p.m. Review ↗
C# Jul 31, 2026 5:06p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

Comment on lines +183 to +185
export function omit(value: SystemContext, key: Key): SystemContext {
return context(value[ContextTypeId].filter((source) => source.key !== key))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

False positive: omit is a module-level exported function in an ES module; the "global scope" rule targets browser scripts and does not apply.

Comment on lines +13 to +15
export function use(metadata: Record<string, unknown> | null | undefined) {
return metadata?.[USE] !== false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

False positive: this is a module-level exported function in an ES module, not a browser script polluting the global scope. Exported function declarations are the convention throughout this codebase.

Comment on lines +18 to +20
export function contribute(metadata: Record<string, unknown> | null | undefined) {
return metadata?.[CONTRIBUTE] !== false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

return metadata?.[CONTRIBUTE] !== false
}

export * as MemoryControls from "./controls"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Module imports itself


A module should never import itself. This usually happens as a mistake or typo and occurs mostly during refactoring. Self importing might result in unexpected results like wrong functions/variables being used.


describe("memory controls", () => {
test("default to on when metadata is missing or untouched", () => {
expect(MemoryControls.use(undefined)).toBe(true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remove redundant `undefined` from function call


When an argument is omitted from a function call, it will default to undefined. It is therefore redundant to explicitly pass an undefined literal as the last argument.

expect(MemoryControls.use(undefined)).toBe(true)
expect(MemoryControls.use(null)).toBe(true)
expect(MemoryControls.use({})).toBe(true)
expect(MemoryControls.contribute(undefined)).toBe(true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remove redundant `undefined` from function call


When an argument is omitted from a function call, it will default to undefined. It is therefore redundant to explicitly pass an undefined literal as the last argument.

...(props.sessionID
? [
{
title: `${mark(MemoryControls.use(metadata()))} Use saved memories in this session`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Hook "MemoryControls.use" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function


Rule 1: Only Call hooks at the Top Level. Don't call hooks inside loops, conditions, or nested functions. Instead, always use hooks at the top level of your React function.

footer: "/memory use on|off",
category: "Session",
value: "use",
onSelect: () => void toggle(MemoryControls.USE),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

footer: "/memory contribute on|off",
category: "Session",
value: "contribute",
onSelect: () => void toggle(MemoryControls.CONTRIBUTE),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-session memory controls (use / contribute toggles)

1 participant