Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions devlog/2026-07-23_memory-tool/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# REQ: Memory Tool — durable facts that survive compression

## Problem

Compression summaries lose fidelity over time (model paraphrases, GC truncates
old-gen blocks). Critical facts — user constraints, irreversible decisions, exact
values, goal pivots — need a survival path independent of compression. Today the
only option is to keep them in raw context, which competes with active work for
context budget, or embed them in summaries, which decay.

## Solution

A standalone `memory` tool the model calls to record a durable fact. Memories
are **permanent and protected from compression** — they survive every compress
via Bug 39 hard-exclusion (the memory tool is registered in
`compress.protectedTools`). The model sees its own past `memory({topic, content})`
calls as anchors, same pattern as v1.12.9 compress-as-anchor.

### Key design decisions

1. **Standalone tool, not a compress flag.** Decouples memory from compression
lifecycle. Recordable any time, no char/quality limits, no nesting complexity.
2. **Survival via protection, not new logic.** `"memory"` in
`COMPRESS_DEFAULT_PROTECTED_TOOLS` → existing `filterProtectedToolMessages`
excludes memory tool-call messages from compress ranges. Zero new protection
code.
3. **Forget = lose protection.** `/acp memory forget <id>` marks the memory
forgotten in state. `filterProtectedToolMessages` gains an
`unprotectedMessageIds` param; forgotten memory messages are not protected →
the next compress that covers them consumes them. (ACP cannot delete messages,
so cleanup is deferred to natural compression.)
4. **Compress-time reminder.** The nudge (`inject.ts`) prepends "record memory
before compressing" so the model captures facts while content is still visible.
5. **Guidance from cc-alg.** `MEMORY_GUIDELINES` (when to record vs. compress,
content guidance, compression interaction) lives in MIT-licensed
`context-compress-algorithms@1.1.0` and is interpolated into the system prompt.

## Scope

### New files
- `lib/memory/state.ts` — `recordMemory`, `forgetMemory`, `listMemories`,
`getActiveMemoryMessageIds`, `formatMemoryId`
- `lib/memory/tool.ts` — `createMemoryTool` factory (`{topic, content}` args)
- `lib/memory/index.ts` — barrel
- `lib/commands/memory.ts` — `/acp memory [list | forget <id>]`
- `tests/memory-feature.test.ts` — 14 tests

### Modified files
- `lib/state/types.ts` — `MemoryEntry`, `MemoryState` types; `memories` on
`SessionState`
- `lib/state/state.ts` — init/reset/load memories
- `lib/state/persistence.ts` — serialize/deserialize memories
- `lib/compress/protected-content.ts` — `unprotectedMessageIds` param on
`filterProtectedToolMessages`
- `lib/compress/range.ts` — pass forgotten memory messageIds
- `lib/config.ts` — `"memory"` in `COMPRESS_DEFAULT_PROTECTED_TOOLS`
- `lib/prompts/system.ts` — MEMORY section + memory tool in TOOLS + WHEN NOT TO
COMPRESS
- `lib/messages/inject/inject.ts` — "record before compress" nudge reminder
- `lib/hooks.ts` — `/acp memory` dispatch
- `lib/commands/index.ts` — barrel export
- `lib/commands/help.ts` — help listing
- `index.ts` — register memory tool + permission
- `package.json` — `context-compress-algorithms` `^1.0.0` → `^1.1.0`

## Acceptance Criteria

- [x] `memory` tool registered and callable by the model
- [x] Memory tool-call messages survive compression (protected)
- [x] `/acp memory forget <id>` loses protection → next compress consumes it
- [x] System prompt includes MEMORY_GUIDELINES from cc-alg
- [x] Compress nudge reminds model to record before compressing
- [x] Memories persist across session restart (persistence layer)
- [x] `npm run typecheck` passes
- [x] `npm test` passes (851 tests)
- [x] `npm run build` passes
64 changes: 64 additions & 0 deletions devlog/2026-07-23_memory-tool/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# WORKLOG: Memory Tool

## Implementation

### Memory state (`lib/memory/state.ts`)
Pure helpers operating on `SessionState.memories`:
- `recordMemory(state, messageId, topic, logger)` — allocates `mem_NNN` id,
stores `MemoryEntry`, increments `nextId`.
- `forgetMemory(state, id)` — sets `forgotten: true`, returns boolean.
- `listMemories(state)` — sorted by `createdAt`.
- `getActiveMemoryMessageIds(state)` — returns Set of forgotten entries'
`messageId`s. Defensive: returns empty set if `state.memories` undefined
(legacy test states / pre-migration sessions).

### Memory tool (`lib/memory/tool.ts`)
`createMemoryTool(ctx)` via `@opencode-ai/plugin` `tool()`. Args:
`{ topic: string, content: string }`. Uses `toolCtx.messageID` to link the
memory entry to the message containing the tool call (needed for forget-
unprotection). Returns confirmation with the memory id.

