Skip to content
Closed
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
42 changes: 42 additions & 0 deletions packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
@@ -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"
Expand All@@ -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 = [
"<env>",
Expand All@@ -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) })
Expand Down
105 changes: 105 additions & 0 deletions packages/opencode/src/tool/memory.ts
Original file line numberDiff line numberDiff line change
@@ -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<typeof Parameters, Metadata, FSUtil.Service | Global.Service>(
"memory",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service

return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
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),
}
}),
)
14 changes: 14 additions & 0 deletions packages/opencode/src/tool/memory.txt
Original file line numberDiff line numberDiff line change
@@ -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`.
6 changes: 6 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line numberDiff line numberDiff line change
@@ -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"
Expand All@@ -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"
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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),
Expand All@@ -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] : []),
Expand DownExpand Up@@ -449,6 +454,7 @@ export const node = LayerNode.make({
MCP.node,
Database.node,
Ripgrep.node,
Global.node,
],
})

Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/test/tool/memory.test.ts
Original file line numberDiff line numberDiff line change
@@ -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)
}),
)
})
Loading