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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`);
8 changes: 8 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
269 changes: 269 additions & 0 deletions packages/opencode/src/memory/extractor.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>
lastBashError?: { command: string; error: string }
lastToolCalls: Array<{ tool: string; input: Record<string, unknown> }>
currentTurnEdits: Set<string>
projectPath: string
sessionId?: string
detectedTopics: Set<string>
}

export namespace MemoryExtractor {
const sessions = new Map<string, ExtractorState>()
let activeSessionId: string | null = null
let flushTimer: ReturnType<typeof setTimeout> | 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<string, unknown>) {
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<string, unknown>, 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(" ")
}
}
5 changes: 5 additions & 0 deletions packages/opencode/src/memory/index.ts
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions packages/opencode/src/memory/injector.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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 []
}
}
}
Loading
Loading