diff --git a/devlog/2026-07-23_memory-tool/REQ.md b/devlog/2026-07-23_memory-tool/REQ.md new file mode 100644 index 00000000..11a31727 --- /dev/null +++ b/devlog/2026-07-23_memory-tool/REQ.md @@ -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 ` 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 ]` +- `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 ` 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 diff --git a/devlog/2026-07-23_memory-tool/WORKLOG.md b/devlog/2026-07-23_memory-tool/WORKLOG.md new file mode 100644 index 00000000..d1903cdf --- /dev/null +++ b/devlog/2026-07-23_memory-tool/WORKLOG.md @@ -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`. +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 `. 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) diff --git a/index.ts b/index.ts index a168ad95..5c5fef40 100644 --- a/index.ts +++ b/index.ts @@ -9,6 +9,7 @@ import { createPruneTool, createSearchContextTool, } from "./lib/compress" +import { createMemoryTool } from "./lib/memory" import { compressDisabledByOpencode, hasExplicitToolPermission, @@ -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) => { @@ -134,6 +136,7 @@ const server: Plugin = (async (ctx) => { ...permission, compress: config.compress.permission, acp_status: "allow", + memory: "allow", } as typeof permission } diff --git a/lib/commands/help.ts b/lib/commands/help.ts index 4391b5d8..219ca2c6 100644 --- a/lib/commands/help.ts +++ b/lib/commands/help.ts @@ -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 ]", "Show or forget recorded memories"], ] const TOOL_COMMANDS: Record = { diff --git a/lib/commands/index.ts b/lib/commands/index.ts index 993420bf..120b612a 100644 --- a/lib/commands/index.ts +++ b/lib/commands/index.ts @@ -9,3 +9,4 @@ export { export { handleRecompressCommand } from "./recompress" export { handleStatsCommand } from "./stats" export { handleSweepCommand } from "./sweep" +export { handleMemoryCommand } from "./memory" diff --git a/lib/commands/memory.ts b/lib/commands/memory.ts new file mode 100644 index 00000000..53c2cc8b --- /dev/null +++ b/lib/commands/memory.ts @@ -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 { + 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 ", + 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 ]", + params, + logger, + ) +} diff --git a/lib/compress/protected-content.ts b/lib/compress/protected-content.ts index 2ecdc47e..9ee89402 100644 --- a/lib/compress/protected-content.ts +++ b/lib/compress/protected-content.ts @@ -235,6 +235,7 @@ export function filterProtectedToolMessages( searchContext: SearchContext, protectedTools: string[], protectedFilePatterns: string[] = [], + unprotectedMessageIds?: Set, ): SelectionResolution { const removedMessageIds = new Set() const removedToolIds = new Set() @@ -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 : [] diff --git a/lib/compress/range.ts b/lib/compress/range.ts index b27722f8..59b44cc4 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -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, @@ -127,6 +128,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType ({ ...plan, @@ -135,6 +137,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType plan.selection.messageIds.length > 0) diff --git a/lib/config.ts b/lib/config.ts index 9c8b4f46..cf2077eb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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" diff --git a/lib/hooks.ts b/lib/hooks.ts index 4f9c427e..bce6ac12 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -33,6 +33,7 @@ import { handleHelpCommand, handleManualToggleCommand, handleManualTriggerCommand, + handleMemoryCommand, handleRecompressCommand, handleStatsCommand, handleSweepCommand, @@ -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__") } diff --git a/lib/memory/index.ts b/lib/memory/index.ts new file mode 100644 index 00000000..1fbf5109 --- /dev/null +++ b/lib/memory/index.ts @@ -0,0 +1,8 @@ +export { createMemoryTool } from "./tool" +export { + recordMemory, + forgetMemory, + listMemories, + getForgottenMemoryMessageIds, + formatMemoryId, +} from "./state" diff --git a/lib/memory/state.ts b/lib/memory/state.ts new file mode 100644 index 00000000..e949472e --- /dev/null +++ b/lib/memory/state.ts @@ -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 { + const forgotten = new Set() + if (!state.memories) return forgotten + for (const entry of state.memories.entries.values()) { + if (entry.forgotten) { + forgotten.add(entry.messageId) + } + } + return forgotten +} diff --git a/lib/memory/tool.ts b/lib/memory/tool.ts new file mode 100644 index 00000000..a82a9522 --- /dev/null +++ b/lib/memory/tool.ts @@ -0,0 +1,32 @@ +import { tool } from "@opencode-ai/plugin" +import type { ToolContext } from "../compress/types" +import { recordMemory } from "./state" +import { saveSessionState } from "../state/persistence" + +const MEMORY_TOOL_DESCRIPTION = `Record a durable fact that must survive for the rest of the task, even after compression. + +Memories are permanent — compression cannot remove them, and only an explicit forget clears them. Record sparingly: each memory consumes context forever until forgotten. See the MEMORY section of your system prompt for when to record vs. when to use a compression summary. + +Args: +- topic: short label for the memory (e.g., "auth constraint", "API base URL") +- content: the load-bearing fact, with enough context to act on without the original conversation` + +export function createMemoryTool(ctx: ToolContext): ReturnType { + return tool({ + description: MEMORY_TOOL_DESCRIPTION, + args: { + topic: tool.schema.string().describe("Short label for the memory category"), + content: tool.schema.string().describe("The durable fact to record"), + }, + async execute(args, toolCtx) { + const entry = recordMemory( + ctx.state, + toolCtx.messageID, + args.topic, + ctx.logger, + ) + await saveSessionState(ctx.state, ctx.logger) + return `Recorded ${entry.id} [${entry.topic}]: ${args.content}\n\nThis memory is protected from compression. It will persist until explicitly forgotten via /acp memory forget ${entry.id}.` + }, + }) +} diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 0d0beacc..afbbd3f1 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -393,6 +393,7 @@ export const injectCompressNudges = ( if (recommendedRanges.length > 0) { breakdown += `\n\n${formatCompressibleRanges(recommendedRanges, contextRanges.protected)}` breakdown += `\n💡 Compress all ranges in one call (pass multiple content entries: \`content: [{...}, {...}]\`).` + breakdown += `\n⚠️ Before compressing, scan each range for critical facts the task depends on (constraints, decisions, exact values) and record them with \`memory\` FIRST — once compressed, the raw content is gone.` } breakdown += `\nUse \`acp_status({scope:"uncompressed"})\` to re-fetch compressible ranges after compressing, or \`acp_status\` for compressed block details.` diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index 54cc0dc9..cac9ae18 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -1,4 +1,7 @@ -import { HOW_TO_COMPRESS_RULES } from "context-compress-algorithms/prompts" +import { + HOW_TO_COMPRESS_RULES, + MEMORY_GUIDELINES, +} from "context-compress-algorithms/prompts" export const SYSTEM = ` @@ -21,13 +24,14 @@ When you see past \`compress\` tool calls in the conversation, their \`summary\` TOOLS -You have five context-management tools: +You have six context-management tools: - \`compress\` — Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Single range: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`. Batch (multiple unrelated ranges, each with its own topic): \`compress({ content: [{ topic: "Auth", startId: "m00150", endId: "m00220", summary: "..." }, { topic: "Deploy", startId: "m00300", endId: "m00350", summary: "..." }] })\`. - \`decompress\` — Restore a previously compressed block's full original content, optionally to a file for large blocks. Use when a summary lacks the exact detail you need. Example: \`decompress({ blockId: "b5" })\` or \`decompress({ blockId: "b5", toFile: "path" })\`. - \`search_context\` — Search compressed block summaries (and optionally visible messages) by keyword. Use BEFORE decompressing to find the right block. Example: \`search_context({ query: "auth token refresh" })\`. - \`prune\` — Remove old tool outputs by tool type, keeping only recent calls. Unlike compress (which creates summaries), prune directly strips outputs. Use for disposable outputs like old todowrite states or edit echoes. Example: \`prune({ toolType: "todowrite", keepLatest: 3 })\`. - \`acp_status\` — Context status with compressible ranges. No args = overview + ranges. \`scope:"uncompressed"\` for range view; add \`view:"messages"\` for per-message listing with \`tool\`/\`sort\` filters. \`scope:"compressed"\` for block details. +- \`memory\` — Record a durable fact that must survive for the rest of the task, even after compression. Memories are permanent and protected from compression. Use \`/acp memory list\` to view them and \`/acp memory forget \` to clear one. Example: \`memory({ topic: "deploy constraint", content: "production deploy requires Node 22 — CI fails on 20" })\`. COMPRESSION PHILOSOPHY @@ -54,10 +58,12 @@ WHEN NOT TO COMPRESS - Content the current task step is actively reading or reasoning about. - Important user messages — preserve their exact intent, constraints, and acceptance criteria verbatim, not just the most recent one. -- Protected tool outputs (default: \`skill\` only) — hard-excluded from compression ranges, survive intact in visible context. +- Protected tool outputs (default: \`skill\` and \`memory\`) — hard-excluded from compression ranges, survive intact in visible context. ${HOW_TO_COMPRESS_RULES} +${MEMORY_GUIDELINES} + PERIODIC CONTEXT STATUS Periodically, as context grows, the system appends a short status line in a synthetic suffix message. It looks like: diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index cbf4e543..66c5ba85 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -9,7 +9,13 @@ import { existsSync } from "fs" import { homedir } from "os" import { join } from "path" import { cpSync, existsSync as existsSyncSync } from "fs" -import type { CompressionBlock, PrunedMessageEntry, SessionState, SessionStats } from "./types" +import type { + CompressionBlock, + PrunedMessageEntry, + SessionState, + SessionStats, + MemoryEntry, +} from "./types" import type { Logger } from "../logger" import { serializePruneMessagesState } from "./utils" @@ -63,6 +69,10 @@ export interface PersistedSessionState { stats: SessionStats lastUpdated: string messageIds?: PersistedMessageIds + memories?: { + entries: Array<[string, MemoryEntry]> + nextId: number + } lastCompaction?: number modelContextLimit?: number } @@ -161,6 +171,13 @@ export async function saveSessionState( }, lastCompaction: sessionState.lastCompaction, modelContextLimit: sessionState.modelContextLimit, + memories: { + entries: Array.from(sessionState.memories.entries.entries()).map(([id, entry]) => [ + id, + { ...entry }, + ]), + nextId: sessionState.memories.nextId, + }, } await writePersistedSessionState(sessionState.sessionId, state, logger) diff --git a/lib/state/state.ts b/lib/state/state.ts index 8e986e73..ad76645e 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,4 +1,4 @@ -import type { SessionState, ToolParameterEntry, WithParts } from "./types" +import type { SessionState, ToolParameterEntry, WithParts, MemoryEntry } from "./types" import type { PluginConfig } from "../config" import type { Logger } from "../logger" import { applyPendingCompressionDurations } from "../compress/timing" @@ -105,6 +105,10 @@ export function createSessionState(): SessionState { byRef: new Map(), nextRef: 1, }, + memories: { + entries: new Map(), + nextId: 1, + }, lastCompaction: 0, currentTurn: 0, modelContextLimit: undefined, @@ -146,6 +150,10 @@ export function resetSessionState(state: SessionState): void { byRef: new Map(), nextRef: 1, } + state.memories = { + entries: new Map(), + nextId: 1, + } state.lastCompaction = 0 state.currentTurn = 0 state.modelContextLimit = undefined @@ -241,6 +249,13 @@ export async function ensureSessionInitialized( if (persistedAny._persistedLastCompaction !== undefined) { state.lastCompaction = Math.max(state.lastCompaction, persistedAny._persistedLastCompaction) } + const persistedMemories = persistedAny.memories as + | { entries?: Array<[string, MemoryEntry]>; nextId?: number } + | undefined + if (persistedMemories?.entries) { + state.memories.entries = new Map(persistedMemories.entries) + state.memories.nextId = persistedMemories.nextId ?? state.memories.entries.size + 1 + } if (typeof persisted.modelContextLimit === "number" && persisted.modelContextLimit > 0) { state.modelContextLimit = persisted.modelContextLimit } diff --git a/lib/state/types.ts b/lib/state/types.ts index 680956de..6a7fac45 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -89,6 +89,19 @@ export interface MessageIdState { nextRef: number } +export interface MemoryEntry { + id: string + messageId: string + topic: string + createdAt: number + forgotten: boolean +} + +export interface MemoryState { + entries: Map + nextId: number +} + export interface Nudges { contextLimitAnchors: Set turnNudgeAnchors: Set @@ -124,6 +137,7 @@ export interface SessionState { subAgentResultCache: Map toolIdList: string[] messageIds: MessageIdState + memories: MemoryState lastCompaction: number currentTurn: number modelContextLimit: number | undefined diff --git a/package-lock.json b/package-lock.json index 3c673565..80d29dbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-acp", - "version": "1.13.1", + "version": "1.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-acp", - "version": "1.13.1", + "version": "1.13.3", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", @@ -17,7 +17,7 @@ "devDependencies": { "@opencode-ai/plugin": "^1.4.3", "@types/node": "^25.5.0", - "context-compress-algorithms": "^1.0.0", + "context-compress-algorithms": "^1.1.0", "prettier": "^3.8.1", "tsup": "^8.5.1", "tsx": "^4.21.0", @@ -1131,9 +1131,9 @@ } }, "node_modules/context-compress-algorithms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/context-compress-algorithms/-/context-compress-algorithms-1.0.0.tgz", - "integrity": "sha512-Cznt1ayFLnHbakC9C6TUBUp+zLDgaoyqn+EpKfMMk3zrLOLPg24ewIvpy+QjMDDRiTvLfVzmJ7qTLCe5FKtfYQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/context-compress-algorithms/-/context-compress-algorithms-1.1.0.tgz", + "integrity": "sha512-9fR7OF2iIFu94DoaQxJKqdZY+PYgoed9uMScI2nnGxttX9frYMHIXdlqEbBQBIUbcu0hI3Y/rHIM4CZLlZnAGw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index a3ac64c0..a142c108 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "devDependencies": { "@opencode-ai/plugin": "^1.4.3", "@types/node": "^25.5.0", - "context-compress-algorithms": "^1.0.0", + "context-compress-algorithms": "^1.1.0", "prettier": "^3.8.1", "tsup": "^8.5.1", "tsx": "^4.21.0", diff --git a/tests/memory-feature.test.ts b/tests/memory-feature.test.ts new file mode 100644 index 00000000..05b15ae5 --- /dev/null +++ b/tests/memory-feature.test.ts @@ -0,0 +1,288 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + recordMemory, + forgetMemory, + listMemories, + getForgottenMemoryMessageIds, + formatMemoryId, +} from "../lib/memory/state" +import { filterProtectedToolMessages } from "../lib/compress/protected-content" +import { SYSTEM } from "../lib/prompts/system" +import { createSessionState } from "../lib/state/state" +import type { SessionState, WithParts } from "../lib/state/types" +import type { SelectionResolution, SearchContext } from "../lib/compress/types" +import type { Logger } from "../lib/logger" + +const mockLogger: Logger = { debug: () => {}, warn: () => {}, info: () => {} } as any + +function makeState(): SessionState { + return createSessionState() +} + +test("formatMemoryId pads to 3 digits", () => { + assert.equal(formatMemoryId(1), "mem_001") + assert.equal(formatMemoryId(7), "mem_007") + assert.equal(formatMemoryId(42), "mem_042") + assert.equal(formatMemoryId(100), "mem_100") + assert.equal(formatMemoryId(1000), "mem_1000") +}) + +test("recordMemory creates entry with sequential id and increments nextId", () => { + const state = makeState() + const e1 = recordMemory(state, "msg_a", "constraint", mockLogger) + assert.equal(e1.id, "mem_001") + assert.equal(e1.topic, "constraint") + assert.equal(e1.messageId, "msg_a") + assert.equal(e1.forgotten, false) + assert.equal(state.memories.nextId, 2) + + const e2 = recordMemory(state, "msg_b", "decision", mockLogger) + assert.equal(e2.id, "mem_002") + assert.equal(e2.messageId, "msg_b") + assert.equal(state.memories.nextId, 3) +}) + +test("recordMemory stores entry retrievable from state.memories.entries", () => { + const state = makeState() + const entry = recordMemory(state, "msg_x", "goal", mockLogger) + const stored = state.memories.entries.get("mem_001") + assert.ok(stored) + assert.equal(stored!.id, entry.id) + assert.equal(stored!.messageId, "msg_x") +}) + +test("forgetMemory returns false for unknown id", () => { + const state = makeState() + assert.equal(forgetMemory(state, "mem_999"), false) +}) + +test("forgetMemory marks entry forgotten and returns true", () => { + const state = makeState() + recordMemory(state, "msg_a", "topic", mockLogger) + assert.equal(forgetMemory(state, "mem_001"), true) + const entry = state.memories.entries.get("mem_001") + assert.ok(entry) + assert.equal(entry!.forgotten, true) +}) + +test("listMemories returns entries sorted by createdAt ascending", () => { + const state = makeState() + recordMemory(state, "msg_a", "first", mockLogger) + recordMemory(state, "msg_b", "second", mockLogger) + const list = listMemories(state) + assert.equal(list.length, 2) + assert.equal(list[0]!.topic, "first") + assert.equal(list[1]!.topic, "second") +}) + +test("getForgottenMemoryMessageIds returns messageIds of forgotten entries only", () => { + const state = makeState() + recordMemory(state, "msg_active", "topic", mockLogger) + recordMemory(state, "msg_forgotten", "topic2", mockLogger) + forgetMemory(state, "mem_002") + const ids = getForgottenMemoryMessageIds(state) + assert.equal(ids.size, 1) + assert.ok(ids.has("msg_forgotten")) + assert.ok(!ids.has("msg_active")) +}) + +test("getForgottenMemoryMessageIds is defensive when state.memories is undefined", () => { + const state = makeState() + ;(state as any).memories = undefined + const ids = getForgottenMemoryMessageIds(state) + assert.equal(ids.size, 0) +}) + +function makeToolMessage(id: string, toolName: string, callID: string): WithParts { + return { + info: { id, role: "assistant", sessionID: "s1", time: 0 } as any, + parts: [{ type: "tool", tool: toolName, callID, state: {} } as any], + } +} + +function makeSelection(messageIds: string[]): SelectionResolution { + return { + startReference: { kind: "message", rawIndex: 0 }, + endReference: { kind: "message", rawIndex: 0 }, + messageIds, + messageTokenById: new Map(messageIds.map((id) => [id, 100])), + toolIds: [], + requiredBlockIds: [], + } +} + +function makeSearchContext(messages: WithParts[]): SearchContext { + const map = new Map() + const idx = new Map() + messages.forEach((m, i) => { + map.set(m.info.id, m) + idx.set(m.info.id, i) + }) + return { + rawMessages: messages, + rawMessagesById: map, + rawIndexById: idx, + summaryByBlockId: new Map(), + } +} + +test("filterProtectedToolMessages excludes memory tool messages from selection", () => { + const memMsg = makeToolMessage("m1", "memory", "call_1") + const plainMsg = makeToolMessage("m2", "bash", "call_2") + const search = makeSearchContext([memMsg, plainMsg]) + const sel = makeSelection(["m1", "m2"]) + const result = filterProtectedToolMessages(sel, search, ["memory"]) + assert.deepEqual(result.messageIds, ["m2"]) +}) + +test("filterProtectedToolMessages keeps memory message when unprotectedMessageIds covers it", () => { + const memMsg = makeToolMessage("m1", "memory", "call_1") + const plainMsg = makeToolMessage("m2", "bash", "call_2") + const search = makeSearchContext([memMsg, plainMsg]) + const sel = makeSelection(["m1", "m2"]) + const result = filterProtectedToolMessages( + sel, + search, + ["memory"], + [], + new Set(["m1"]), + ) + assert.deepEqual(result.messageIds, ["m1", "m2"]) +}) + +test("filterProtectedToolMessages unprotects only forgotten memory messages", () => { + const forgotten = makeToolMessage("m1", "memory", "call_1") + const active = makeToolMessage("m3", "memory", "call_3") + const plain = makeToolMessage("m2", "bash", "call_2") + const search = makeSearchContext([forgotten, plain, active]) + const sel = makeSelection(["m1", "m2", "m3"]) + const result = filterProtectedToolMessages( + sel, + search, + ["memory"], + [], + new Set(["m1"]), + ) + assert.ok(result.messageIds.includes("m1")) + assert.ok(result.messageIds.includes("m2")) + assert.ok(!result.messageIds.includes("m3")) +}) + +test("SYSTEM prompt includes MEMORY section from cc-alg MEMORY_GUIDELINES", () => { + assert.ok(SYSTEM.includes("MEMORY"), "system prompt should mention MEMORY") + assert.ok( + SYSTEM.includes("memory"), + "system prompt should reference the memory tool", + ) + assert.ok( + SYSTEM.includes("record"), + "system prompt should guide recording memories", + ) +}) + +test("SYSTEM prompt lists memory tool in TOOLS section", () => { + assert.ok( + SYSTEM.includes("`memory`"), + "system prompt should list the memory tool in backticks", + ) +}) + +test("SYSTEM prompt marks memory as protected in WHEN NOT TO COMPRESS", () => { + assert.ok( + SYSTEM.includes("skill` and `memory"), + "WHEN NOT TO COMPRESS should list memory as protected alongside skill", + ) +}) + +test("SYSTEM prompt lists six context-management tools", () => { + assert.ok( + SYSTEM.includes("six context-management tools"), + "tool count should be six now that memory is added", + ) +}) + +test("integration: record → filterProtectedToolMessages → active memory survives", () => { + const state = makeState() + const memMsg = makeToolMessage("msg_mem", "memory", "call_mem") + const plainMsg = makeToolMessage("msg_plain", "bash", "call_plain") + const search = makeSearchContext([memMsg, plainMsg]) + + recordMemory(state, "msg_mem", "constraint", mockLogger) + + const sel = makeSelection(["msg_mem", "msg_plain"]) + const forgotten = getForgottenMemoryMessageIds(state) + assert.equal(forgotten.size, 0, "no forgotten memories yet") + + const result = filterProtectedToolMessages(sel, search, ["memory"], [], forgotten) + assert.ok( + !result.messageIds.includes("msg_mem"), + "active memory message should survive (excluded from compress selection)", + ) + assert.ok( + result.messageIds.includes("msg_plain"), + "non-protected message should remain compressible", + ) +}) + +test("integration: forget → filterProtectedToolMessages → forgotten memory consumed", () => { + const state = makeState() + const memMsg = makeToolMessage("msg_mem", "memory", "call_mem") + const plainMsg = makeToolMessage("msg_plain", "bash", "call_plain") + const search = makeSearchContext([memMsg, plainMsg]) + + const entry = recordMemory(state, "msg_mem", "constraint", mockLogger) + forgetMemory(state, entry.id) + + const sel = makeSelection(["msg_mem", "msg_plain"]) + const forgotten = getForgottenMemoryMessageIds(state) + assert.equal(forgotten.size, 1) + assert.ok(forgotten.has("msg_mem")) + + const result = filterProtectedToolMessages(sel, search, ["memory"], [], forgotten) + assert.ok( + result.messageIds.includes("msg_mem"), + "forgotten memory message should be consumable (in compress selection)", + ) + assert.ok( + result.messageIds.includes("msg_plain"), + "non-protected message should remain compressible", + ) +}) + +test("persistence: memories serialize and deserialize correctly", () => { + const state = makeState() + recordMemory(state, "msg_a", "topic1", mockLogger) + recordMemory(state, "msg_b", "topic2", mockLogger) + forgetMemory(state, "mem_001") + + const serialized = { + entries: Array.from(state.memories.entries.entries()).map(([id, entry]) => [ + id, + { ...entry }, + ]), + nextId: state.memories.nextId, + } + + const reloaded = createSessionState() + reloaded.memories.entries = new Map( + (serialized.entries as Array<[string, any]>).map(([id, entry]) => [ + id, + { ...entry }, + ]), + ) + reloaded.memories.nextId = serialized.nextId + + assert.equal(reloaded.memories.entries.size, 2) + assert.equal(reloaded.memories.nextId, 3) + + const e1 = reloaded.memories.entries.get("mem_001") + assert.ok(e1) + assert.equal(e1!.forgotten, true) + assert.equal(e1!.topic, "topic1") + + const e2 = reloaded.memories.entries.get("mem_002") + assert.ok(e2) + assert.equal(e2!.forgotten, false) + assert.equal(e2!.topic, "topic2") +})