Skip to content

Commit e1d5a5c

Browse files
anandgupta42claude
andcommitted
feat: add TTL expiration, hierarchical namespaces, dedup detection, audit logging, citations, session extraction, and global opt-out to Altimate Memory
Implements P0/P1 improvements: - TTL expiration via optional `expires` field with automatic filtering - Hierarchical namespace IDs with slash-separated paths mapped to subdirectories - Deduplication detection on write with tag-overlap warnings - Audit log for all CREATE/UPDATE/DELETE operations - Citation-backed memories with file/line/note references - Session-end batch extraction tool (opt-in via ALTIMATE_MEMORY_AUTO_EXTRACT) - Global opt-out via ALTIMATE_DISABLE_MEMORY environment variable - Comprehensive tests: 175 tests covering all new features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 102997c commit e1d5a5c

14 files changed

Lines changed: 1308 additions & 115 deletions

File tree

packages/opencode/src/flag/flag.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export namespace Flag {
3030
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
3131
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
3232
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
33+
// altimate_change start - global opt-out for Altimate Memory
34+
export const ALTIMATE_DISABLE_MEMORY = altTruthy("ALTIMATE_DISABLE_MEMORY", "OPENCODE_DISABLE_MEMORY")
35+
// altimate_change end
36+
// altimate_change start - opt-in for session-end auto-extraction
37+
export const ALTIMATE_MEMORY_AUTO_EXTRACT = altTruthy("ALTIMATE_MEMORY_AUTO_EXTRACT", "OPENCODE_MEMORY_AUTO_EXTRACT")
38+
// altimate_change end
3339
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
3440
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
3541
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
export { MemoryStore } from "./store"
1+
export { MemoryStore, isExpired } from "./store"
22
export { MemoryPrompt } from "./prompt"
33
export { MemoryReadTool } from "./tools/memory-read"
44
export { MemoryWriteTool } from "./tools/memory-write"
55
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"
6+
export { MemoryAuditTool } from "./tools/memory-audit"
7+
export { MemoryExtractTool } from "./tools/memory-extract"
8+
export { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE, MEMORY_MAX_CITATIONS, MEMORY_DEFAULT_INJECTION_BUDGET } from "./types"
9+
export type { MemoryBlock, Citation } from "./types"

packages/opencode/src/memory/prompt.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
1-
import { MemoryStore } from "./store"
2-
import { MEMORY_DEFAULT_INJECTION_BUDGET } from "./types"
1+
import { MemoryStore, isExpired } from "./store"
2+
import { MEMORY_DEFAULT_INJECTION_BUDGET, type MemoryBlock } from "./types"
33

44
export namespace MemoryPrompt {
5-
export function formatBlock(block: { id: string; scope: string; tags: string[]; content: string }): string {
5+
export function formatBlock(block: MemoryBlock): string {
66
const tagsStr = block.tags.length > 0 ? ` [${block.tags.join(", ")}]` : ""
7-
return `### ${block.id} (${block.scope})${tagsStr}\n${block.content}`
7+
const expiresStr = block.expires ? ` (expires: ${block.expires})` : ""
8+
let result = `### ${block.id} (${block.scope})${tagsStr}${expiresStr}\n${block.content}`
9+
10+
if (block.citations && block.citations.length > 0) {
11+
const citationLines = block.citations.map((c) => {
12+
const lineStr = c.line ? `:${c.line}` : ""
13+
const noteStr = c.note ? ` — ${c.note}` : ""
14+
return `- \`${c.file}${lineStr}\`${noteStr}`
15+
})
16+
result += "\n\n**Sources:**\n" + citationLines.join("\n")
17+
}
18+
19+
return result
820
}
921

1022
export async function inject(budget: number = MEMORY_DEFAULT_INJECTION_BUDGET): Promise<string> {
1123
const blocks = await MemoryStore.listAll()
1224
if (blocks.length === 0) return ""
1325

14-
const header = "## Agent Memory\n\nThe following memory blocks were saved from previous sessions:\n"
26+
const header = "## Altimate Memory\n\nThe following memory blocks were saved from previous sessions:\n"
1527
let result = header
1628
let used = header.length
1729

1830
for (const block of blocks) {
31+
if (isExpired(block)) continue
1932
const formatted = formatBlock(block)
20-
const needed = formatted.length + 2 // +2 for double newline separator
33+
const needed = formatted.length + 2
2134
if (used + needed > budget) break
2235
result += "\n" + formatted + "\n"
2336
used += needed

packages/opencode/src/memory/store.ts

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from "fs/promises"
33
import path from "path"
44
import { Global } from "@/global"
55
import { Instance } from "@/project/instance"
6-
import { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE, type MemoryBlock } from "./types"
6+
import { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE, type MemoryBlock, type Citation } from "./types"
77

88
const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
99

@@ -20,7 +20,11 @@ function dirForScope(scope: "global" | "project"): string {
2020
}
2121

2222
function blockPath(scope: "global" | "project", id: string): string {
23-
return path.join(dirForScope(scope), `${id}.md`)
23+
return path.join(dirForScope(scope), ...id.split("/").slice(0, -1), `${id.split("/").pop()}.md`)
24+
}
25+
26+
function auditLogPath(scope: "global" | "project"): string {
27+
return path.join(dirForScope(scope), ".log")
2428
}
2529

2630
function parseFrontmatter(raw: string): { meta: Record<string, unknown>; content: string } | undefined {
@@ -50,19 +54,43 @@ function parseFrontmatter(raw: string): { meta: Record<string, unknown>; content
5054

5155
function serializeBlock(block: MemoryBlock): string {
5256
const tags = block.tags.length > 0 ? `\ntags: ${JSON.stringify(block.tags)}` : ""
57+
const expires = block.expires ? `\nexpires: ${block.expires}` : ""
58+
const citations = block.citations && block.citations.length > 0 ? `\ncitations: ${JSON.stringify(block.citations)}` : ""
5359
return [
5460
"---",
5561
`id: ${block.id}`,
5662
`scope: ${block.scope}`,
5763
`created: ${block.created}`,
58-
`updated: ${block.updated}${tags}`,
64+
`updated: ${block.updated}${tags}${expires}${citations}`,
5965
"---",
6066
"",
6167
block.content,
6268
"",
6369
].join("\n")
6470
}
6571

72+
export function isExpired(block: MemoryBlock): boolean {
73+
if (!block.expires) return false
74+
return new Date(block.expires) <= new Date()
75+
}
76+
77+
async function appendAuditLog(scope: "global" | "project", entry: string): Promise<void> {
78+
const logPath = auditLogPath(scope)
79+
const dir = path.dirname(logPath)
80+
try {
81+
await fs.mkdir(dir, { recursive: true })
82+
await fs.appendFile(logPath, entry + "\n", "utf-8")
83+
} catch {
84+
// Audit logging is best-effort — never fail the operation
85+
}
86+
}
87+
88+
function auditEntry(action: string, id: string, scope: string, extra?: string): string {
89+
const ts = new Date().toISOString()
90+
const suffix = extra ? ` ${extra}` : ""
91+
return `[${ts}] ${action} ${scope}/${id}${suffix}`
92+
}
93+
6694
export namespace MemoryStore {
6795
export async function read(scope: "global" | "project", id: string): Promise<MemoryBlock | undefined> {
6896
const filepath = blockPath(scope, id)
@@ -77,79 +105,132 @@ export namespace MemoryStore {
77105
const parsed = parseFrontmatter(raw)
78106
if (!parsed) return undefined
79107

108+
const citations = (() => {
109+
if (!parsed.meta.citations) return undefined
110+
if (Array.isArray(parsed.meta.citations)) return parsed.meta.citations as Citation[]
111+
return undefined
112+
})()
113+
80114
return {
81115
id: String(parsed.meta.id ?? id),
82116
scope: (parsed.meta.scope as "global" | "project") ?? scope,
83117
tags: Array.isArray(parsed.meta.tags) ? (parsed.meta.tags as string[]) : [],
84118
created: String(parsed.meta.created ?? new Date().toISOString()),
85119
updated: String(parsed.meta.updated ?? new Date().toISOString()),
120+
expires: parsed.meta.expires ? String(parsed.meta.expires) : undefined,
121+
citations,
86122
content: parsed.content,
87123
}
88124
}
89125

90-
export async function list(scope: "global" | "project"): Promise<MemoryBlock[]> {
126+
export async function list(scope: "global" | "project", opts?: { includeExpired?: boolean }): Promise<MemoryBlock[]> {
91127
const dir = dirForScope(scope)
92-
let entries: string[]
93-
try {
94-
entries = await fs.readdir(dir)
95-
} catch (e: any) {
96-
if (e.code === "ENOENT") return []
97-
throw e
98-
}
99-
100128
const blocks: MemoryBlock[] = []
101-
for (const entry of entries) {
102-
if (!entry.endsWith(".md")) continue
103-
const id = entry.slice(0, -3)
104-
const block = await read(scope, id)
105-
if (block) blocks.push(block)
129+
130+
async function scanDir(currentDir: string, prefix: string) {
131+
let entries: { name: string; isDirectory: () => boolean }[]
132+
try {
133+
entries = await fs.readdir(currentDir, { withFileTypes: true })
134+
} catch (e: any) {
135+
if (e.code === "ENOENT") return
136+
throw e
137+
}
138+
139+
for (const entry of entries) {
140+
if (entry.name.startsWith(".")) continue
141+
if (entry.isDirectory()) {
142+
await scanDir(path.join(currentDir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name)
143+
} else if (entry.name.endsWith(".md")) {
144+
const baseName = entry.name.slice(0, -3)
145+
const id = prefix ? `${prefix}/${baseName}` : baseName
146+
const block = await read(scope, id)
147+
if (block) {
148+
if (!opts?.includeExpired && isExpired(block)) continue
149+
blocks.push(block)
150+
}
151+
}
152+
}
106153
}
107154

155+
await scanDir(dir, "")
108156
blocks.sort((a, b) => b.updated.localeCompare(a.updated))
109157
return blocks
110158
}
111159

112-
export async function listAll(): Promise<MemoryBlock[]> {
113-
const [global, project] = await Promise.all([list("global"), list("project")])
160+
export async function listAll(opts?: { includeExpired?: boolean }): Promise<MemoryBlock[]> {
161+
const [global, project] = await Promise.all([list("global", opts), list("project", opts)])
114162
const all = [...project, ...global]
115163
all.sort((a, b) => b.updated.localeCompare(a.updated))
116164
return all
117165
}
118166

119-
export async function write(block: MemoryBlock): Promise<void> {
167+
export async function findDuplicates(
168+
scope: "global" | "project",
169+
block: { id: string; tags: string[] },
170+
): Promise<MemoryBlock[]> {
171+
const existing = await list(scope)
172+
return existing.filter((b) => {
173+
if (b.id === block.id) return false // same block = update, not duplicate
174+
if (block.tags.length === 0) return false
175+
const overlap = block.tags.filter((t) => b.tags.includes(t))
176+
return overlap.length >= Math.ceil(block.tags.length / 2)
177+
})
178+
}
179+
180+
export async function write(block: MemoryBlock): Promise<{ duplicates: MemoryBlock[] }> {
120181
if (block.content.length > MEMORY_MAX_BLOCK_SIZE) {
121182
throw new Error(
122183
`Memory block "${block.id}" content exceeds maximum size of ${MEMORY_MAX_BLOCK_SIZE} characters (got ${block.content.length})`,
123184
)
124185
}
125186

126-
const existing = await list(block.scope)
187+
const existing = await list(block.scope, { includeExpired: true })
127188
const isUpdate = existing.some((b) => b.id === block.id)
128189
if (!isUpdate && existing.length >= MEMORY_MAX_BLOCKS_PER_SCOPE) {
129190
throw new Error(
130191
`Cannot create memory block "${block.id}": scope "${block.scope}" already has ${MEMORY_MAX_BLOCKS_PER_SCOPE} blocks (maximum). Delete an existing block first.`,
131192
)
132193
}
133194

134-
const dir = dirForScope(block.scope)
135-
await fs.mkdir(dir, { recursive: true })
195+
const duplicates = await findDuplicates(block.scope, block)
136196

137197
const filepath = blockPath(block.scope, block.id)
198+
const dir = path.dirname(filepath)
199+
await fs.mkdir(dir, { recursive: true })
200+
138201
const tmpPath = filepath + ".tmp"
139202
const serialized = serializeBlock(block)
140203

141204
await fs.writeFile(tmpPath, serialized, "utf-8")
142205
await fs.rename(tmpPath, filepath)
206+
207+
const action = isUpdate ? "UPDATE" : "CREATE"
208+
await appendAuditLog(block.scope, auditEntry(action, block.id, block.scope))
209+
210+
return { duplicates }
143211
}
144212

145213
export async function remove(scope: "global" | "project", id: string): Promise<boolean> {
146214
const filepath = blockPath(scope, id)
147215
try {
148216
await fs.unlink(filepath)
217+
await appendAuditLog(scope, auditEntry("DELETE", id, scope))
149218
return true
150219
} catch (e: any) {
151220
if (e.code === "ENOENT") return false
152221
throw e
153222
}
154223
}
224+
225+
export async function readAuditLog(scope: "global" | "project", limit: number = 50): Promise<string[]> {
226+
const logPath = auditLogPath(scope)
227+
try {
228+
const raw = await fs.readFile(logPath, "utf-8")
229+
const lines = raw.trim().split("\n").filter(Boolean)
230+
return lines.slice(-limit)
231+
} catch (e: any) {
232+
if (e.code === "ENOENT") return []
233+
throw e
234+
}
235+
}
155236
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import z from "zod"
2+
import { Tool } from "../../tool/tool"
3+
import { MemoryStore } from "../store"
4+
5+
export const MemoryAuditTool = Tool.define("altimate_memory_audit", {
6+
description:
7+
"View the Altimate Memory audit log — a record of all memory create, update, and delete operations. Useful for debugging when a memory was written or deleted, and by which session.",
8+
parameters: z.object({
9+
scope: z
10+
.enum(["global", "project", "all"])
11+
.optional()
12+
.default("all")
13+
.describe("Which scope to show audit log for"),
14+
limit: z
15+
.number()
16+
.int()
17+
.positive()
18+
.max(200)
19+
.optional()
20+
.default(50)
21+
.describe("Maximum number of log entries to return (most recent first)"),
22+
}),
23+
async execute(args, ctx) {
24+
try {
25+
const scopes: Array<"global" | "project"> =
26+
args.scope === "all" ? ["global", "project"] : [args.scope as "global" | "project"]
27+
28+
const allEntries: string[] = []
29+
for (const scope of scopes) {
30+
const entries = await MemoryStore.readAuditLog(scope, args.limit)
31+
allEntries.push(...entries)
32+
}
33+
34+
if (allEntries.length === 0) {
35+
return {
36+
title: "Memory Audit: empty",
37+
metadata: { count: 0 },
38+
output: "No audit log entries found. The audit log records memory create, update, and delete operations.",
39+
}
40+
}
41+
42+
// Sort by timestamp (entries start with [ISO-date])
43+
allEntries.sort()
44+
const trimmed = allEntries.slice(-args.limit!)
45+
46+
return {
47+
title: `Memory Audit: ${trimmed.length} entries`,
48+
metadata: { count: trimmed.length },
49+
output: trimmed.join("\n"),
50+
}
51+
} catch (e) {
52+
const msg = e instanceof Error ? e.message : String(e)
53+
return {
54+
title: "Memory Audit: ERROR",
55+
metadata: { count: 0 },
56+
output: `Failed to read audit log: ${msg}`,
57+
}
58+
}
59+
},
60+
})

0 commit comments

Comments
 (0)