Skip to content

Commit 502d6bc

Browse files
committed
feat: add smart recall, path security, file limits, extraction mutual exclusion, and freshness warnings
- Smart recall: keyword-scored prefetch injects top-5 relevant memory content into system prompt - Path security: validateMemoryFileName rejects traversal, null bytes, hidden files, reserved names - File limits: 40KB/file, 200 file cap, 30-line frontmatter scan limit - Extraction mutual exclusion: skip background extraction when main agent already wrote memories - Freshness warnings: memories older than 1 day show age warning in recalled output - Add bun-types and @opencode-ai/plugin as devDependencies to fix type errors
1 parent feca2a4 commit 502d6bc

7 files changed

Lines changed: 251 additions & 11 deletions

File tree

bin/opencode

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ log() {
125125
echo "[opencode-memory] $*" >&2
126126
}
127127

128+
has_new_memories() {
129+
# Check if any memory file was modified during the session
130+
# Checks all projects' memory directories for files newer than the timestamp marker
131+
local mem_base="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
132+
133+
if [ ! -d "$mem_base" ]; then
134+
return 1
135+
fi
136+
137+
# Find any .md file under projects/*/memory/ newer than our timestamp
138+
local newer_files
139+
newer_files=$(find "$mem_base" -path "*/memory/*.md" -newer "$TIMESTAMP_FILE" 2>/dev/null | head -1)
140+
141+
[ -n "$newer_files" ]
142+
}
143+
128144
get_latest_session_id() {
129145
local session_json
130146
session_json=$("$REAL_OPENCODE" session list --format json -n 1 2>/dev/null) || return 1
@@ -157,6 +173,10 @@ release_lock() {
157173
rm -f "$LOCK_FILE"
158174
}
159175

176+
cleanup_timestamp() {
177+
rm -f "$TIMESTAMP_FILE"
178+
}
179+
160180
run_extraction() {
161181
local session_id="$1"
162182

@@ -190,12 +210,16 @@ run_extraction() {
190210
# Main
191211
# ============================================================================
192212

213+
# Step 0: Create timestamp marker before running opencode
214+
TIMESTAMP_FILE=$(mktemp)
215+
193216
# Step 1: Run the real opencode with all original arguments, capture exit code
194217
opencode_exit=0
195218
"$REAL_OPENCODE" "$@" || opencode_exit=$?
196219

197220
# Step 2: Check if extraction is enabled
198221
if [ "$EXTRACT_ENABLED" = "0" ]; then
222+
cleanup_timestamp
199223
exit $opencode_exit
200224
fi
201225

@@ -204,23 +228,34 @@ session_id=$(get_latest_session_id)
204228

205229
if [ -z "$session_id" ]; then
206230
log "No session found, skipping memory extraction"
231+
cleanup_timestamp
232+
exit $opencode_exit
233+
fi
234+
235+
# Step 3.5: Check if memories were already written during the session
236+
if has_new_memories; then
237+
log "Main agent already wrote memories during session, skipping extraction"
238+
cleanup_timestamp
207239
exit $opencode_exit
208240
fi
209241

210242
# Step 4: Acquire lock (prevent concurrent extractions)
211243
if ! acquire_lock; then
244+
cleanup_timestamp
212245
exit $opencode_exit
213246
fi
214247

215248
# Step 5: Run extraction
216249
if [ "$FOREGROUND" = "1" ]; then
217250
# Foreground mode (for debugging)
218251
run_extraction "$session_id"
252+
cleanup_timestamp
219253
else
220254
# Background mode (default) — user isn't blocked
221255
run_extraction "$session_id" &
222256
disown
223257
log "Memory extraction started in background (PID $!)"
258+
cleanup_timestamp
224259
fi
225260

226261
exit $opencode_exit

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,9 @@
3333
},
3434
"dependencies": {
3535
"zod": "^3.24.0"
36+
},
37+
"devDependencies": {
38+
"bun-types": "^1.3.11",
39+
"@opencode-ai/plugin": "^1.3.10"
3640
}
3741
}

src/index.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22
import { tool } from "@opencode-ai/plugin"
33
import { buildMemorySystemPrompt } from "./prompt.js"
4+
import { recallRelevantMemories, formatRecalledMemories } from "./recall.js"
45
import {
56
saveMemory,
67
deleteMemory,
78
listMemories,
89
searchMemories,
910
readMemory,
10-
readIndex,
1111
MEMORY_TYPES,
12-
type MemoryType,
1312
} from "./memory.js"
1413
import { getMemoryDir } from "./paths.js"
1514

@@ -18,7 +17,26 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
1817

1918
return {
2019
"experimental.chat.system.transform": async (_input, output) => {
21-
const memoryPrompt = buildMemorySystemPrompt(worktree)
20+
let query: string | undefined
21+
if (_input && typeof _input === "object") {
22+
const messages = (_input as { messages?: unknown }).messages
23+
if (Array.isArray(messages)) {
24+
const lastUserMsg = [...messages]
25+
.reverse()
26+
.find((message) =>
27+
message && typeof message === "object" && "role" in message && (message as { role?: unknown }).role === "user",
28+
)
29+
30+
if (lastUserMsg && typeof lastUserMsg === "object" && "content" in lastUserMsg) {
31+
const content = (lastUserMsg as { content?: unknown }).content
32+
query = typeof content === "string" ? content : JSON.stringify(content)
33+
}
34+
}
35+
}
36+
37+
const recalled = recallRelevantMemories(worktree, query)
38+
const recalledSection = formatRecalledMemories(recalled)
39+
const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection)
2240
output.system.push(memoryPrompt)
2341
},
2442

src/memory.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "fs"
22
import { join, basename } from "path"
3-
import { getMemoryDir, getMemoryEntrypoint, ENTRYPOINT_NAME } from "./paths.js"
3+
import {
4+
getMemoryDir,
5+
getMemoryEntrypoint,
6+
ENTRYPOINT_NAME,
7+
validateMemoryFileName,
8+
MAX_MEMORY_FILES,
9+
MAX_MEMORY_FILE_BYTES,
10+
FRONTMATTER_MAX_LINES,
11+
} from "./paths.js"
412

513
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"] as const
614
export type MemoryType = (typeof MEMORY_TYPES)[number]
@@ -21,11 +29,20 @@ function parseFrontmatter(raw: string): { frontmatter: Record<string, string>; c
2129
return { frontmatter: {}, content: trimmed }
2230
}
2331

24-
const endIndex = trimmed.indexOf("---", 3)
25-
if (endIndex === -1) {
32+
const lines = trimmed.split("\n")
33+
let closingLineIdx = -1
34+
for (let i = 1; i < Math.min(lines.length, FRONTMATTER_MAX_LINES); i++) {
35+
if (lines[i].trimEnd() === "---") {
36+
closingLineIdx = i
37+
break
38+
}
39+
}
40+
if (closingLineIdx === -1) {
2641
return { frontmatter: {}, content: trimmed }
2742
}
2843

44+
const endIndex = lines.slice(0, closingLineIdx).join("\n").length + 1
45+
2946
const frontmatterBlock = trimmed.slice(3, endIndex).trim()
3047
const content = trimmed.slice(endIndex + 3).trim()
3148

@@ -61,6 +78,7 @@ export function listMemories(worktree: string): MemoryEntry[] {
6178
files = readdirSync(memDir)
6279
.filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
6380
.sort()
81+
.slice(0, MAX_MEMORY_FILES)
6482
} catch {
6583
return entries
6684
}
@@ -88,8 +106,9 @@ export function listMemories(worktree: string): MemoryEntry[] {
88106
}
89107

90108
export function readMemory(worktree: string, fileName: string): MemoryEntry | null {
109+
const safeName = validateMemoryFileName(fileName)
91110
const memDir = getMemoryDir(worktree)
92-
const filePath = join(memDir, fileName.endsWith(".md") ? fileName : `${fileName}.md`)
111+
const filePath = join(memDir, safeName)
93112

94113
try {
95114
const rawContent = readFileSync(filePath, "utf-8")
@@ -116,11 +135,16 @@ export function saveMemory(
116135
type: MemoryType,
117136
content: string,
118137
): string {
138+
const safeName = validateMemoryFileName(fileName)
119139
const memDir = getMemoryDir(worktree)
120-
const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
121140
const filePath = join(memDir, safeName)
122141

123142
const fileContent = `${buildFrontmatter(name, description, type)}\n\n${content.trim()}\n`
143+
if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
144+
throw new Error(
145+
`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`,
146+
)
147+
}
124148
writeFileSync(filePath, fileContent, "utf-8")
125149

126150
updateIndex(worktree, safeName, name, description)
@@ -129,8 +153,8 @@ export function saveMemory(
129153
}
130154

131155
export function deleteMemory(worktree: string, fileName: string): boolean {
156+
const safeName = validateMemoryFileName(fileName)
132157
const memDir = getMemoryDir(worktree)
133-
const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
134158
const filePath = join(memDir, safeName)
135159

136160
try {

src/paths.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ export const ENTRYPOINT_NAME = "MEMORY.md"
1010
export const MAX_ENTRYPOINT_LINES = 200
1111
export const MAX_ENTRYPOINT_BYTES = 25_000
1212

13+
export const MAX_MEMORY_FILES = 200
14+
export const MAX_MEMORY_FILE_BYTES = 40_000
15+
export const FRONTMATTER_MAX_LINES = 30
16+
17+
export function validateMemoryFileName(fileName: string): string {
18+
const base = fileName.endsWith(".md") ? fileName.slice(0, -3) : fileName
19+
20+
if (base.length === 0) {
21+
throw new Error("Memory file name cannot be empty")
22+
}
23+
if (base.includes("/") || base.includes("\\")) {
24+
throw new Error(`Memory file name must not contain path separators: ${fileName}`)
25+
}
26+
if (base.includes("..")) {
27+
throw new Error(`Memory file name must not contain path traversal: ${fileName}`)
28+
}
29+
if (base.includes("\0")) {
30+
throw new Error(`Memory file name must not contain null bytes: ${fileName}`)
31+
}
32+
if (base.startsWith(".")) {
33+
throw new Error(`Memory file name must not start with '.': ${fileName}`)
34+
}
35+
if (base.toUpperCase() === "MEMORY") {
36+
throw new Error(`'MEMORY' is a reserved name and cannot be used as a memory file name`)
37+
}
38+
39+
return `${base}.md`
40+
}
41+
1342
const MAX_SANITIZED_LENGTH = 200
1443

1544
// Exact copy of Claude Code's djb2Hash() from utils/hash.ts

src/prompt.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { MEMORY_TYPES } from "./memory.js"
2-
import { readIndex, truncateEntrypoint, listMemories } from "./memory.js"
2+
import { readIndex, truncateEntrypoint } from "./memory.js"
33
import { getMemoryDir, ENTRYPOINT_NAME } from "./paths.js"
44

55
const FRONTMATTER_EXAMPLE = `\`\`\`markdown
@@ -89,7 +89,7 @@ A memory that names a specific function, file, or flag is a claim that it existe
8989
9090
A memory that summarizes repo state is frozen in time. If the user asks about *recent* or *current* state, prefer \`git log\` or reading the code over recalling the snapshot.`
9191

92-
export function buildMemorySystemPrompt(worktree: string): string {
92+
export function buildMemorySystemPrompt(worktree: string, recalledMemoriesSection?: string): string {
9393
const memoryDir = getMemoryDir(worktree)
9494
const indexContent = readIndex(worktree)
9595

@@ -139,5 +139,9 @@ export function buildMemorySystemPrompt(worktree: string): string {
139139
)
140140
}
141141

142+
if (recalledMemoriesSection?.trim()) {
143+
lines.push("", recalledMemoriesSection)
144+
}
145+
142146
return lines.join("\n")
143147
}

0 commit comments

Comments
 (0)