From 68ebcd023b38c59a636b557c66fc5841b4339653 Mon Sep 17 00:00:00 2001 From: Rohith AP Date: Wed, 22 Apr 2026 17:34:47 +0000 Subject: [PATCH] Add simple scoped memory with global user scope and robust recall --- AGENTS.md | 159 +++++++++++++++++++++++++++++++++----- src/index.ts | 213 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 281 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e46d533..5abff45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,19 +1,140 @@ -# Agent Guidelines - -## Commands -- **Install**: `bun install` -- **Type check**: `bun run tsc --noEmit` -- **Run**: `bun run index.ts` -- **Test manually**: `bun -e "import { MemoryPlugin } from './index.ts'; ..."` - -## Code Style -- **Runtime**: Bun (use Bun APIs: `Bun.file()`, `Bun.write()`, `Bun.Glob`, `Bun.$`) -- **Imports**: Use `import type` for type-only imports (`verbatimModuleSyntax`) -- **Types**: Strict mode enabled, handle `undefined` from indexed access (`noUncheckedIndexedAccess`) -- **Naming**: camelCase for functions/variables, PascalCase for types/interfaces -- **Exports**: Re-export public API from `index.ts`, implementation in `src/` - -## Plugin Structure -- Tools use `@opencode-ai/plugin` `tool()` helper with Zod-like schema (`tool.schema`) -- Plugin exports async function returning `{ tool: { ... } }` -- Memories stored in `.opencode/memory/` as logfmt files +# Global OpenCode Instructions + +## Memory Behavior + +You have access to persistent memory tools: +`memory_remember`, `memory_recall`, `memory_update`, `memory_forget`, and `memory_list`. + +Memory storage is split into two layers: + +- `scope="user"` → global cross-repo memory +- all other scopes → repo-local memory for the current project + +Treat memory as long-term working context, not as a transcript or scratchpad. + +## Core Principle + +Only save memories that are likely to matter in a future session. + +Prefer saving: +- stable user preferences +- recurring workflow habits +- durable project conventions +- finalized design decisions +- recurring blockers or important gotchas + +Do not save: +- temporary task state +- one-off conversational details +- speculative ideas +- transient errors +- secrets, credentials, tokens, or private endpoints + +## Session Start + +Do not call unfiltered `memory_recall` at session start. + +On the first meaningful user request in a session: + +1. Recall stable cross-project user context: + - `memory_recall(scope="user", limit=5)` + +2. If working inside a repository, recall repo-relevant memory with a small limit: + - `memory_recall(scope="", limit=8)` + +3. Only recall domain-specific memory when the task clearly touches that area: + - `memory_recall(scope="auth", query="token session cookie", limit=5)` + - `memory_recall(scope="api", query="routing endpoint convention", limit=5)` + +4. Only recall blockers when beginning implementation, debugging, or delivery work: + - `memory_recall(type="blocker", limit=5)` + +Do not load all memories by default. + +## Implicit Saving + +Save memory silently only when the information is stable and likely to be useful again. + +Auto-save when clearly observed: +- user tooling preferences +- user coding style preferences +- repeated workflow choices +- stable project conventions +- final architectural decisions +- recurring blockers or durable gotchas + +Do not auto-save: +- casual personal details +- weakly stated or tentative preferences +- intermediate design discussion +- ephemeral blockers +- transient debugging noise + +## Updating vs Creating + +Prefer `memory_update` when refining an existing stable memory. +Prefer `memory_remember` for a new durable fact. + +Use `memory_recall` before saving only when duplication is likely or when you are unsure whether a matching memory already exists. + +## Memory Format + +Keep each memory: +- atomic +- short +- durable +- reusable + +Good: +- `User prefers bun over npm` +- `User prefers concise answers unless asking for deep comparison` +- `Repo uses uv for Python tooling` +- `Auth uses httpOnly cookies for session tokens` + +Avoid bloated entries. +Include file paths only when they materially improve future usefulness. + +## Types + +Use the most specific type: + +- `preference` for user choices and style +- `pattern` for recurring workflows or conventions +- `context` for stable background that matters across sessions +- `decision` for finalized architectural choices +- `learning` for durable discoveries +- `blocker` for active recurring issues worth checking again + +## Scopes + +Use scopes carefully: + +- `user` for cross-project user preferences and stable personal context +- `` for repo-wide conventions and decisions +- `auth`, `api`, `database`, `testing`, `deployment` only when domain-specific recall will actually help + +Important: +- `user` is global across repositories +- non-`user` scopes are local to the current repo +- do not use a vague generic scope if a repo name or domain scope is better + +## Recall Strategy + +Keep recall targeted and small. + +Default pattern: +1. recall `user` +2. recall current repo scope if relevant +3. recall a domain scope only if the task clearly touches it +4. recall blockers only when implementation or debugging starts + +Do not perform broad multi-scope recall unless clearly necessary. + +## Behavior Rules + +- Never announce memory saves unless the user asks +- Never store secrets or sensitive operational data +- Never use memory as a transcript +- Prefer fewer, higher-signal memories over many noisy ones +- Update contradicted memories when the new information is clearly authoritative +- Forget memories only when they are clearly obsolete or explicitly invalidated diff --git a/src/index.ts b/src/index.ts index 3bbae77..9341a27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,13 @@ +import os from "node:os" +import path from "node:path" import { type Plugin, tool } from "@opencode-ai/plugin" +import { stat } from "node:fs/promises" -let MEMORY_DIR = ".opencode/memory" +let PROJECT_MEMORY_DIR = ".opencode/memory" +let GLOBAL_MEMORY_DIR = path.join(os.homedir(), ".config", "opencode", "memory") -const getMemoryFile = () => { - const date = new Date().toISOString().split("T")[0] - return Bun.file(`${MEMORY_DIR}/${date}.logfmt`) -} - -const ensureDir = async () => { - const dir = Bun.file(MEMORY_DIR) - if (!(await dir.exists())) { - await Bun.$`mkdir -p ${MEMORY_DIR}` - } -} +const USER_SCOPE = "user" +const DELETIONS_FILE = "deletions.logfmt" interface Memory { ts: string @@ -23,6 +18,39 @@ interface Memory { tags?: string[] } +const getDirForScope = (scope?: string) => { + return scope === USER_SCOPE ? GLOBAL_MEMORY_DIR : PROJECT_MEMORY_DIR +} + +const getSearchDirs = (scope?: string) => { + if (scope === USER_SCOPE) return [GLOBAL_MEMORY_DIR] + if (scope) return [PROJECT_MEMORY_DIR] + + // Unscoped recall/list searches both global user memory and project memory + return [...new Set([GLOBAL_MEMORY_DIR, PROJECT_MEMORY_DIR])] +} + +const dirExists = async (dirPath: string) => { + try { + const s = await stat(dirPath) + return s.isDirectory() + } catch { + return false + } +} + +const ensureDir = async (scope?: string) => { + const dirPath = getDirForScope(scope) + if (!(await dirExists(dirPath))) { + await Bun.$`mkdir -p ${dirPath}` + } +} + +const getMemoryFile = (scope?: string) => { + const date = new Date().toISOString().split("T")[0] + return Bun.file(path.join(getDirForScope(scope), `${date}.logfmt`)) +} + const parseLine = (line: string): Memory | null => { const tsMatch = line.match(/ts=([^\s]+)/) const typeMatch = line.match(/type=([^\s]+)/) @@ -51,7 +79,9 @@ const formatMemory = (m: Memory): string => { } const scoreMatch = (memory: Memory, words: string[]): number => { - const searchable = `${memory.type} ${memory.scope} ${memory.content} ${memory.tags?.join(" ") || ""}`.toLowerCase() + const searchable = + `${memory.type} ${memory.scope} ${memory.content} ${memory.tags?.join(" ") || ""}`.toLowerCase() + let score = 0 for (const word of words) { if (searchable.includes(word)) score++ @@ -61,66 +91,86 @@ const scoreMatch = (memory: Memory, words: string[]): number => { return score } -const remember = tool({ - description: "Store a memory (decision, learning, preference, blocker, context, pattern)", - args: { - type: tool.schema - .enum(["decision", "learning", "preference", "blocker", "context", "pattern"]) - .describe("Type of memory"), - scope: tool.schema.string().describe("Scope/area (e.g., auth, api, mobile)"), - content: tool.schema.string().describe("The memory content"), - issue: tool.schema.string().optional().describe("Related GitHub issue (e.g., #51)"), - tags: tool.schema.array(tool.schema.string()).optional().describe("Additional tags"), - }, - async execute(args) { - await ensureDir() - - const ts = new Date().toISOString() - const issue = args.issue ? ` issue=${args.issue}` : "" - const tags = args.tags?.length ? ` tags=${args.tags.join(",")}` : "" - const content = args.content.replace(/"/g, '\\"') - const line = `ts=${ts} type=${args.type} scope=${args.scope} content="${content}"${issue}${tags}\n` +const readMemoriesFromDir = async (dirPath: string): Promise => { + if (!(await dirExists(dirPath))) return [] - const file = getMemoryFile() - const existing = (await file.exists()) ? await file.text() : "" - await Bun.write(file, existing + line) - - return `Remembered: ${args.type} in ${args.scope}` - }, -}) - -const getAllMemories = async (): Promise => { const glob = new Bun.Glob("*.logfmt") - const files = await Array.fromAsync(glob.scan(MEMORY_DIR)) + const files = await Array.fromAsync(glob.scan(dirPath)) if (!files.length) return [] const lines: string[] = [] for (const filename of files) { - if (filename === "deletions.logfmt") continue // skip audit log - const file = Bun.file(`${MEMORY_DIR}/${filename}`) + if (filename === DELETIONS_FILE) continue + + const file = Bun.file(path.join(dirPath, filename)) const text = await file.text() - lines.push(...text.trim().split("\n").filter(Boolean)) + const trimmed = text.trim() + if (!trimmed) continue + + lines.push(...trimmed.split("\n").filter(Boolean)) } return lines.map(parseLine).filter((m): m is Memory => m !== null) } +const getAllMemories = async (scope?: string): Promise => { + const dirs = getSearchDirs(scope) + const all = await Promise.all(dirs.map(readMemoriesFromDir)) + + return all + .flat() + .sort((a, b) => a.ts.localeCompare(b.ts)) +} + const logDeletion = async (memory: Memory, reason: string) => { - await ensureDir() + await ensureDir(memory.scope) + + const dirPath = getDirForScope(memory.scope) + const file = Bun.file(path.join(dirPath, DELETIONS_FILE)) + const ts = new Date().toISOString() const content = memory.content.replace(/"/g, '\\"') const originalTs = memory.ts const issue = memory.issue ? ` issue=${memory.issue}` : "" const tags = memory.tags?.length ? ` tags=${memory.tags.join(",")}` : "" const escapedReason = reason.replace(/"/g, '\\"') - const line = `ts=${ts} action=deleted original_ts=${originalTs} type=${memory.type} scope=${memory.scope} content="${content}" reason="${escapedReason}"${issue}${tags}\n` - const file = Bun.file(`${MEMORY_DIR}/deletions.logfmt`) + const line = + `ts=${ts} action=deleted original_ts=${originalTs} type=${memory.type} scope=${memory.scope} content="${content}" reason="${escapedReason}"${issue}${tags}\n` + const existing = (await file.exists()) ? await file.text() : "" await Bun.write(file, existing + line) } +const remember = tool({ + description: "Store a memory (decision, learning, preference, blocker, context, pattern)", + args: { + type: tool.schema + .enum(["decision", "learning", "preference", "blocker", "context", "pattern"]) + .describe("Type of memory"), + scope: tool.schema.string().describe("Scope/area (e.g., user, auth, api, mobile)"), + content: tool.schema.string().describe("The memory content"), + issue: tool.schema.string().optional().describe("Related GitHub issue (e.g., #51)"), + tags: tool.schema.array(tool.schema.string()).optional().describe("Additional tags"), + }, + async execute(args) { + await ensureDir(args.scope) + + const ts = new Date().toISOString() + const issue = args.issue ? ` issue=${args.issue}` : "" + const tags = args.tags?.length ? ` tags=${args.tags.join(",")}` : "" + const content = args.content.replace(/"/g, '\\"') + const line = `ts=${ts} type=${args.type} scope=${args.scope} content="${content}"${issue}${tags}\n` + + const file = getMemoryFile(args.scope) + const existing = (await file.exists()) ? await file.text() : "" + await Bun.write(file, existing + line) + + return `Remembered: ${args.type} in ${args.scope}` + }, +}) + const recall = tool({ description: "Retrieve memories by scope, type, or search query", args: { @@ -133,7 +183,7 @@ const recall = tool({ limit: tool.schema.number().optional().describe("Max results (default 20)"), }, async execute(args) { - let results = await getAllMemories() + let results = await getAllMemories(args.scope) if (!results.length) return "No memories found" @@ -142,6 +192,7 @@ const recall = tool({ if (args.scope) { results = results.filter((m) => m.scope === args.scope || m.scope.includes(args.scope!)) } + if (args.type) { results = results.filter((m) => m.type === args.type) } @@ -151,7 +202,8 @@ const recall = tool({ const scored = results .map((m) => ({ memory: m, score: scoreMatch(m, words) })) .filter((x) => x.score > 0) - .sort((a, b) => b.score - a.score) + .sort((a, b) => b.score - a.score || a.memory.ts.localeCompare(b.memory.ts)) + results = scored.map((x) => x.memory) } @@ -161,11 +213,12 @@ const recall = tool({ if (!limited.length) return "No matching memories" - const header = filteredCount > limit - ? `Found ${filteredCount} memories (showing last ${limit} of ${totalCount} total)\n\n` - : filteredCount !== totalCount - ? `Found ${filteredCount} memories (${totalCount} total)\n\n` - : `Found ${filteredCount} memories\n\n` + const header = + filteredCount > limit + ? `Found ${filteredCount} memories (showing last ${limit} of ${totalCount} total)\n\n` + : filteredCount !== totalCount + ? `Found ${filteredCount} memories (${totalCount} total)\n\n` + : `Found ${filteredCount} memories\n\n` return header + limited.map(formatMemory).join("\n") }, @@ -184,17 +237,19 @@ const update = tool({ tags: tool.schema.array(tool.schema.string()).optional().describe("Update tags"), }, async execute(args) { - const glob = new Bun.Glob("*.logfmt") - const files = await Array.fromAsync(glob.scan(MEMORY_DIR)) + const dirPath = getDirForScope(args.scope) + if (!(await dirExists(dirPath))) return "No memory files found" + const glob = new Bun.Glob("*.logfmt") + const files = await Array.fromAsync(glob.scan(dirPath)) if (!files.length) return "No memory files found" - // Find matching memories const matches: { memory: Memory; filepath: string; lineIndex: number }[] = [] for (const filename of files) { - if (filename === "deletions.logfmt") continue - const filepath = `${MEMORY_DIR}/${filename}` + if (filename === DELETIONS_FILE) continue + + const filepath = path.join(dirPath, filename) const file = Bun.file(filepath) const text = await file.text() const lines = text.split("\n") @@ -212,19 +267,20 @@ const update = tool({ return `No memories found for ${args.type} in ${args.scope}` } - // If multiple matches and query provided, filter by query let target: typeof matches[number] | undefined = matches[0] + if (matches.length > 1) { if (args.query) { const words = args.query.toLowerCase().split(/\s+/).filter(Boolean) const scored = matches .map((m) => ({ ...m, score: scoreMatch(m.memory, words) })) .filter((x) => x.score > 0) - .sort((a, b) => b.score - a.score) + .sort((a, b) => b.score - a.score || a.memory.ts.localeCompare(b.memory.ts)) if (scored.length === 0) { return `Found ${matches.length} memories for ${args.type}/${args.scope}, but none matched query "${args.query}". Use recall to see all matches.` } + target = scored[0] } else { return `Found ${matches.length} memories for ${args.type}/${args.scope}. Provide a query to select which one to update, or use recall to see all matches.` @@ -235,10 +291,8 @@ const update = tool({ return `No memories found for ${args.type} in ${args.scope}` } - // Log the old version before updating await logDeletion(target.memory, `Updated to: ${args.content}`) - // Update the memory const file = Bun.file(target.filepath) const text = await file.text() const lines = text.split("\n") @@ -273,6 +327,7 @@ const listMemories = tool({ for (const m of memories) { scopes.set(m.scope, (scopes.get(m.scope) || 0) + 1) types.set(m.type, (types.get(m.type) || 0) + 1) + if (!scopeTypes.has(m.scope)) scopeTypes.set(m.scope, new Set()) scopeTypes.get(m.scope)!.add(m.type) } @@ -281,12 +336,15 @@ const listMemories = tool({ lines.push(`Total memories: ${memories.length}`) lines.push("") lines.push("Scopes:") + for (const [scope, count] of [...scopes.entries()].sort((a, b) => b[1] - a[1])) { const typeList = [...scopeTypes.get(scope)!].join(", ") lines.push(` ${scope}: ${count} (${typeList})`) } + lines.push("") lines.push("Types:") + for (const [type, count] of [...types.entries()].sort((a, b) => b[1] - a[1])) { lines.push(` ${type}: ${count}`) } @@ -305,47 +363,58 @@ const forget = tool({ reason: tool.schema.string().describe("Why this is being deleted (for audit purposes)"), }, async execute(args) { - const glob = new Bun.Glob("*.logfmt") - const files = await Array.fromAsync(glob.scan(MEMORY_DIR)) + const dirPath = getDirForScope(args.scope) + if (!(await dirExists(dirPath))) return "No memory files found" + const glob = new Bun.Glob("*.logfmt") + const files = await Array.fromAsync(glob.scan(dirPath)) if (!files.length) return "No memory files found" let deleted = 0 const deletedMemories: Memory[] = [] for (const filename of files) { - if (filename === "deletions.logfmt") continue // skip audit log - const filepath = `${MEMORY_DIR}/${filename}` + if (filename === DELETIONS_FILE) continue + + const filepath = path.join(dirPath, filename) const file = Bun.file(filepath) const text = await file.text() const lines = text.split("\n") + const filtered = lines.filter((line) => { const memory = parseLine(line) if (!memory) return true + if (memory.scope === args.scope && memory.type === args.type) { deleted++ deletedMemories.push(memory) return false } + return true }) + if (filtered.length !== lines.length) { await Bun.write(filepath, filtered.join("\n")) } } - // Log all deletions to audit file for (const memory of deletedMemories) { await logDeletion(memory, args.reason) } - if (deleted === 0) return `No memories found for ${args.type} in ${args.scope}` - return `Deleted ${deleted} ${args.type} memory(s) from ${args.scope}. Reason: ${args.reason}\nDeletions logged to ${MEMORY_DIR}/deletions.logfmt` + if (deleted === 0) { + return `No memories found for ${args.type} in ${args.scope}` + } + + return `Deleted ${deleted} ${args.type} memory(s) from ${args.scope}. Reason: ${args.reason}\nDeletions logged to ${path.join(dirPath, DELETIONS_FILE)}` }, }) export const MemoryPlugin: Plugin = async (_ctx) => { - MEMORY_DIR = `${_ctx.directory}/.opencode/memory` + PROJECT_MEMORY_DIR = path.join(_ctx.directory, ".opencode", "memory") + GLOBAL_MEMORY_DIR = path.join(os.homedir(), ".config", "opencode", "memory") + return { tool: { memory_remember: remember,