### Forget-unprotection (`lib/compress/protected-content.ts`)
`filterProtectedToolMessages` gains optional `unprotectedMessageIds?: Set<string>`.
Messages in this set skip the protected-tool check entirely → they CAN be
compressed. This is message-level granularity (not callID-level): the common
case (one memory call per assistant message) works perfectly. Edge case
(parallel memory calls in one message) is acceptable for MVP.

`lib/compress/range.ts` computes `getActiveMemoryMessageIds(ctx.state)` and
passes it to `filterProtectedToolMessages`.

### Persistence (`lib/state/persistence.ts`)
`memories` serialized as `{ entries: [[id, entry], ...], nextId: number }` in
`PersistedSessionState`. Loaded in `ensureSessionInitialized`. Survives restart.

### System prompt (`lib/prompts/system.ts`)
- Imports `MEMORY_GUIDELINES` from `context-compress-algorithms/prompts`
- Added `memory` tool to TOOLS section with usage example
- Added MEMORY section (the full `MEMORY_GUIDELINES`) after `HOW_TO_COMPRESS_RULES`
- Updated WHEN NOT TO COMPRESS: "Protected tool outputs (default: `skill` and
`memory`)"

### Nudge reminder (`lib/messages/inject/inject.ts`)
After the compressible-ranges + "compress all in one call" hint, added:
"⚠️ Before compressing, scan each range for critical facts the task depends on
and record them with `memory` FIRST."

### Command (`lib/commands/memory.ts`)
`/acp memory` — list (default) or `forget <id>`. Uses `sendIgnoredMessage`
pattern matching other commands. Wired in `hooks.ts` dispatch +
`commands/index.ts` barrel + `help.ts`.

### Config (`lib/config.ts`)
`COMPRESS_DEFAULT_PROTECTED_TOOLS` changed from `["skill"]` to
`["skill", "memory"]`.

### index.ts
- Registers `memory: createMemoryTool(compressToolContext)`
- Adds `memory: "allow"` to default permissions

## Verification

