diff --git a/packages/opencode/migration/20260331180000_add_memory_table/migration.sql b/packages/opencode/migration/20260331180000_add_memory_table/migration.sql new file mode 100644 index 000000000000..de11ae65aee0 --- /dev/null +++ b/packages/opencode/migration/20260331180000_add_memory_table/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `memory` ( + `id` text PRIMARY KEY NOT NULL, + `project_path` text NOT NULL, + `type` text NOT NULL, + `topic` text NOT NULL, + `content` text NOT NULL, + `session_id` text, + `access_count` integer DEFAULT 0 NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `memory_project_topic_idx` ON `memory` (`project_path`, `topic`); +--> statement-breakpoint +CREATE INDEX `memory_project_idx` ON `memory` (`project_path`); +--> statement-breakpoint +CREATE INDEX `memory_type_idx` ON `memory` (`type`); diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f86d8d32af60..d5647ae1ff71 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1076,6 +1076,14 @@ export namespace Config { .describe("Timeout in milliseconds for model context protocol (MCP) requests"), }) .optional(), + memory: z + .object({ + enabled: z.boolean().default(true).describe("Enable auto-memory for cross-session learning"), + auto_extract: z.boolean().default(true).describe("Automatically extract memories from session events"), + max_memory_lines: z.number().default(200).describe("Maximum lines of memory to inject into system prompt"), + }) + .optional() + .describe("Auto-memory configuration for persistent cross-session learning"), }) .strict() .meta({ diff --git a/packages/opencode/src/memory/extractor.ts b/packages/opencode/src/memory/extractor.ts new file mode 100644 index 000000000000..d05b47e030ce --- /dev/null +++ b/packages/opencode/src/memory/extractor.ts @@ -0,0 +1,269 @@ +import type { MemoryType } from "./types" +import { MemoryStore } from "./store" +import { Log } from "../util/log" + +const log = Log.create({ service: "memory.extractor" }) + +const CONFIG_FILES = new Set([ + "package.json", + "tsconfig.json", + ".env", + ".env.local", + ".eslintrc", + ".prettierrc", + "vitest.config.ts", + "vite.config.ts", + "webpack.config.js", + "next.config.js", + "tailwind.config.js", + "drizzle.config.ts", +]) + +const PREFERENCE_PATTERNS = [ + /\bno\b.*\buse\b/i, + /\bdon'?t\b/i, + /\binstead\b/i, + /\bprefer\b/i, + /\bnot.*that\b/i, + /\bi want\b.*\binstead\b/i, + /\buse.*rather than\b/i, +] + +interface ExtractorState { + bashCommandCount: Map + lastBashError?: { command: string; error: string } + lastToolCalls: Array<{ tool: string; input: Record }> + currentTurnEdits: Set + projectPath: string + sessionId?: string + detectedTopics: Set +} + +export namespace MemoryExtractor { + const sessions = new Map() + let activeSessionId: string | null = null + let flushTimer: ReturnType | null = null + const pendingSaves: Array<{ type: MemoryType; topic: string; content: string; sessionId?: string }> = [] + const FLUSH_DELAY_MS = 3000 + + function getState(): ExtractorState | null { + return activeSessionId ? sessions.get(activeSessionId) ?? null : null + } + + export function init(projectPath: string, sessionId?: string) { + const sid = sessionId ?? "__default__" + sessions.set(sid, { + bashCommandCount: new Map(), + lastToolCalls: [], + currentTurnEdits: new Set(), + projectPath, + sessionId, + detectedTopics: new Set(), + }) + activeSessionId = sid + pendingSaves.length = 0 + } + + export function reset() { + try { + flushPending() + } catch (err) { + log.warn("error during flush in reset", { error: String(err) }) + } + if (activeSessionId) { + sessions.delete(activeSessionId) + activeSessionId = null + } + } + + /** Flush any buffered memory saves (debounced writes) */ + export function flushPending() { + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + while (pendingSaves.length > 0) { + const item = pendingSaves.shift()! + commitSave(item) + } + } + + function scheduleSave(input: { type: MemoryType; topic: string; content: string; sessionId?: string }) { + pendingSaves.push(input) + if (!flushTimer) { + flushTimer = setTimeout(() => { + flushPending() + }, FLUSH_DELAY_MS) + } + } + + export function onToolCall(tool: string, input: Record) { + const state = getState() + if (!state) return + + // Track bash commands + if (tool === "bash") { + const cmd = (input.command as string) || "" + const base = normalizeCommand(cmd) + if (base) { + const count = (state.bashCommandCount.get(base) ?? 0) + 1 + state.bashCommandCount.set(base, count) + + // build-command pattern: same command 3+ times + if (count >= 3 && !state.detectedTopics.has(`build:${base}`)) { + state.detectedTopics.add(`build:${base}`) + saveMemory(state, { + type: "build-command", + topic: `build:${base}`, + content: `Frequently used command: ${cmd} (used ${count} times)`, + }) + } + } + } + + // Track file edits + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + const filePath = (input.file as string) || (input.path as string) || "" + state.currentTurnEdits.add(filePath) + + // config-pattern: editing config files + const basename = filePath.split(/[/\\]/).pop() ?? "" + if (CONFIG_FILES.has(basename) && !state.detectedTopics.has(`config:${basename}`)) { + state.detectedTopics.add(`config:${basename}`) + saveMemory(state, { + type: "config-pattern", + topic: `config:${basename}`, + content: `Config file ${basename} was modified in this project`, + }) + } + + // error-solution: fix after bash error (detected on tool call, not just result) + if (state.lastBashError) { + const topic = `fix:${filePath}` + if (!state.detectedTopics.has(topic)) { + state.detectedTopics.add(topic) + saveMemory(state, { + type: "error-solution", + topic, + content: `Error with command "${state.lastBashError.command}" was fixed by editing ${filePath}. Error: ${state.lastBashError.error.slice(0, 200)}`, + }) + } + state.lastBashError = undefined + } + } + + state.lastToolCalls.push({ tool, input }) + } + + export function onToolResult(tool: string, input: Record, output: string, exitCode?: number) { + const state = getState() + if (!state) return + + // Track bash failures for error-solution pattern + if (tool === "bash" && exitCode && exitCode !== 0) { + const cmd = (input.command as string) || "" + state.lastBashError = { command: cmd, error: output.slice(0, 500) } + } + + // Detect fix after error: if there was a bash error and now a write/edit follows + if (state.lastBashError) { + if (tool === "write" || tool === "edit" || tool === "patch") { + const filePath = (input.file as string) || (input.path as string) || "" + const topic = `fix:${filePath}` + if (!state.detectedTopics.has(topic)) { + state.detectedTopics.add(topic) + saveMemory(state, { + type: "error-solution", + topic, + content: `Error with command "${state.lastBashError.command}" was fixed by editing ${filePath}. Error: ${state.lastBashError.error.slice(0, 200)}`, + }) + } + state.lastBashError = undefined + } else if (tool === "bash" && !exitCode) { + // Successful bash after error might be the fix + const topic = `fix:${normalizeCommand((input.command as string) || "")}` + if (!state.detectedTopics.has(topic)) { + state.detectedTopics.add(topic) + saveMemory(state, { + type: "error-solution", + topic, + content: `Error with command "${state.lastBashError.command}" was resolved with: ${(input.command as string) || ""}`, + }) + } + state.lastBashError = undefined + } + } + } + + export function onUserMessage(text: string) { + const state = getState() + if (!state) return + + // Check for preference patterns β€” require 2+ matches to reduce false positives + let matchCount = 0 + for (const pattern of PREFERENCE_PATTERNS) { + if (pattern.test(text)) matchCount++ + } + if (matchCount >= 2) { + const topic = `pref:${text.slice(0, 80).replace(/[^a-zA-Z0-9]/g, "_")}` + if (!state.detectedTopics.has(topic)) { + state.detectedTopics.add(topic) + saveMemory(state, { + type: "preference", + topic, + content: `User preference: ${text.slice(0, 300)}`, + }) + } + } + + // Reset per-turn tracking + if (state.currentTurnEdits.size >= 3) { + const topic = `decision:${Date.now()}` + if (!state.detectedTopics.has(topic)) { + state.detectedTopics.add(topic) + const files = Array.from(state.currentTurnEdits).join(", ") + saveMemory(state, { + type: "decision", + topic, + content: `Architecture decision: ${state.currentTurnEdits.size} files edited in one turn: ${files}`, + }) + } + } + state.currentTurnEdits.clear() + } + + function saveMemory(state: ExtractorState, input: { type: MemoryType; topic: string; content: string }) { + scheduleSave({ ...input, sessionId: state.sessionId }) + } + + function commitSave(input: { type: MemoryType; topic: string; content: string; sessionId?: string }) { + const state = getState() + if (!state) return + try { + MemoryStore.save({ + projectPath: state.projectPath, + type: input.type, + topic: input.topic, + content: input.content, + sessionId: input.sessionId, + }) + log.debug("saved memory", { type: input.type, topic: input.topic }) + } catch (err) { + log.warn("failed to save memory", { error: String(err) }) + } + } + + function normalizeCommand(cmd: string): string { + const tokens = cmd.trim().split(/\s+/).filter(Boolean) + if (tokens.length === 0) return "" + // Keep command name + first 2 meaningful tokens (skip flags) + const result: string[] = [tokens[0]] + let argCount = 0 + for (let i = 1; i < tokens.length && argCount < 2; i++) { + if (tokens[i].startsWith("-")) continue + result.push(tokens[i]) + argCount++ + } + return result.join(" ") + } +} diff --git a/packages/opencode/src/memory/index.ts b/packages/opencode/src/memory/index.ts new file mode 100644 index 000000000000..6e96c3dc5ff2 --- /dev/null +++ b/packages/opencode/src/memory/index.ts @@ -0,0 +1,5 @@ +export { MemoryType, type Memory } from "./types" +export { MemoryTable } from "./memory.sql" +export { MemoryStore } from "./store" +export { MemoryExtractor } from "./extractor" +export { MemoryFile } from "./memory-file" diff --git a/packages/opencode/src/memory/injector.ts b/packages/opencode/src/memory/injector.ts new file mode 100644 index 000000000000..c97258e4585f --- /dev/null +++ b/packages/opencode/src/memory/injector.ts @@ -0,0 +1,25 @@ +import { Log } from "../util/log" +import { MemoryFile } from "./memory-file" + +const log = Log.create({ service: "memory.injector" }) + +export namespace MemoryInjector { + export async function inject(projectDir: string, maxLines: number): Promise { + try { + const content = await MemoryFile.readMemoryFile(projectDir) + if (!content) return [] + + const lines = content.split("\n") + if (lines.length > maxLines) { + const trimmed = lines.slice(0, maxLines).join("\n") + log.debug("memory file trimmed", { original: lines.length, max: maxLines }) + return [`Memory from previous sessions:\n${trimmed}`] + } + + return [`Memory from previous sessions:\n${content}`] + } catch (err) { + log.warn("failed to inject memory", { error: String(err) }) + return [] + } + } +} diff --git a/packages/opencode/src/memory/memory-file.ts b/packages/opencode/src/memory/memory-file.ts new file mode 100644 index 000000000000..e79c70e80533 --- /dev/null +++ b/packages/opencode/src/memory/memory-file.ts @@ -0,0 +1,81 @@ +import path from "path" +import fs from "fs/promises" +import { Log } from "../util/log" +import { MemoryStore } from "./store" + +const log = Log.create({ service: "memory.file" }) + +const TYPE_LABELS: Record = { + "error-solution": "πŸ”§ Error Solutions", + "build-command": "⌨️ Build Commands", + preference: "πŸ’‘ Preferences", + decision: "πŸ—οΈ Architecture Decisions", + "config-pattern": "βš™οΈ Config Patterns", + general: "πŸ“ General", +} + +export namespace MemoryFile { + export function memoryFilePath(projectDir: string) { + return path.join(projectDir, ".opencode", "MEMORY.md") + } + + export async function readMemoryFile(projectDir: string): Promise { + const filepath = memoryFilePath(projectDir) + try { + return await fs.readFile(filepath, "utf-8") + } catch { + return null + } + } + + export async function writeMemoryFile(projectDir: string, content: string): Promise { + const filepath = memoryFilePath(projectDir) + await fs.mkdir(path.dirname(filepath), { recursive: true }) + // Write to temp file first, then rename for atomicity + const tmpPath = filepath + ".tmp" + await fs.writeFile(tmpPath, content, "utf-8") + await fs.rename(tmpPath, filepath) + } + + export async function updateMemoryFile(projectDir: string): Promise { + const sections = MemoryStore.compact(projectDir) + if (Object.keys(sections).length === 0) return + + const lines: string[] = [ + "# Auto-generated Memory", + "", + `> Last updated: ${new Date().toISOString()}`, + "> This file is automatically maintained by opencode memory.", + "> You can edit it β€” your edits are preserved on next extraction.", + "> Add to .gitignore if you don't want to track it.", + "", + ] + + for (const [type, memories] of Object.entries(sections)) { + lines.push(`## ${TYPE_LABELS[type] ?? type}`) + lines.push("") + for (const m of memories) { + lines.push(`- ${m.content}`) + } + lines.push("") + } + + const filepath = memoryFilePath(projectDir) + await fs.mkdir(path.dirname(filepath), { recursive: true }) + + // Append .opencode/MEMORY.md to .gitignore if it exists + try { + const gitignorePath = path.join(projectDir, ".gitignore") + const gitignore = await fs.readFile(gitignorePath, "utf-8") + const entry = ".opencode/MEMORY.md" + if (!gitignore.includes(entry)) { + await fs.appendFile(gitignorePath, `\n${entry}\n`, "utf-8") + } + } catch { + // .gitignore doesn't exist β€” don't create it + } + + await writeMemoryFile(projectDir, lines.join("\n")) + log.info("updated memory file", { path: memoryFilePath(projectDir) }) + } +} diff --git a/packages/opencode/src/memory/memory.sql.ts b/packages/opencode/src/memory/memory.sql.ts new file mode 100644 index 000000000000..d930aabf9bfb --- /dev/null +++ b/packages/opencode/src/memory/memory.sql.ts @@ -0,0 +1,21 @@ +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../storage/schema.sql" + +export const MemoryTable = sqliteTable( + "memory", + { + id: text().primaryKey(), + project_path: text().notNull(), + type: text().notNull(), + topic: text().notNull(), + content: text().notNull(), + session_id: text(), + access_count: integer().notNull().default(0), + ...Timestamps, + }, + (table) => [ + index("memory_project_topic_idx").on(table.project_path, table.topic), + index("memory_project_idx").on(table.project_path), + index("memory_type_idx").on(table.type), + ], +) diff --git a/packages/opencode/src/memory/store.ts b/packages/opencode/src/memory/store.ts new file mode 100644 index 000000000000..e80df495e492 --- /dev/null +++ b/packages/opencode/src/memory/store.ts @@ -0,0 +1,126 @@ +import { Database, eq, desc, like, sql, and } from "@/storage/db" +import { MemoryTable } from "./memory.sql" +import type { MemoryType } from "./types" +import { Log } from "../util/log" + +const log = Log.create({ service: "memory.store" }) + +export namespace MemoryStore { + export function save(input: { + projectPath: string + type: MemoryType + topic: string + content: string + sessionId?: string + }) { + return Database.transaction((db) => { + // UPSERT: merge with existing memory on same topic+project to avoid duplicates + const existing = db + .select() + .from(MemoryTable) + .where( + and( + eq(MemoryTable.topic, input.topic), + eq(MemoryTable.project_path, input.projectPath), + ), + ) + .limit(1) + .get() + + if (existing) { + // Merge content: append new info if different, truncate input first + const truncatedInput = input.content.length > 200 ? input.content.slice(0, 200) : input.content + const mergedContent = + existing.content.includes(truncatedInput) + ? existing.content + : `${existing.content}\n${truncatedInput}` + db.update(MemoryTable) + .set({ + content: mergedContent.slice(0, 500), + access_count: sql`${MemoryTable.access_count} + 1`, + time_updated: Date.now(), + }) + .where(eq(MemoryTable.id, existing.id)) + .run() + log.debug("merged memory", { topic: input.topic, id: existing.id }) + } else { + db.insert(MemoryTable) + .values({ + id: crypto.randomUUID(), + project_path: input.projectPath, + type: input.type, + topic: input.topic, + content: input.content, + session_id: input.sessionId, + access_count: 0, + }) + .run() + log.debug("saved memory", { type: input.type, topic: input.topic }) + } + }) + } + + export function search(query: string, projectPath?: string) { + return Database.use((db) => { + const conditions = [like(MemoryTable.content, `%${query}%`)] + if (projectPath) conditions.push(eq(MemoryTable.project_path, projectPath)) + return db + .select() + .from(MemoryTable) + .where(and(...conditions)) + .orderBy(desc(MemoryTable.access_count), desc(MemoryTable.time_created)) + .limit(20) + .all() + }) + } + + export function getByTopic(topic: string, projectPath?: string) { + return Database.use((db) => { + const conditions = [eq(MemoryTable.topic, topic)] + if (projectPath) conditions.push(eq(MemoryTable.project_path, projectPath)) + return db + .select() + .from(MemoryTable) + .where(and(...conditions)) + .orderBy(desc(MemoryTable.time_created)) + .limit(1) + .get() + }) + } + + export function list(projectPath?: string, limit = 50) { + return Database.use((db) => { + const conditions = projectPath ? [eq(MemoryTable.project_path, projectPath)] : [] + return db + .select() + .from(MemoryTable) + .where(and(...conditions)) + .orderBy(desc(MemoryTable.access_count), desc(MemoryTable.time_created)) + .limit(limit) + .all() + }) + } + + export function delete_(id: string) { + return Database.use((db) => { + db.delete(MemoryTable).where(eq(MemoryTable.id, id)).run() + }) + } + + export function compact(projectPath: string, maxLines = 200) { + const memories = list(projectPath, 100) + let lines = 0 + const sections: Record = {} + + for (const m of memories) { + if (lines >= maxLines) break + const contentLines = m.content.split("\n").length + if (lines + contentLines > maxLines) continue + if (!sections[m.type]) sections[m.type] = [] + sections[m.type].push(m) + lines += contentLines + 2 + } + + return sections + } +} diff --git a/packages/opencode/src/memory/types.ts b/packages/opencode/src/memory/types.ts new file mode 100644 index 000000000000..1330fa6feca8 --- /dev/null +++ b/packages/opencode/src/memory/types.ts @@ -0,0 +1,23 @@ +import z from "zod" + +export const MemoryType = z.enum([ + "error-solution", + "build-command", + "preference", + "decision", + "config-pattern", + "general", +]) +export type MemoryType = z.infer + +export interface Memory { + id: string + projectPath: string + type: MemoryType + topic: string + content: string + createdAt: Date + accessedAt: Date + accessCount: number + sessionId?: string +} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2482e40fb346..cc60a114ed51 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -3,6 +3,9 @@ import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Config } from "@/config/config" +import { MemoryExtractor } from "@/memory/extractor" +import { MemoryFile } from "@/memory/memory-file" +import { Instance } from "@/project/instance" import { Permission } from "@/permission" import { Plugin } from "@/plugin" import { Snapshot } from "@/snapshot" @@ -180,6 +183,9 @@ export namespace SessionProcessor { metadata: value.providerMetadata, } satisfies MessageV2.ToolPart) + // Feed to memory extractor (fire-and-forget) + try { MemoryExtractor.onToolCall(value.toolName, value.input as Record) } catch (err) { log.debug("memory extraction skipped (onToolCall)", { error: String(err) }) } + const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id)) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) @@ -223,6 +229,18 @@ export namespace SessionProcessor { attachments: value.output.attachments, }, }) + + // Feed to memory extractor (fire-and-forget) + try { + const exitCode = (value.output.metadata as Record)?.exitCode as number | undefined + MemoryExtractor.onToolResult( + match.tool, + (value.input ?? match.state.input) as Record, + value.output.output, + exitCode, + ) + } catch (err) { log.debug("memory extraction skipped (onToolResult)", { error: String(err) }) } + delete ctx.toolcalls[value.toolCallId] return } @@ -407,6 +425,19 @@ export namespace SessionProcessor { } ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) + + // Update MEMORY.md from extracted memories (best-effort) + try { + const cfg = yield* config.get() + if (cfg.memory?.enabled !== false && cfg.memory?.auto_extract !== false) { + yield* Effect.sync(() => MemoryExtractor.flushPending()) + yield* Effect.promise(() => MemoryFile.updateMemoryFile(Instance.directory)) + yield* Effect.sync(() => MemoryExtractor.reset()) + } + } catch (err) { + // Memory file update is best-effort + log.debug("memory file update skipped", { error: String(err) }) + } }) const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { @@ -443,6 +474,17 @@ export namespace SessionProcessor { ctx.needsCompaction = false ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true + // Initialize memory extractor + try { + const cfg = yield* config.get() + if (cfg.memory?.enabled !== false && cfg.memory?.auto_extract !== false) { + MemoryExtractor.init(Instance.directory, ctx.sessionID) + } + } catch (err) { + // Memory extraction is best-effort + log.debug("memory extractor init skipped", { error: String(err) }) + } + return yield* Effect.gen(function* () { yield* Effect.gen(function* () { ctx.currentText = undefined diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 78f4fae52111..96204feb52d8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -16,6 +16,8 @@ import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { InstructionPrompt } from "./instruction" +import { MemoryInjector } from "../memory/injector" +import { Config } from "../config/config" import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -1504,15 +1506,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() => + const [skills, env, instructions, modelMsgs, memory] = yield* Effect.promise(() => Promise.all([ SystemPrompt.skills(agent), SystemPrompt.environment(model), InstructionPrompt.system(), MessageV2.toModelMessages(msgs, model), + (async () => { + const cfg = await Config.get() + if (cfg.memory?.enabled === false) return [] as string[] + const maxLines = cfg.memory?.max_memory_lines ?? 200 + return MemoryInjector.inject(Instance.directory, maxLines) + })(), ]), ) - const system = [...env, ...(skills ? [skills] : []), ...instructions] + const system = [...env, ...(skills ? [skills] : []), ...instructions, ...memory] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts index eebb651a53b4..6096d9288c63 100644 --- a/packages/opencode/test/memory/abort-leak.test.ts +++ b/packages/opencode/test/memory/abort-leak.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test" +ο»Ώimport { describe, test, expect } from "bun:test" import path from "path" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" diff --git a/packages/opencode/test/memory/extractor.test.ts b/packages/opencode/test/memory/extractor.test.ts new file mode 100644 index 000000000000..bfb8b28d1986 --- /dev/null +++ b/packages/opencode/test/memory/extractor.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Database } from "../../src/storage/db" +import { MemoryExtractor } from "../../src/memory/extractor" +import { MemoryStore } from "../../src/memory/store" + +describe("MemoryExtractor", () => { + const projectPath = "/test/project" + + beforeEach(() => { + const db = Database.Client() + try { db.run(`DELETE FROM memory`) } catch {} + MemoryExtractor.init(projectPath, "test-session") + }) + + afterEach(() => { + const db = Database.Client() + try { db.run(`DELETE FROM memory`) } catch {} + MemoryExtractor.reset() + }) + + test("detects build-command pattern (3+ same bash commands)", () => { + const cmd = { command: "bun run build" } + MemoryExtractor.onToolCall("bash", cmd) + MemoryExtractor.onToolCall("bash", cmd) + MemoryExtractor.onToolCall("bash", cmd) + MemoryExtractor.flushPending() + + const memories = MemoryStore.search("Frequently used command") + expect(memories.length).toBe(1) + expect(memories[0].type).toBe("build-command") + }) + + test("does not false positive build-command with < 3 calls", () => { + const cmd = { command: "bun run build" } + MemoryExtractor.onToolCall("bash", cmd) + MemoryExtractor.onToolCall("bash", cmd) + MemoryExtractor.flushPending() + + const memories = MemoryStore.search("Frequently used command") + expect(memories.length).toBe(0) + }) + + test("detects preference pattern in user messages (2+ patterns required)", () => { + // "No, don't use that. I prefer tabs instead of spaces." matches: + // /\bno\b.*\buse\b/i, /\bdon'?t\b/i, /\binstead\b/i, /\bprefer\b/i + MemoryExtractor.onUserMessage("No, don't use that. I prefer tabs instead of spaces.") + MemoryExtractor.flushPending() + + const memories = MemoryStore.list(projectPath) + expect(memories.length).toBe(1) + expect(memories[0].type).toBe("preference") + }) + + test("does not false positive preference with only 1 pattern match", () => { + // "don't worry about it" only matches /\bdon'?t\b/i β€” not enough + MemoryExtractor.onUserMessage("don't worry about it") + MemoryExtractor.flushPending() + + const memories = MemoryStore.list(projectPath) + expect(memories.length).toBe(0) + }) + + test("detects config-pattern when editing config files", () => { + MemoryExtractor.onToolCall("write", { file: "/project/package.json", content: "{}" }) + MemoryExtractor.flushPending() + + const memories = MemoryStore.list(projectPath) + expect(memories.length).toBe(1) + expect(memories[0].type).toBe("config-pattern") + }) + + test("detects decision pattern (3+ files edited in one turn)", () => { + MemoryExtractor.onToolCall("edit", { file: "/project/a.ts", oldText: "", newText: "" }) + MemoryExtractor.onToolCall("edit", { file: "/project/b.ts", oldText: "", newText: "" }) + MemoryExtractor.onToolCall("edit", { file: "/project/c.ts", oldText: "", newText: "" }) + MemoryExtractor.onUserMessage("continue") + MemoryExtractor.flushPending() + + const memories = MemoryStore.list(projectPath) + expect(memories.some(m => m.type === "decision")).toBe(true) + }) + + test("detects error-solution pattern", () => { + MemoryExtractor.onToolCall("bash", { command: "npm test" }) + MemoryExtractor.onToolResult("bash", { command: "npm test" }, "Error: test failed", 1) + MemoryExtractor.onToolCall("edit", { file: "/project/test.ts", oldText: "bad", newText: "good" }) + MemoryExtractor.flushPending() + + const memories = MemoryStore.list(projectPath) + expect(memories.some(m => m.type === "error-solution")).toBe(true) + }) + + test("does not detect false positive for normal operations", () => { + MemoryExtractor.onToolCall("bash", { command: "ls" }) + MemoryExtractor.onToolResult("bash", { command: "ls" }, "file1\nfile2", 0) + MemoryExtractor.onUserMessage("show me the files") + MemoryExtractor.flushPending() + + const memories = MemoryStore.list(projectPath) + // Should only have no memories since: bash used once, no preferences, no config edits + expect(memories.length).toBe(0) + }) + + test("debounced saves are flushed after timeout", async () => { + MemoryExtractor.onToolCall("bash", { command: "npm run build" }) + MemoryExtractor.onToolCall("bash", { command: "npm run build" }) + MemoryExtractor.onToolCall("bash", { command: "npm run build" }) + // Don't flush yet β€” saves should be buffered + + let memories = MemoryStore.search("Frequently used command") + expect(memories.length).toBe(0) // Not flushed yet + + // Wait for flush timer (FLUSH_DELAY_MS = 3000, but we'll call flushPending) + await new Promise(r => setTimeout(r, 100)) + MemoryExtractor.flushPending() + + memories = MemoryStore.search("Frequently used command") + expect(memories.length).toBe(1) + }) + + test("reset flushes pending saves", () => { + MemoryExtractor.onToolCall("bash", { command: "npm run build" }) + MemoryExtractor.onToolCall("bash", { command: "npm run build" }) + MemoryExtractor.onToolCall("bash", { command: "npm run build" }) + + MemoryExtractor.reset() // Should flush pending + const memories = MemoryStore.search("Frequently used command") + expect(memories.length).toBe(1) + }) +}) diff --git a/packages/opencode/test/memory/store.test.ts b/packages/opencode/test/memory/store.test.ts new file mode 100644 index 000000000000..dd01cd011be1 --- /dev/null +++ b/packages/opencode/test/memory/store.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Database } from "../../src/storage/db" +import { randomUUID } from "crypto" +import { MemoryStore } from "../../src/memory/store" +import { MemoryFile } from "../../src/memory/memory-file" +import path from "path" +import fs from "fs/promises" + +describe("MemoryStore", () => { + const projectPath = "/test/project" + + beforeEach(() => { + const db = Database.Client() + try { db.run(`DELETE FROM memory`) } catch {} + }) + + afterEach(() => { + const db = Database.Client() + try { db.run(`DELETE FROM memory`) } catch {} + }) + + test("save and retrieve a memory", () => { + MemoryStore.save({ + projectPath, + type: "general", + topic: "test-topic", + content: "Test content", + }) + + const memories = MemoryStore.list(projectPath) + expect(memories.length).toBe(1) + expect(memories[0].topic).toBe("test-topic") + expect(memories[0].content).toBe("Test content") + expect(memories[0].type).toBe("general") + expect(memories[0].access_count).toBe(0) + }) + + test("search memories by content", () => { + MemoryStore.save({ projectPath, type: "general", topic: "t1", content: "hello world" }) + MemoryStore.save({ projectPath, type: "general", topic: "t2", content: "foo bar" }) + + const results = MemoryStore.search("hello") + expect(results.length).toBe(1) + expect(results[0].topic).toBe("t1") + }) + + test("getByTopic returns latest memory for topic (UPSERT merges)", async () => { + MemoryStore.save({ projectPath, type: "general", topic: "shared", content: "first" }) + // Ensure different timestamp + await new Promise(r => setTimeout(r, 10)) + MemoryStore.save({ projectPath, type: "general", topic: "shared", content: "second" }) + + const result = MemoryStore.getByTopic("shared", projectPath) + expect(result).toBeDefined() + // UPSERT merges content when topic+project match + expect(result!.content).toContain("first") + expect(result!.content).toContain("second") + expect(result!.access_count).toBe(1) // UPSERT increments on merge + }) + + test("UPSERT increments access_count on merge", () => { + MemoryStore.save({ projectPath, type: "general", topic: "t1", content: "initial" }) + MemoryStore.save({ projectPath, type: "general", topic: "t1", content: "updated" }) + const result = MemoryStore.getByTopic("t1", projectPath) + expect(result!.access_count).toBe(1) // UPSERT increments once on merge + }) + + test("delete removes a memory", () => { + MemoryStore.save({ projectPath, type: "general", topic: "t1", content: "test" }) + const memories = MemoryStore.list(projectPath) + expect(memories.length).toBe(1) + + MemoryStore.delete_(memories[0].id) + const remaining = MemoryStore.list(projectPath) + expect(remaining.length).toBe(0) + }) + + test("compact returns grouped memories", () => { + MemoryStore.save({ projectPath, type: "error-solution", topic: "e1", content: "fixed a bug" }) + MemoryStore.save({ projectPath, type: "preference", topic: "p1", content: "use tabs" }) + MemoryStore.save({ projectPath, type: "error-solution", topic: "e2", content: "another fix" }) + + const sections = MemoryStore.compact(projectPath) + expect(sections["error-solution"]).toBeDefined() + expect(sections["preference"]).toBeDefined() + }) +}) + +describe("MemoryFile", () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = path.join(import.meta.dirname, ".tmp-memory-test-" + randomUUID().slice(0, 8)) + await fs.mkdir(tmpDir, { recursive: true }) + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("write and read memory file", async () => { + await MemoryFile.writeMemoryFile(tmpDir, "# Test\n- item1\n- item2") + const content = await MemoryFile.readMemoryFile(tmpDir) + expect(content).toContain("Test") + expect(content).toContain("item1") + }) + + test("readMemoryFile returns null for non-existent file", async () => { + const content = await MemoryFile.readMemoryFile(tmpDir) + expect(content).toBeNull() + }) + + test("updateMemoryFile creates file from store", async () => { + // We need to insert into the DB first + const db = Database.Client() + try { db.run(`DELETE FROM memory`) } catch {} + MemoryStore.save({ projectPath: tmpDir, type: "preference", topic: "t1", content: "use spaces" }) + + await MemoryFile.updateMemoryFile(tmpDir) + const content = await MemoryFile.readMemoryFile(tmpDir) + expect(content).toContain("use spaces") + expect(content).toContain("Preferences") + + // Cleanup + try { db.run(`DELETE FROM memory`) } catch {} + }) +})