From 1a4ace1817a3e703721daa145e02f5f9afa30bec Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 01:41:22 +0800 Subject: [PATCH 01/10] feat: add MemoryEntry/MemoryState types + state persistence Foundation for the memory tool: MemoryEntry { id, messageId, topic, createdAt, forgotten } and MemoryState { entries, nextId } added to SessionState. Initialized in createSessionState/resetSessionState, serialized in persistence, loaded in ensureSessionInitialized. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- lib/state/persistence.ts | 19 ++++++++++++++++++- lib/state/state.ts | 17 ++++++++++++++++- lib/state/types.ts | 14 ++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) 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 From 31e81130a563cc9669983014443f05f5879adba6 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 01:41:27 +0800 Subject: [PATCH 02/10] feat: add memory state helpers + memory tool factory lib/memory/state.ts: recordMemory, forgetMemory, listMemories, getActiveMemoryMessageIds, formatMemoryId. lib/memory/tool.ts: createMemoryTool with {topic, content} args, links entry to toolCtx.messageID for forget-unprotection. getActiveMemoryMessageIds is defensive (returns empty set if state.memories undefined). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- lib/memory/index.ts | 8 ++++++++ lib/memory/state.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++ lib/memory/tool.ts | 30 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 lib/memory/index.ts create mode 100644 lib/memory/state.ts create mode 100644 lib/memory/tool.ts diff --git a/lib/memory/index.ts b/lib/memory/index.ts new file mode 100644 index 00000000..38d468c0 --- /dev/null +++ b/lib/memory/index.ts @@ -0,0 +1,8 @@ +export { createMemoryTool } from "./tool" +export { + recordMemory, + forgetMemory, + listMemories, + getActiveMemoryMessageIds, + formatMemoryId, +} from "./state" diff --git a/lib/memory/state.ts b/lib/memory/state.ts new file mode 100644 index 00000000..3c82e4d3 --- /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 getActiveMemoryMessageIds(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..f8a6573f --- /dev/null +++ b/lib/memory/tool.ts @@ -0,0 +1,30 @@ +import { tool } from "@opencode-ai/plugin" +import type { ToolContext } from "../compress/types" +import { recordMemory } from "./state" + +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, + ) + 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}.` + }, + }) +} From 764842574f7111d3eed8dcd07e28ed3a4aeeac89 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 01:41:35 +0800 Subject: [PATCH 03/10] feat: unprotectedMessageIds param for forget-unprotection filterProtectedToolMessages gains optional unprotectedMessageIds: Set. Messages in this set skip the protected-tool check and CAN be compressed. range.ts passes forgotten memory messageIds (getActiveMemoryMessageIds) so forgotten memories lose protection and get cleaned by the next compress. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- lib/compress/protected-content.ts | 3 +++ lib/compress/range.ts | 3 +++ 2 files changed, 6 insertions(+) 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..d5be1aa5 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 { getActiveMemoryMessageIds } 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) From 570aa4a5a4e647cd7e0442669f89d96e30d1d382 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 01:41:45 +0800 Subject: [PATCH 04/10] feat: add memory to default protected tools + system prompt + compress nudge config.ts: COMPRESS_DEFAULT_PROTECTED_TOOLS adds 'memory'. system.ts: imports MEMORY_GUIDELINES from cc-alg, adds MEMORY section + memory tool to TOOLS + updates WHEN NOT TO COMPRESS. inject.ts: nudge prepends 'record memory before compressing' reminder when compressible ranges are shown. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- lib/config.ts | 2 +- lib/messages/inject/inject.ts | 1 + lib/prompts/system.ts | 10 ++++++++-- 3 files changed, 10 insertions(+), 3 deletions(-) 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/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..1784a7a8 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 = ` @@ -28,6 +31,7 @@ You have five context-management tools: - \`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: From 7d3014fadfa81f0d1ece08a2c1fa119b548613fa Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 01:41:55 +0800 Subject: [PATCH 05/10] feat: /acp memory command (list | forget) + dispatch wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/commands/memory.ts: handleMemoryCommand β€” list shows mem_NNN entries with topic; forget marks forgotten, next compress cleans it. Wired into hooks.ts dispatch, commands barrel, and help listing. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- lib/commands/help.ts | 1 + lib/commands/index.ts | 1 + lib/commands/memory.ts | 90 ++++++++++++++++++++++++++++++++++++++++++ lib/hooks.ts | 6 +++ 4 files changed, 98 insertions(+) create mode 100644 lib/commands/memory.ts 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..02a92d8c --- /dev/null +++ b/lib/commands/memory.ts @@ -0,0 +1,90 @@ +import type { Logger } from "../logger" +import type { SessionState } from "../state" +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: any[] +} + +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 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/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__") } From 2e687750c72bf771bce3998ad8f2234a55bcfac4 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 01:42:02 +0800 Subject: [PATCH 06/10] feat: register memory tool + upgrade cc-alg dep to ^1.1.0 index.ts: registers memory tool and adds memory:allow to default permissions. package.json: context-compress-algorithms ^1.0.0 -> ^1.1.0 for MEMORY_GUIDELINES. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- index.ts | 7 ++++--- package.json | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index a168ad95..1f1c2b0f 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) => { @@ -118,9 +120,7 @@ const server: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { toolsToAdd.push("compress", "decompress", "search_context", "acp_status") - } - - if (toolsToAdd.length > 0) { + } if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] opencodeConfig.experimental = { ...opencodeConfig.experimental, @@ -134,6 +134,7 @@ const server: Plugin = (async (ctx) => { ...permission, compress: config.compress.permission, acp_status: "allow", + memory: "allow", } as typeof permission } 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", From fec0bf10b4a613b30593ac79d665635813921fa4 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 01:42:10 +0800 Subject: [PATCH 07/10] test: memory feature (14 tests) + devlog Tests cover: recordMemory/forgetMemory/listMemories/getActiveMemoryMessageIds helpers, formatMemoryId padding, filterProtectedToolMessages exclusion + forget-unprotection, and system prompt MEMORY section presence. All 851 tests pass. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- devlog/2026-07-23_memory-tool/REQ.md | 76 +++++++++ devlog/2026-07-23_memory-tool/WORKLOG.md | 64 ++++++++ tests/memory-feature.test.ts | 196 +++++++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 devlog/2026-07-23_memory-tool/REQ.md create mode 100644 devlog/2026-07-23_memory-tool/WORKLOG.md create mode 100644 tests/memory-feature.test.ts 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/tests/memory-feature.test.ts b/tests/memory-feature.test.ts new file mode 100644 index 00000000..b030c69c --- /dev/null +++ b/tests/memory-feature.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + recordMemory, + forgetMemory, + listMemories, + getActiveMemoryMessageIds, + 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("getActiveMemoryMessageIds 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 = getActiveMemoryMessageIds(state) + assert.equal(ids.size, 1) + assert.ok(ids.has("msg_forgotten")) + assert.ok(!ids.has("msg_active")) +}) + +test("getActiveMemoryMessageIds is defensive when state.memories is undefined", () => { + const state = makeState() + ;(state as any).memories = undefined + const ids = getActiveMemoryMessageIds(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", + ) +}) From d7fa9a516ffb65e59004130aa7f9dc9447fe838c Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 02:18:30 +0800 Subject: [PATCH 08/10] fix: resolve cc-alg 1.1.0 from registry (was local file: dep) Lock file pointed at local ../context-compress-algorithms from dev install. Now resolves 1.1.0 from npm registry so CI can install. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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" }, From f63980d39fe0b6a5a6ed3efe81993276e658472e Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 09:13:37 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20ren?= =?UTF-8?q?ame=20getForgottenMemoryMessageIds=20+=20persist=20record/forge?= =?UTF-8?q?t=20+=20type=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-agent review findings: 1. getActiveMemoryMessageIds β†’ getForgottenMemoryMessageIds (name returned forgotten IDs, not active β€” both agents flagged). 2. recordMemory (tool.ts) and forgetMemory (command) now call saveSessionState explicitly β€” previously relied on incidental transform saves that aren't guaranteed (Oracle HIGH). 3. MemoryCommandContext.messages: any[] β†’ WithParts[] to match all 7 sibling commands (Agent #2). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- lib/commands/memory.ts | 6 ++++-- lib/compress/range.ts | 4 ++-- lib/memory/index.ts | 2 +- lib/memory/state.ts | 2 +- lib/memory/tool.ts | 2 ++ 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/commands/memory.ts b/lib/commands/memory.ts index 02a92d8c..53c2cc8b 100644 --- a/lib/commands/memory.ts +++ b/lib/commands/memory.ts @@ -1,5 +1,6 @@ import type { Logger } from "../logger" -import type { SessionState } from "../state" +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" @@ -9,7 +10,7 @@ export interface MemoryCommandContext { state: SessionState logger: Logger sessionId: string - messages: any[] + messages: WithParts[] } export async function handleMemoryCommand( @@ -69,6 +70,7 @@ export async function handleMemoryCommand( ) return } + await saveSessionState(state, logger) await sendIgnoredMessage( client, sessionId, diff --git a/lib/compress/range.ts b/lib/compress/range.ts index d5be1aa5..59b44cc4 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -1,7 +1,7 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" import { countMessageCharacters, countTokens } from "../token-utils" -import { getActiveMemoryMessageIds } from "../memory" +import { getForgottenMemoryMessageIds } from "../memory" import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool" import { finalizeSession, @@ -128,7 +128,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType ({ ...plan, diff --git a/lib/memory/index.ts b/lib/memory/index.ts index 38d468c0..1fbf5109 100644 --- a/lib/memory/index.ts +++ b/lib/memory/index.ts @@ -3,6 +3,6 @@ export { recordMemory, forgetMemory, listMemories, - getActiveMemoryMessageIds, + getForgottenMemoryMessageIds, formatMemoryId, } from "./state" diff --git a/lib/memory/state.ts b/lib/memory/state.ts index 3c82e4d3..e949472e 100644 --- a/lib/memory/state.ts +++ b/lib/memory/state.ts @@ -36,7 +36,7 @@ export function listMemories(state: SessionState): MemoryEntry[] { return Array.from(state.memories.entries.values()).sort((a, b) => a.createdAt - b.createdAt) } -export function getActiveMemoryMessageIds(state: SessionState): Set { +export function getForgottenMemoryMessageIds(state: SessionState): Set { const forgotten = new Set() if (!state.memories) return forgotten for (const entry of state.memories.entries.values()) { diff --git a/lib/memory/tool.ts b/lib/memory/tool.ts index f8a6573f..a82a9522 100644 --- a/lib/memory/tool.ts +++ b/lib/memory/tool.ts @@ -1,6 +1,7 @@ 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. @@ -24,6 +25,7 @@ export function createMemoryTool(ctx: ToolContext): ReturnType { 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}.` }, }) From 4eabad078212d7d2b6e2bceb45a8dec2fbeab99a Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 24 Jul 2026 09:13:37 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20ind?= =?UTF-8?q?ex.ts=20prettier=20regression=20+=20tool=20count=20six=20+=20in?= =?UTF-8?q?tegration/persistence=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. index.ts:123 formatting regression (collapsed } and if onto one line) β€” restored proper line break (Agent #2 HIGH). 2. system.ts: 'five context-management tools' β†’ 'six' (memory added, count was stale). 3. Added 4 tests: integration recordβ†’survives, integration forgetβ†’consumed, persistence round-trip, tool-count assertion. 18 memory tests total, 855 overall. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- index.ts | 4 +- lib/prompts/system.ts | 2 +- tests/memory-feature.test.ts | 102 +++++++++++++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index 1f1c2b0f..5c5fef40 100644 --- a/index.ts +++ b/index.ts @@ -120,7 +120,9 @@ const server: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { toolsToAdd.push("compress", "decompress", "search_context", "acp_status") - } if (toolsToAdd.length > 0) { + } + + if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] opencodeConfig.experimental = { ...opencodeConfig.experimental, diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index 1784a7a8..cac9ae18 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -24,7 +24,7 @@ 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" })\`. diff --git a/tests/memory-feature.test.ts b/tests/memory-feature.test.ts index b030c69c..05b15ae5 100644 --- a/tests/memory-feature.test.ts +++ b/tests/memory-feature.test.ts @@ -4,7 +4,7 @@ import { recordMemory, forgetMemory, listMemories, - getActiveMemoryMessageIds, + getForgottenMemoryMessageIds, formatMemoryId, } from "../lib/memory/state" import { filterProtectedToolMessages } from "../lib/compress/protected-content" @@ -76,21 +76,21 @@ test("listMemories returns entries sorted by createdAt ascending", () => { assert.equal(list[1]!.topic, "second") }) -test("getActiveMemoryMessageIds returns messageIds of forgotten entries only", () => { +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 = getActiveMemoryMessageIds(state) + const ids = getForgottenMemoryMessageIds(state) assert.equal(ids.size, 1) assert.ok(ids.has("msg_forgotten")) assert.ok(!ids.has("msg_active")) }) -test("getActiveMemoryMessageIds is defensive when state.memories is undefined", () => { +test("getForgottenMemoryMessageIds is defensive when state.memories is undefined", () => { const state = makeState() ;(state as any).memories = undefined - const ids = getActiveMemoryMessageIds(state) + const ids = getForgottenMemoryMessageIds(state) assert.equal(ids.size, 0) }) @@ -194,3 +194,95 @@ test("SYSTEM prompt marks memory as protected in WHEN NOT TO COMPRESS", () => { "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") +})