diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index b8b50577cccf..3315f353ffd3 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,5 +1,6 @@ export * as SystemContextBuiltIns from "./builtins" +import path from "path" import { makeLocationNode } from "../effect/app-node" import { DateTime, Effect, Layer, Schema } from "effect" import { Location } from "../location" @@ -9,9 +10,43 @@ import { SystemContextRegistry } from "./registry" import { FSUtil } from "../fs-util" import { Global } from "../global" +// Mirrors the slug used by the memory tool so both read the same directory. +function memoryDir(globalData: string, worktree: string) { + let hash = 0 + for (let i = 0; i < worktree.length; i++) { + hash = (Math.imul(31, hash) + worktree.charCodeAt(i)) | 0 + } + const base = path.basename(worktree).replace(/[^a-zA-Z0-9_-]/g, "") || "project" + return path.join(globalData, "memory", `${base}-${(hash >>> 0).toString(36)}`) +} + +const loadMemoryIndex = Effect.fn("SystemContextBuiltIns.loadMemoryIndex")(function* ( + fs: FSUtil.Interface, + dir: string, +) { + const exists = yield* fs.existsSafe(dir) + if (!exists) return "" + const files = (yield* fs.glob("**/*.md", { cwd: dir }).pipe(Effect.catch(() => Effect.succeed([])))).sort() + if (files.length === 0) return "" + const lines: string[] = [] + for (const file of files.slice(0, 50)) { + const content = + (yield* fs.readFileStringSafe(path.join(dir, file)).pipe(Effect.catch(() => Effect.succeed(undefined)))) + ?.split("\n") + .find((l) => l.trim()) ?? "" + lines.push(`- ${file.replace(/\.md$/, "")}${content ? ` — ${content.slice(0, 120)}` : ""}`) + } + return [ + "Persistent project memories recorded by previous sessions (use the memory tool to read or update):", + ...lines, + ].join("\n") +}) + const builtIns = Layer.effectDiscard( Effect.gen(function* () { const location = yield* Location.Service + const global = yield* Global.Service + const fs = yield* FSUtil.Service const registry = yield* SystemContextRegistry.Service const environment = [ "", @@ -37,6 +72,13 @@ const builtIns = Layer.effectDiscard( baseline: (date) => `Today's date: ${date}`, update: (_previous, date) => `Today's date is now: ${date}`, }), + SystemContext.make({ + key: SystemContext.Key.make("core/memory"), + codec: Schema.toCodecJson(Schema.String), + load: loadMemoryIndex(fs, memoryDir(global.data, location.project.directory)), + baseline: (index) => (index ? index : ""), + update: (_previous, index) => (index ? index : ""), + }), ]) yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) }) diff --git a/packages/opencode/src/tool/memory.ts b/packages/opencode/src/tool/memory.ts new file mode 100644 index 000000000000..3eaa209669e8 --- /dev/null +++ b/packages/opencode/src/tool/memory.ts @@ -0,0 +1,105 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { InstanceState } from "@/effect/instance-state" +import DESCRIPTION from "./memory.txt" +import * as Tool from "./tool" + +export const Parameters = Schema.Struct({ + action: Schema.Literals(["list", "read", "write", "delete"]).annotate({ + description: + "list: show stored memories. read: read one memory file. write: create or update a memory file. delete: remove a memory file.", + }), + name: Schema.optional(Schema.String).annotate({ + description: + "Memory name without extension, e.g. 'user-preferences' or 'architecture/decisions'. Required for read, write and delete.", + }), + content: Schema.optional(Schema.String).annotate({ + description: "Markdown content to store. Required for write.", + }), +}) + +function memoryDir(globalData: string, worktree: string) { + // Slug the worktree so different projects never share memories. + let hash = 0 + for (let i = 0; i < worktree.length; i++) { + hash = (Math.imul(31, hash) + worktree.charCodeAt(i)) | 0 + } + const base = path.basename(worktree).replace(/[^a-zA-Z0-9_-]/g, "") || "project" + return path.join(globalData, "memory", `${base}-${(hash >>> 0).toString(36)}`) +} + +const normalizeName = (name: string) => { + if (!name.trim()) throw new Error("memory name is required") + const cleaned = name.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "") + if (!cleaned || cleaned.includes("..")) throw new Error(`invalid memory name: ${name}`) + return `${cleaned}.md` +} + +type Metadata = { + count?: number +} + +export const MemoryTool = Tool.define( + "memory", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const dir = memoryDir(global.data, ins.worktree) + + yield* ctx.ask({ + permission: "memory", + patterns: ["*"], + always: ["*"], + metadata: { action: params.action, name: params.name }, + }) + + if (params.action === "list") { + yield* fs.ensureDir(dir) + const files = (yield* fs.glob("**/*.md", { cwd: dir })).sort() + if (files.length === 0) return { title: "memory", metadata: {}, output: "No memories stored yet." } + const lines = [] + for (const file of files.slice(0, 100)) { + const content = + (yield* fs.readFileStringSafe(path.join(dir, file)))?.split("\n").find((l) => l.trim()) ?? "" + lines.push(`- ${file.replace(/\.md$/, "")}${content ? ` — ${content.slice(0, 120)}` : ""}`) + } + return { title: `${files.length} memories`, metadata: { count: files.length }, output: lines.join("\n") } + } + + if (!params.name) throw new Error(`'name' is required for action '${params.action}'`) + const file = normalizeName(params.name) + const target = path.join(dir, file) + + if (params.action === "read") { + const content = yield* fs.readFileStringSafe(target) + if (content === undefined) throw new Error(`memory not found: ${params.name}`) + return { title: params.name, metadata: {}, output: content } + } + + if (params.action === "write") { + if (params.content === undefined) throw new Error("'content' is required for action 'write'") + yield* fs.writeWithDirs(target, params.content) + return { + title: params.name, + metadata: {}, + output: `Stored memory '${params.name}' (${params.content.length} chars)`, + } + } + + const exists = yield* fs.existsSafe(target) + if (!exists) throw new Error(`memory not found: ${params.name}`) + yield* fs.remove(target) + return { title: params.name, metadata: {}, output: `Deleted memory '${params.name}'` } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/memory.txt b/packages/opencode/src/tool/memory.txt new file mode 100644 index 000000000000..fe06934a6db5 --- /dev/null +++ b/packages/opencode/src/tool/memory.txt @@ -0,0 +1,14 @@ +Use the memory tool to persist and recall knowledge across sessions for this project. + +## When to use + +- When you learn a durable fact worth keeping (user preferences, project decisions, environment quirks, resolved debugging root causes), write it to a memory file. +- At the start of a task on familiar ground, `list` memories to check for relevant knowledge before rediscovering it. +- Read a specific memory only when its name/description looks relevant. + +## Guidelines + +- One topic per file; keep files small and factual. +- Prefer updating an existing memory over creating near-duplicates. +- Do not store secrets, credentials, or session-specific scratch state. +- Names may be namespaced with slashes, e.g. `architecture/decisions`. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9167cb3ea6bc..a1e79d2c51ab 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,6 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Global } from "@opencode-ai/core/global" import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" @@ -16,6 +17,7 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { MemoryTool } from "./memory" import * as Tool from "./tool" import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" @@ -114,6 +116,7 @@ const layer = Layer.effect( const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const memorytool = yield* MemoryTool const agent = yield* Agent.Service const codeMode = flags.experimentalCodeMode ? yield* Effect.promise(() => import("./code-mode")) : undefined const codeModeTool = codeMode ? yield* codeMode.CodeModeTool : undefined @@ -219,6 +222,7 @@ const layer = Layer.effect( todo: Tool.init(todo), search: Tool.init(websearch), skill: Tool.init(skilltool), + memory: Tool.init(memorytool), patch: Tool.init(patchtool), question: Tool.init(question), lsp: Tool.init(lsptool), @@ -242,6 +246,7 @@ const layer = Layer.effect( tool.todo, tool.search, tool.skill, + tool.memory, tool.patch, ...(tool.execute ? [tool.execute] : []), ...(flags.experimentalLspTool ? [tool.lsp] : []), @@ -449,6 +454,7 @@ export const node = LayerNode.make({ MCP.node, Database.node, Ripgrep.node, + Global.node, ], }) diff --git a/packages/opencode/test/tool/memory.test.ts b/packages/opencode/test/tool/memory.test.ts new file mode 100644 index 000000000000..ee98020efe10 --- /dev/null +++ b/packages/opencode/test/tool/memory.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Effect, Exit } from "effect" +import { Agent } from "../../src/agent/agent" +import { MemoryTool } from "@/tool/memory" +import { Truncate } from "@/tool/truncate" +import { disposeAllInstances } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const layer = () => LayerNode.compile(LayerNode.group([FSUtil.node, Truncate.node, Agent.node, Global.node])) + +const it = testEffect(layer()) + +describe("MemoryTool", () => { + it.instance("writes, lists, reads and deletes memories", () => + Effect.gen(function* () { + const tool = yield* MemoryTool + const def = yield* tool.init() + yield* TestInstance + + const ctx = { + sessionID: "ses_test", + messageID: "msg_test", + agent: "build", + abort: new AbortController().signal, + ask: () => Effect.void, + metadata: () => Effect.void, + } as never + + const write = yield* def.execute({ action: "write", name: "environment/user-prefs", content: "# Prefers bun" }, ctx) + expect(write.output).toContain("Stored memory") + + const list = yield* def.execute({ action: "list" }, ctx) + expect(list.output).toContain("user-prefs") + expect(list.output).toContain("Prefers bun") + + const read = yield* def.execute({ action: "read", name: "environment/user-prefs" }, ctx) + expect(read.output).toBe("# Prefers bun") + + const del = yield* def.execute({ action: "delete", name: "environment/user-prefs" }, ctx) + expect(del.output).toContain("Deleted") + + const emptyList = yield* def.execute({ action: "list" }, ctx) + expect(emptyList.output).toContain("No memories") + }), + ) + + it.instance("rejects path traversal names", () => + Effect.gen(function* () { + const tool = yield* MemoryTool + const def = yield* tool.init() + const ctx = { + sessionID: "ses_test", + messageID: "msg_test", + agent: "build", + abort: new AbortController().signal, + ask: () => Effect.void, + metadata: () => Effect.void, + } as never + + const exit = yield* def.execute({ action: "write", name: "../../escape", content: "x" }, ctx).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) +})