opencode-claude-memory 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/opencode CHANGED
@@ -125,6 +125,22 @@ log() {
125
125
  echo "[opencode-memory] $*" >&2
126
126
  }
127
127
 
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
+
128
144
  get_latest_session_id() {
129
145
  local session_json
130
146
  session_json=$("$REAL_OPENCODE" session list --format json -n 1 2>/dev/null) || return 1
@@ -157,6 +173,10 @@ release_lock() {
157
173
  rm -f "$LOCK_FILE"
158
174
  }
159
175
 
176
+ cleanup_timestamp() {
177
+ rm -f "$TIMESTAMP_FILE"
178
+ }
179
+
160
180
  run_extraction() {
161
181
  local session_id="$1"
162
182
 
@@ -190,12 +210,16 @@ run_extraction() {
190
210
  # Main
191
211
  # ============================================================================
192
212
 
213
+ # Step 0: Create timestamp marker before running opencode
214
+ TIMESTAMP_FILE=$(mktemp)
215
+
193
216
  # Step 1: Run the real opencode with all original arguments, capture exit code
194
217
  opencode_exit=0
195
218
  "$REAL_OPENCODE" "$@" || opencode_exit=$?
196
219
 
197
220
  # Step 2: Check if extraction is enabled
198
221
  if [ "$EXTRACT_ENABLED" = "0" ]; then
222
+ cleanup_timestamp
199
223
  exit $opencode_exit
200
224
  fi
201
225
 
@@ -204,11 +228,20 @@ session_id=$(get_latest_session_id)
204
228
 
205
229
  if [ -z "$session_id" ]; then
206
230
  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
207
239
  exit $opencode_exit
208
240
  fi
209
241
 
210
242
  # Step 4: Acquire lock (prevent concurrent extractions)
211
243
  if ! acquire_lock; then
244
+ cleanup_timestamp
212
245
  exit $opencode_exit
213
246
  fi
214
247
 
@@ -216,11 +249,13 @@ fi
216
249
  if [ "$FOREGROUND" = "1" ]; then
217
250
  # Foreground mode (for debugging)
218
251
  run_extraction "$session_id"
252
+ cleanup_timestamp
219
253
  else
220
254
  # Background mode (default) — user isn't blocked
221
255
  run_extraction "$session_id" &
222
256
  disown
223
257
  log "Memory extraction started in background (PID $!)"
258
+ cleanup_timestamp
224
259
  fi
225
260
 
226
261
  exit $opencode_exit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Cross-session memory plugin for OpenCode — Claude Code compatible, persistent, file-based memory",
6
6
  "main": "src/index.ts",
@@ -33,5 +33,9 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "zod": "^3.24.0"
36
+ },
37
+ "devDependencies": {
38
+ "bun-types": "^1.3.11",
39
+ "@opencode-ai/plugin": "^1.3.10"
36
40
  }
37
41
  }
package/src/index.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { tool } from "@opencode-ai/plugin"
3
3
  import { buildMemorySystemPrompt } from "./prompt.js"
4
+ import { recallRelevantMemories, formatRecalledMemories } from "./recall.js"
4
5
  import {
5
6
  saveMemory,
6
7
  deleteMemory,
7
8
  listMemories,
8
9
  searchMemories,
9
10
  readMemory,
10
- readIndex,
11
11
  MEMORY_TYPES,
12
- type MemoryType,
13
12
  } from "./memory.js"
14
13
  import { getMemoryDir } from "./paths.js"
15
14
 
@@ -18,7 +17,26 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
18
17
 
19
18
  return {
20
19
  "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)
22
40
  output.system.push(memoryPrompt)
23
41
  },
24
42
 
package/src/memory.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "fs"
2
2
  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"
4
12
 
5
13
  export const MEMORY_TYPES = ["user", "feedback", "project", "reference"] as const
6
14
  export type MemoryType = (typeof MEMORY_TYPES)[number]
@@ -21,11 +29,20 @@ function parseFrontmatter(raw: string): { frontmatter: Record<string, string>; c
21
29
  return { frontmatter: {}, content: trimmed }
22
30
  }
23
31
 
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) {
26
41
  return { frontmatter: {}, content: trimmed }
27
42
  }
28
43
 
44
+ const endIndex = lines.slice(0, closingLineIdx).join("\n").length + 1
45
+
29
46
  const frontmatterBlock = trimmed.slice(3, endIndex).trim()