- `npm run typecheck` — pass
- `npm test` — 851/851 pass (837 existing + 14 new)
- `npm run build` — pass; `MEMORY_GUIDELINES` text inlined (no external import)
3 changes: 3 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createPruneTool,
createSearchContextTool,
} from "./lib/compress"
import { createMemoryTool } from "./lib/memory"
import {
compressDisabledByOpencode,
hasExplicitToolPermission,
Expand Down Expand Up @@ -97,6 +98,7 @@ const server: Plugin = (async (ctx) => {
search_context: createSearchContextTool(compressToolContext),
acp_status: createAcpStatusTool(compressToolContext),
acp_context_recap: createAcpContextRecapTool(compressToolContext),
memory: createMemoryTool(compressToolContext),
}),
},
config: async (opencodeConfig) => {
Expand Down Expand Up @@ -134,6 +136,7 @@ const server: Plugin = (async (ctx) => {
...permission,
compress: config.compress.permission,
acp_status: "allow",
memory: "allow",
} as typeof permission
}

Expand Down
1 change: 1 addition & 0 deletions lib/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const BASE_COMMANDS: [string, string][] = [
["/acp stats", "Show ACP pruning statistics"],
["/acp sweep [n]", "Prune tools since last user message, or last n tools"],
["/acp manual [on|off]", "Toggle manual mode or set explicit state"],
["/acp memory [list|forget <id>]", "Show or forget recorded memories"],
]

const TOOL_COMMANDS: Record<string, [string, string]> = {
Expand Down
1 change: 1 addition & 0 deletions lib/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {
export { handleRecompressCommand } from "./recompress"
export { handleStatsCommand } from "./stats"
export { handleSweepCommand } from "./sweep"
export { handleMemoryCommand } from "./memory"
92 changes: 92 additions & 0 deletions lib/commands/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import { saveSessionState } from "../state/persistence"
import { sendIgnoredMessage } from "../ui/notification"
import { getCurrentParams } from "../token-utils"
import { listMemories, forgetMemory } from "../memory"

export interface MemoryCommandContext {
client: any
state: SessionState
logger: Logger
sessionId: string
messages: WithParts[]
}

export async function handleMemoryCommand(
ctx: MemoryCommandContext,
args: string[],
): Promise<void> {
const { client, state, logger, sessionId, messages } = ctx
const sub = args[0]?.toLowerCase()

const params = getCurrentParams(state, messages, logger)

if (!sub || sub === "list") {
const entries = listMemories(state)
const active = entries.filter((e) => !e.forgotten)
const forgotten = entries.filter((e) => e.forgotten)

const lines: string[] = ["ACP Memories", "─".repeat(50)]
if (active.length === 0) {
lines.push(" (none recorded)")
} else {
for (const e of active) {
lines.push(` ${e.id} [${e.topic}]`)
}
}
if (forgotten.length > 0) {
lines.push("", `Forgotten (${forgotten.length}):`)
for (const e of forgotten) {
lines.push(` ${e.id} [${e.topic}] — forgotten`)
}
}
lines.push("", `Total: ${active.length} active, ${forgotten.length} forgotten.`)

await sendIgnoredMessage(client, sessionId, lines.join("\n"), params, logger)
return
}

if (sub === "forget") {
const id = args[1]
if (!id) {
await sendIgnoredMessage(
client,
sessionId,
"Usage: /acp memory forget <mem_id>",
params,
logger,
)
return
}
const ok = forgetMemory(state, id)
if (!ok) {
await sendIgnoredMessage(
client,
sessionId,
`No memory found with id "${id}". Use /acp memory list to see recorded memories.`,
params,
logger,
)
return
}
await saveSessionState(state, logger)
await sendIgnoredMessage(
client,
sessionId,
`Forgot ${id}. It is no longer protected — the next compression that covers it will consume it.`,
params,
logger,
)
logger.info("Memory forgotten via command", { id })
return
}

await sendIgnoredMessage(
client,
sessionId,
"Usage: /acp memory [list | forget <mem_id>]",
params,
logger,
)
}
3 changes: 3 additions & 0 deletions lib/compress/protected-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export function filterProtectedToolMessages(
searchContext: SearchContext,
protectedTools: string[],
protectedFilePatterns: string[] = [],
unprotectedMessageIds?: Set<string>,
): SelectionResolution {
const removedMessageIds = new Set<string>()
const removedToolIds = new Set<string>()
Expand All @@ -243,6 +244,8 @@ export function filterProtectedToolMessages(
const message = searchContext.rawMessagesById.get(messageId)
if (!message) continue

if (unprotectedMessageIds?.has(messageId)) continue

if (messageContainsProtectedTool(message, protectedTools, protectedFilePatterns)) {
removedMessageIds.add(messageId)
const parts = Array.isArray(message.parts) ? message.parts : []
Expand Down
3 changes: 3 additions & 0 deletions lib/compress/range.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { tool } from "@opencode-ai/plugin"
import type { ToolContext } from "./types"
import { countMessageCharacters, countTokens } from "../token-utils"
import { getForgottenMemoryMessageIds } from "../memory"
import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
import {
finalizeSession,
Expand Down Expand Up @@ -127,6 +128,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
const resolvedPlans = resolveRanges(input, searchContext, ctx.state)
validateNonOverlapping(resolvedPlans)

const forgottenMemoryMessageIds = getForgottenMemoryMessageIds(ctx.state)
const filteredPlans = resolvedPlans
.map((plan) => ({
...plan,
Expand All @@ -135,6 +137,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
searchContext,
ctx.config.compress.protectedTools,
ctx.config.protectedFilePatterns,
forgottenMemoryMessageIds,
),
}))
.filter((plan) => plan.selection.messageIds.length > 0)
Expand Down
2 changes: 1 addition & 1 deletion lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const DEFAULT_PROTECTED_TOOLS = [
"edit",
]

const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill"]
const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill", "memory"]

export { VALID_CONFIG_KEYS, getInvalidConfigKeys, validateConfigTypes, type ValidationError } from "./config-validation"

Expand Down
6 changes: 6 additions & 0 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
handleHelpCommand,
handleManualToggleCommand,
handleManualTriggerCommand,
handleMemoryCommand,
handleRecompressCommand,
handleStatsCommand,
handleSweepCommand,
Expand Down Expand Up @@ -377,6 +378,11 @@ export function createCommandExecuteHandler(
throw new Error("__DCP_RECOMPRESS_HANDLED__")
}

if (subcommand === "memory") {
await handleMemoryCommand(commandCtx, subArgs)
throw new Error("__DCP_MEMORY_HANDLED__")
}

await handleHelpCommand(commandCtx)
throw new Error("__DCP_HELP_HANDLED__")
}
Expand Down
8 changes: 8 additions & 0 deletions lib/memory/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { createMemoryTool } from "./tool"
export {
recordMemory,
forgetMemory,
listMemories,
getForgottenMemoryMessageIds,
formatMemoryId,
} from "./state"
48 changes: 48 additions & 0 deletions lib/memory/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { SessionState, MemoryEntry } from "../state/types"
import type { Logger } from "../logger"

export function formatMemoryId(n: number): string {
return `mem_${String(n).padStart(3, "0")}`
}

export function recordMemory(
state: SessionState,
messageId: string,
topic: string,
logger: Logger,
): MemoryEntry {
const id = formatMemoryId(state.memories.nextId)
const entry: MemoryEntry = {
id,
messageId,
topic,
createdAt: Date.now(),
forgotten: false,
}
state.memories.entries.set(id, entry)
state.memories.nextId += 1
logger.info("Recorded memory", { id, topic, messageId })
return entry
}

export function forgetMemory(state: SessionState, id: string): boolean {
const entry = state.memories.entries.get(id)
if (!entry) return false
entry.forgotten = true
return true
}

export function listMemories(state: SessionState): MemoryEntry[] {
return Array.from(state.memories.entries.values()).sort((a, b) => a.createdAt - b.createdAt)
}

export function getForgottenMemoryMessageIds(state: SessionState): Set<string> {
const forgotten = new Set<string>()
if (!state.memories) return forgotten
for (const entry of state.memories.entries.values()) {
if (entry.forgotten) {
forgotten.add(entry.messageId)
}
}
return forgotten
}
Loading
Loading