Skip to content

Commit 28e0d6e

Browse files
anandgupta42claude
andcommitted
feat: add Letta-style persistent memory blocks for cross-session agent context
Adds a file-based persistent memory system that allows the AI agent to retain and recall context across sessions — warehouse configurations, naming conventions, team preferences, and past analysis decisions. Three new tools: memory_read, memory_write, memory_delete with global and project scoping, YAML frontmatter format, atomic writes, size/count limits, and system prompt injection support. Closes #135 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2a09639 commit 28e0d6e

File tree

13 files changed

+1339
-0
lines changed

13 files changed

+1339
-0
lines changed

packages/opencode/src/altimate/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,6 @@ export * from "./tools/warehouse-discover"
7676
export * from "./tools/warehouse-list"
7777
export * from "./tools/warehouse-remove"
7878
export * from "./tools/warehouse-test"
79+
80+
// Memory
81+
export * from "../memory"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { MemoryStore } from "./store"
2+
export { MemoryPrompt } from "./prompt"
3+
export { MemoryReadTool } from "./tools/memory-read"
4+
export { MemoryWriteTool } from "./tools/memory-write"
5+
export { MemoryDeleteTool } from "./tools/memory-delete"
6+
export { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE, MEMORY_DEFAULT_INJECTION_BUDGET } from "./types"
7+
export type { MemoryBlock } from "./types"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { MemoryStore } from "./store"
2+
import { MEMORY_DEFAULT_INJECTION_BUDGET } from "./types"
3+
4+
export namespace MemoryPrompt {
5+
export function formatBlock(block: { id: string; scope: string; tags: string[]; content: string }): string {
6+
const tagsStr = block.tags.length > 0 ? ` [${block.tags.join(", ")}]` : ""
7+
return `### ${block.id} (${block.scope})${tagsStr}\n${block.content}`
8+
}
9+
10+
export async function inject(budget: number = MEMORY_DEFAULT_INJECTION_BUDGET): Promise<string> {
11+
const blocks = await MemoryStore.listAll()
12+
if (blocks.length === 0) return ""
13+
14+
const header = "## Agent Memory\n\nThe following memory blocks were saved from previous sessions:\n"
15+
let result = header
16+
let used = header.length
17+
18+
for (const block of blocks) {
19+
const formatted = formatBlock(block)
20+
const needed = formatted.length + 2 // +2 for double newline separator
21+
if (used + needed > budget) break
22+
result += "\n" + formatted + "\n"
23+
used += needed
24+
}
25+
26+
return result
27+
}
28+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import fs from "fs/promises"
2+
import path from "path"
3+
import { Global } from "@/global"
4+
import { Instance } from "@/project/instance"
5+
import { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE, type MemoryBlock } from "./types"
6+
7+
const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
8+
9+
function globalDir(): string {
10+
return path.join(Global.Path.data, "memory")
11+
}
12+
13+
function projectDir(): string {
14+
return path.join(Instance.directory, ".opencode", "memory")
15+
}
16+
17+
function dirForScope(scope: "global" | "project"): string {
18+
return scope === "global" ? globalDir() : projectDir()
19+
}
20+
21+
function blockPath(scope: "global" | "project", id: string): string {
22+
return path.join(dirForScope(scope), `${id}.md`)
23+
}
24+
25+
function parseFrontmatter(raw: string): { meta: Record<string, unknown>; content: string } | undefined {
26+
const match = raw.match(FRONTMATTER_REGEX)
27+
if (!match) return undefined
28+
29+
const meta: Record<string, unknown> = {}
30+
for (const line of match[1].split("\n")) {
31+
const idx = line.indexOf(":")
32+
if (idx === -1) continue
33+
const key = line.slice(0, idx).trim()
34+
let value: unknown = line.slice(idx + 1).trim()
35+
36+
if (value === "") continue
37+
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
38+
try {
39+
value = JSON.parse(value)
40+
} catch {
41+
// keep as string
42+
}
43+
}
44+
meta[key] = value
45+
}
46+
47+
return { meta, content: match[2].trim() }
48+
}
49+
50+
function serializeBlock(block: MemoryBlock): string {
51+
const tags = block.tags.length > 0 ? `\ntags: ${JSON.stringify(block.tags)}` : ""
52+
return [
53+
"---",
54+
`id: ${block.id}`,
55+
`scope: ${block.scope}`,
56+
`created: ${block.created}`,
57+
`updated: ${block.updated}${tags}`,
58+
"---",
59+
"",
60+
block.content,
61+
"",
62+
].join("\n")
63+
}
64+
65+
export namespace MemoryStore {
66+
export async function read(scope: "global" | "project", id: string): Promise<MemoryBlock | undefined> {
67+
const filepath = blockPath(scope, id)
68+
let raw: string
69+
try {
70+
raw = await fs.readFile(filepath, "utf-8")
71+
} catch (e: any) {
72+
if (e.code === "ENOENT") return undefined
73+
throw e
74+
}
75+
76+
const parsed = parseFrontmatter(raw)
77+
if (!parsed) return undefined
78+
79+
return {
80+
id: String(parsed.meta.id ?? id),
81+
scope: (parsed.meta.scope as "global" | "project") ?? scope,
82+
tags: Array.isArray(parsed.meta.tags) ? (parsed.meta.tags as string[]) : [],
83+
created: String(parsed.meta.created ?? new Date().toISOString()),
84+
updated: String(parsed.meta.updated ?? new Date().toISOString()),
85+
content: parsed.content,
86+
}
87+
}
88+
89+
export async function list(scope: "global" | "project"): Promise<MemoryBlock[]> {
90+
const dir = dirForScope(scope)
91+
let entries: string[]
92+
try {
93+
entries = await fs.readdir(dir)
94+
} catch (e: any) {
95+
if (e.code === "ENOENT") return []
96+
throw e
97+
}
98+
99+
const blocks: MemoryBlock[] = []
100+
for (const entry of entries) {
101+
if (!entry.endsWith(".md")) continue
102+
const id = entry.slice(0, -3)
103+
const block = await read(scope, id)
104+
if (block) blocks.push(block)
105+
}
106+
107+
blocks.sort((a, b) => b.updated.localeCompare(a.updated))
108+
return blocks
109+
}
110+
111+
export async function listAll(): Promise<MemoryBlock[]> {
112+
const [global, project] = await Promise.all([list("global"), list("project")])
113+
const all = [...project, ...global]
114+
all.sort((a, b) => b.updated.localeCompare(a.updated))
115+
return all
116+
}
117+
118+
export async function write(block: MemoryBlock): Promise<void> {
119+
if (block.content.length > MEMORY_MAX_BLOCK_SIZE) {
120+
throw new Error(
121+
`Memory block "${block.id}" content exceeds maximum size of ${MEMORY_MAX_BLOCK_SIZE} characters (got ${block.content.length})`,
122+
)
123+
}
124+
125+
const existing = await list(block.scope)
126+
const isUpdate = existing.some((b) => b.id === block.id)
127+
if (!isUpdate && existing.length >= MEMORY_MAX_BLOCKS_PER_SCOPE) {
128+
throw new Error(
129+
`Cannot create memory block "${block.id}": scope "${block.scope}" already has ${MEMORY_MAX_BLOCKS_PER_SCOPE} blocks (maximum). Delete an existing block first.`,
130+
)
131+
}
132+
133+
const dir = dirForScope(block.scope)
134+
await fs.mkdir(dir, { recursive: true })
135+
136+
const filepath = blockPath(block.scope, block.id)
137+
const tmpPath = filepath + ".tmp"
138+
const serialized = serializeBlock(block)
139+
140+
await fs.writeFile(tmpPath, serialized, "utf-8")
141+
await fs.rename(tmpPath, filepath)
142+
}
143+
144+
export async function remove(scope: "global" | "project", id: string): Promise<boolean> {
145+
const filepath = blockPath(scope, id)
146+
try {
147+
await fs.unlink(filepath)
148+
return true
149+
} catch (e: any) {
150+
if (e.code === "ENOENT") return false
151+
throw e
152+
}
153+
}
154+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import z from "zod"
2+
import { Tool } from "../../tool/tool"
3+
import { MemoryStore } from "../store"
4+
5+
export const MemoryDeleteTool = Tool.define("memory_delete", {
6+
description:
7+
"Delete a persistent memory block that is outdated, incorrect, or no longer needed. Use this to keep memory clean and relevant.",
8+
parameters: z.object({
9+
id: z.string().min(1).describe("The ID of the memory block to delete"),
10+
scope: z
11+
.enum(["global", "project"])
12+
.describe("The scope of the memory block to delete"),
13+
}),
14+
async execute(args, ctx) {
15+
try {
16+
const removed = await MemoryStore.remove(args.scope, args.id)
17+
if (removed) {
18+
return {
19+
title: `Memory: Deleted "${args.id}"`,
20+
metadata: { deleted: true, id: args.id, scope: args.scope },
21+
output: `Deleted memory block "${args.id}" from ${args.scope} scope.`,
22+
}
23+
}
24+
return {
25+
title: `Memory: Not found "${args.id}"`,
26+
metadata: { deleted: false, id: args.id, scope: args.scope },
27+
output: `No memory block found with ID "${args.id}" in ${args.scope} scope. Use memory_read to list existing blocks.`,
28+
}
29+
} catch (e) {
30+
const msg = e instanceof Error ? e.message : String(e)
31+
return {
32+
title: "Memory Delete: ERROR",
33+
metadata: { deleted: false, id: args.id, scope: args.scope },
34+
output: `Failed to delete memory: ${msg}`,
35+
}
36+
}
37+
},
38+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import z from "zod"
2+
import { Tool } from "../../tool/tool"
3+
import { MemoryStore } from "../store"
4+
import { MemoryPrompt } from "../prompt"
5+
6+
export const MemoryReadTool = Tool.define("memory_read", {
7+
description:
8+
"Read persistent memory blocks from previous sessions. Use this to recall warehouse configurations, naming conventions, team preferences, and past analysis decisions. Supports filtering by scope (global/project) and tags.",
9+
parameters: z.object({
10+
scope: z
11+
.enum(["global", "project", "all"])
12+
.optional()
13+
.default("all")
14+
.describe("Which scope to read from: 'global' for user-wide, 'project' for current project, 'all' for both"),
15+
tags: z
16+
.array(z.string())
17+
.optional()
18+
.default([])
19+
.describe("Filter blocks to only those containing all specified tags"),
20+
id: z.string().optional().describe("Read a specific block by ID"),
21+
}),
22+
async execute(args, ctx) {
23+
try {
24+
if (args.id) {
25+
const scopes: Array<"global" | "project"> =
26+
args.scope === "all" ? ["project", "global"] : [args.scope as "global" | "project"]
27+
28+
for (const scope of scopes) {
29+
const block = await MemoryStore.read(scope, args.id)
30+
if (block) {
31+
return {
32+
title: `Memory: ${block.id} (${block.scope})`,
33+
metadata: { count: 1 },
34+
output: MemoryPrompt.formatBlock(block),
35+
}
36+
}
37+
}
38+
return {
39+
title: "Memory: not found",
40+
metadata: { count: 0 },
41+
output: `No memory block found with ID "${args.id}"`,
42+
}
43+
}
44+
45+
let blocks =
46+
args.scope === "all"
47+
? await MemoryStore.listAll()
48+
: await MemoryStore.list(args.scope as "global" | "project")
49+
50+
if (args.tags && args.tags.length > 0) {
51+
blocks = blocks.filter((b) => args.tags!.every((tag) => b.tags.includes(tag)))
52+
}
53+
54+
if (blocks.length === 0) {
55+
return {
56+
title: "Memory: empty",
57+
metadata: { count: 0 },
58+
output: "No memory blocks found. Use memory_write to save information for future sessions.",
59+
}
60+
}
61+
62+
const formatted = blocks.map((b) => MemoryPrompt.formatBlock(b)).join("\n\n")
63+
return {
64+
title: `Memory: ${blocks.length} block(s)`,
65+
metadata: { count: blocks.length },
66+
output: formatted,
67+
}
68+
} catch (e) {
69+
const msg = e instanceof Error ? e.message : String(e)
70+
return {
71+
title: "Memory Read: ERROR",
72+
metadata: { count: 0 },
73+
output: `Failed to read memory: ${msg}`,
74+
}
75+
}
76+
},
77+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import z from "zod"
2+
import { Tool } from "../../tool/tool"
3+
import { MemoryStore } from "../store"
4+
import { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE } from "../types"
5+
6+
export const MemoryWriteTool = Tool.define("memory_write", {
7+
description: `Create or update a persistent memory block. Use this to save information worth remembering across sessions — warehouse configurations, naming conventions, team preferences, data model notes, or past analysis decisions. Each block is a Markdown file persisted to disk. Max ${MEMORY_MAX_BLOCK_SIZE} chars per block, ${MEMORY_MAX_BLOCKS_PER_SCOPE} blocks per scope.`,
8+
parameters: z.object({
9+
id: z
10+
.string()
11+
.min(1)
12+
.max(128)
13+
.regex(/^[a-z0-9][a-z0-9_-]*$/)
14+
.describe(
15+
"Unique identifier for this memory block (lowercase, hyphens/underscores). Examples: 'warehouse-config', 'naming-conventions', 'dbt-patterns'",
16+
),
17+
scope: z
18+
.enum(["global", "project"])
19+
.describe("'global' for user-wide preferences, 'project' for project-specific knowledge"),
20+
content: z
21+
.string()
22+
.min(1)
23+
.max(MEMORY_MAX_BLOCK_SIZE)
24+
.describe("Markdown content to store. Keep concise and structured."),
25+
tags: z
26+
.array(z.string().max(64))
27+
.max(10)
28+
.optional()
29+
.default([])
30+
.describe("Tags for categorization and filtering (e.g., ['warehouse', 'snowflake'])"),
31+
}),
32+
async execute(args, ctx) {
33+
try {
34+
const existing = await MemoryStore.read(args.scope, args.id)
35+
const now = new Date().toISOString()
36+
37+
await MemoryStore.write({
38+
id: args.id,
39+
scope: args.scope,
40+
tags: args.tags ?? [],
41+
created: existing?.created ?? now,
42+
updated: now,
43+
content: args.content,
44+
})
45+
46+
const action = existing ? "Updated" : "Created"
47+
return {
48+
title: `Memory: ${action} "${args.id}"`,
49+
metadata: { action: action.toLowerCase(), id: args.id, scope: args.scope },
50+
output: `${action} memory block "${args.id}" in ${args.scope} scope.`,
51+
}
52+
} catch (e) {
53+
const msg = e instanceof Error ? e.message : String(e)
54+
return {
55+
title: "Memory Write: ERROR",
56+
metadata: { action: "error", id: args.id, scope: args.scope },
57+
output: `Failed to write memory: ${msg}`,
58+
}
59+
}
60+
},
61+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import z from "zod"
2+
3+
export const MemoryBlockSchema = z.object({
4+
id: z.string().min(1).max(128).regex(/^[a-z0-9][a-z0-9_-]*$/, {
5+
message: "ID must be lowercase alphanumeric with hyphens/underscores, starting with alphanumeric",
6+
}),
7+
scope: z.enum(["global", "project"]),
8+
tags: z.array(z.string().max(64)).max(10).default([]),
9+
created: z.string().datetime(),
10+
updated: z.string().datetime(),
11+
content: z.string(),
12+
})
13+
14+
export type MemoryBlock = z.infer<typeof MemoryBlockSchema>
15+
16+
export const MEMORY_MAX_BLOCK_SIZE = 2048
17+
export const MEMORY_MAX_BLOCKS_PER_SCOPE = 50
18+
export const MEMORY_DEFAULT_INJECTION_BUDGET = 8000

0 commit comments

Comments
 (0)