|
| 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 | +} |
0 commit comments