30
47
  const content = trimmed.slice(endIndex + 3).trim()
31
48
 
@@ -61,6 +78,7 @@ export function listMemories(worktree: string): MemoryEntry[] {
61
78
  files = readdirSync(memDir)
62
79
  .filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
63
80
  .sort()
81
+ .slice(0, MAX_MEMORY_FILES)
64
82
  } catch {
65
83
  return entries
66
84
  }
@@ -88,8 +106,9 @@ export function listMemories(worktree: string): MemoryEntry[] {
88
106
  }
89
107
 
90
108
  export function readMemory(worktree: string, fileName: string): MemoryEntry | null {
109
+ const safeName = validateMemoryFileName(fileName)
91
110
  const memDir = getMemoryDir(worktree)
92
- const filePath = join(memDir, fileName.endsWith(".md") ? fileName : `${fileName}.md`)
111
+ const filePath = join(memDir, safeName)
93
112
 
94
113
  try {
95
114
  const rawContent = readFileSync(filePath, "utf-8")
@@ -116,11 +135,16 @@ export function saveMemory(
116
135
  type: MemoryType,
117
136
  content: string,
118
137
  ): string {
138
+ const safeName = validateMemoryFileName(fileName)
119
139
  const memDir = getMemoryDir(worktree)
120
- const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
121
140
  const filePath = join(memDir, safeName)
122
141
 
123
142
  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
+ }
124
148
  writeFileSync(filePath, fileContent, "utf-8")
125
149
 
126
150
  updateIndex(worktree, safeName, name, description)
@@ -129,8 +153,8 @@ export function saveMemory(
129
153
  }
130
154
 
131
155
  export function deleteMemory(worktree: string, fileName: string): boolean {
156
+ const safeName = validateMemoryFileName(fileName)
132
157
  const memDir = getMemoryDir(worktree)
133
- const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
134
158
  const filePath = join(memDir, safeName)
135
159
 
136
160
  try {
package/src/paths.ts CHANGED
@@ -10,6 +10,35 @@ export const ENTRYPOINT_NAME = "MEMORY.md"
10
10
  export const MAX_ENTRYPOINT_LINES = 200
11
11
  export const MAX_ENTRYPOINT_BYTES = 25_000
12
12
 
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
+
13
42
  const MAX_SANITIZED_LENGTH = 200
14
43
 
15
44
  // Exact copy of Claude Code's djb2Hash() from utils/hash.ts
package/src/prompt.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { MEMORY_TYPES } from "./memory.js"
2
- import { readIndex, truncateEntrypoint, listMemories } from "./memory.js"
2
+ import { readIndex, truncateEntrypoint } from "./memory.js"
3
3
  import { getMemoryDir, ENTRYPOINT_NAME } from "./paths.js"
4
4
 
5
5
  const FRONTMATTER_EXAMPLE = `\`\`\`markdown
@@ -89,7 +89,7 @@ A memory that names a specific function, file, or flag is a claim that it existe
89
89
 
90
90
  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.`
91
91
 
92
- export function buildMemorySystemPrompt(worktree: string): string {
92
+ export function buildMemorySystemPrompt(worktree: string, recalledMemoriesSection?: string): string {
93
93
  const memoryDir = getMemoryDir(worktree)
94
94
  const indexContent = readIndex(worktree)
95
95
 
@@ -139,5 +139,9 @@ export function buildMemorySystemPrompt(worktree: string): string {
139
139
  )
140
140
  }
141
141
 
142
+ if (recalledMemoriesSection?.trim()) {
143
+ lines.push("", recalledMemoriesSection)
144
+ }
145
+
142
146
  return lines.join("\n")
143
147
  }
package/src/recall.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { statSync } from "fs"
2
+ import { listMemories, type MemoryEntry } from "./memory.js"
3
+
4
+ const encoder = new TextEncoder()
5
+
6
+ export type RecalledMemory = {
7
+ fileName: string
8
+ name: string
9
+ type: string
10
+ description: string
11
+ content: string
12
+ ageInDays: number
13
+ }
14
+
15
+ const MAX_RECALLED_MEMORIES = 5
16
+ const MAX_MEMORY_LINES = 200
17
+ const MAX_MEMORY_BYTES = 4096
18
+
19
+ function tokenizeQuery(query: string): string[] {
20
+ return [...new Set(query.toLowerCase().split(/\s+/).map((token) => token.trim()).filter((token) => token.length >= 2))]
21
+ }
22
+
23
+ function getMemoryMtimeMs(entry: MemoryEntry): number {
24
+ try {
25
+ return statSync(entry.filePath).mtimeMs
26
+ } catch {
27
+ return 0
28
+ }
29
+ }
30
+
31
+ function scoreMemory(entry: MemoryEntry, terms: string[]): number {
32
+ if (terms.length === 0) return 0
33
+ const haystack = `${entry.name}\n${entry.description}\n${entry.content}`.toLowerCase()
34
+ let score = 0
35
+ for (const term of terms) {
36
+ if (haystack.includes(term)) score += 1
37
+ }
38
+ return score
39
+ }
40
+
41
+ function truncateMemoryContent(content: string): string {
42
+ const maxLines = content.split("\n").slice(0, MAX_MEMORY_LINES)
43
+ const lineTruncated = maxLines.join("\n")
44
+ if (encoder.encode(lineTruncated).length <= MAX_MEMORY_BYTES) {
45
+ return lineTruncated
46
+ }
47
+
48
+ const lines = lineTruncated.split("\n")
49
+ const kept: string[] = []
50
+ let usedBytes = 0
51
+
52
+ for (const line of lines) {
53
+ const candidate = kept.length === 0 ? line : `\n${line}`
54
+ const candidateBytes = encoder.encode(candidate).length
55
+ if (usedBytes + candidateBytes > MAX_MEMORY_BYTES) break
56
+ kept.push(line)
57
+ usedBytes += candidateBytes
58
+ }
59
+
60
+ return kept.join("\n")
61
+ }
62
+
63
+ export function recallRelevantMemories(worktree: string, query?: string): RecalledMemory[] {
64
+ const memories = listMemories(worktree)
65
+ if (memories.length === 0) return []
66
+
67
+ const now = Date.now()
68
+ const memoriesWithMeta = memories.map((entry) => {
69
+ const mtimeMs = getMemoryMtimeMs(entry)
70
+ return {
71
+ entry,
72
+ mtimeMs,
73
+ }
74
+ })
75
+
76
+ const terms = query ? tokenizeQuery(query) : []
77
+
78
+ let selected = memoriesWithMeta
79
+
80
+ if (terms.length > 0) {
81
+ const withScores = memoriesWithMeta
82
+ .map((item) => ({
83
+ ...item,
84
+ score: scoreMemory(item.entry, terms),
85
+ }))
86
+ .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)
87
+
88
+ if (withScores.some((item) => item.score > 0)) {
89
+ selected = withScores
90
+ }
91
+ }
92
+
93
+ if (selected === memoriesWithMeta) {
94
+ selected = [...memoriesWithMeta].sort((a, b) => b.mtimeMs - a.mtimeMs)
95
+ }
96
+
97
+ return selected.slice(0, MAX_RECALLED_MEMORIES).map(({ entry, mtimeMs }) => ({
98
+ fileName: entry.fileName,
99
+ name: entry.name,
100
+ type: entry.type,
101
+ description: entry.description,
102
+ content: truncateMemoryContent(entry.content),
103
+ ageInDays: Math.max(0, Math.floor((now - mtimeMs) / (1000 * 60 * 60 * 24))),
104
+ }))
105
+ }
106
+
107
+ function formatAgeWarning(ageInDays: number): string {
108
+ if (ageInDays <= 1) return ""
109
+ return `\n> ⚠️ This memory is ${ageInDays} days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.\n`
110
+ }
111
+
112
+ export function formatRecalledMemories(memories: RecalledMemory[]): string {
113
+ if (memories.length === 0) return ""
114
+
115
+ const sections = memories.map((memory) => {
116
+ const ageWarning = formatAgeWarning(memory.ageInDays)
117
+ return `### ${memory.name} (${memory.type})${ageWarning}\n${memory.content}`
118
+ })
119
+ return [
120
+ "## Recalled Memories",
121
+ "",
122
+ "The following memories were automatically selected as relevant to this conversation. They may be outdated — verify against current state before relying on them.",
123
+ "",
124
+ sections.join("\n\n"),
125
+ ].join("\n")
126
+ }