opencode-claude-memory 1.6.0 → 1.6.2

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/README.md CHANGED
@@ -230,7 +230,9 @@ Supported memory types:
230
230
  # Run tests
231
231
  bun test
232
232
 
233
- # No build needed — raw TS consumed by OpenCode
233
+ # Build published artifacts
234
+ bun run build
235
+
234
236
  # Release: push to main triggers semantic-release → npm publish
235
237
  ```
236
238
 
@@ -956,6 +956,80 @@ rollback_consolidation_lock() {
956
956
  set_file_mtime_secs "$path" "$prior_mtime"
957
957
  }
958
958
 
959
+ cleanup_forked_sessions() {
960
+ local before_json="$1"
961
+
962
+ if ! command -v python3 >/dev/null 2>&1; then
963
+ return 0
964
+ fi
965
+
966
+ local after_json
967
+ after_json=$(get_session_list_json 10 2>/dev/null || true)
968
+
969
+ if [ -z "$before_json" ] || [ -z "$after_json" ]; then
970
+ return 0
971
+ fi
972
+
973
+ local fork_ids
974
+ fork_ids=$(python3 - "$before_json" "$after_json" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" <<'PY'
975
+ import json
976
+ import os
977
+ import sys
978
+
979
+ def parse(raw):
980
+ try:
981
+ data = json.loads(raw)
982
+ return data if isinstance(data, list) else []
983
+ except Exception:
984
+ return []
985
+
986
+ before_raw, after_raw, workdir, project_dir = sys.argv[1:5]
987
+ before = parse(before_raw)
988
+ after = parse(after_raw)
989
+
990
+ before_ids = {item.get("id") for item in before if item.get("id")}
991
+ workdir = os.path.realpath(workdir)
992
+ project_dir = os.path.realpath(project_dir)
993
+
994
+ for item in after:
995
+ sid = item.get("id", "")
996
+ if not sid or sid in before_ids:
997
+ continue
998
+ directory = item.get("directory", "")
999
+ if not directory:
1000
+ continue
1001
+ d = os.path.realpath(directory)
1002
+ if d in (workdir, project_dir):
1003
+ print(sid)
1004
+ PY
1005
+ ) || return 0
1006
+
1007
+ while IFS= read -r fork_id; do
1008
+ [ -n "$fork_id" ] || continue
1009
+ if "$REAL_OPENCODE" session delete "$fork_id" >/dev/null 2>&1; then
1010
+ log "Cleaned up forked session $fork_id"
1011
+ fi
1012
+ done <<< "$fork_ids"
1013
+ }
1014
+
1015
+ session_has_conversation() {
1016
+ local session_id="$1"
1017
+ local transcript_file
1018
+ transcript_file="$(get_transcripts_dir)/${session_id}.jsonl"
1019
+
1020
+ if [ -f "$transcript_file" ]; then
1021
+ local line_count
1022
+ line_count=$(wc -l < "$transcript_file")
1023
+ # Empty sessions have at most 1 line (the initial user entry with no content).
1024
+ # Real sessions have 3+ lines (user + tool_use + tool_result at minimum).
1025
+ [ "$line_count" -gt 1 ]
1026
+ return $?
1027
+ fi
1028
+
1029
+ # Transcript not found — assume content exists to avoid skipping a valid session.
1030
+ return 0
1031
+ }
1032
+
959
1033
  run_extraction_if_needed() {
960
1034
  local session_id="$1"
961
1035
  local memory_written_during_session="$2"
@@ -985,6 +1059,9 @@ run_extraction_if_needed() {
985
1059
  fi
986
1060
  cmd+=("$EXTRACT_PROMPT")
987
1061
 
1062
+ local pre_fork_json
1063
+ pre_fork_json=$(get_session_list_json 5 2>/dev/null || true)
1064
+
988
1065
  if "${cmd[@]}" >> "$EXTRACT_LOG_FILE" 2>&1; then
989
1066
  log "Memory extraction completed successfully"
990
1067
  else
@@ -992,6 +1069,7 @@ run_extraction_if_needed() {
992
1069
  log "Memory extraction failed (exit code $code). Check $EXTRACT_LOG_FILE for details"
993
1070
  fi
994
1071
 
1072
+ cleanup_forked_sessions "$pre_fork_json"
995
1073
  release_simple_lock "$EXTRACT_LOCK_FILE"
996
1074
  }
997
1075
 
@@ -1043,6 +1121,9 @@ run_autodream_if_needed() {
1043
1121
  fi
1044
1122
  cmd+=("$AUTODREAM_PROMPT")
1045
1123
 
1124
+ local pre_fork_json
1125
+ pre_fork_json=$(get_session_list_json 5 2>/dev/null || true)
1126
+
1046
1127
  if "${cmd[@]}" >> "$AUTODREAM_LOG_FILE" 2>&1; then
1047
1128
  log "Auto-dream consolidation completed successfully"
1048
1129
  # Keep lock mtime at "now" to represent last consolidated timestamp.
@@ -1050,8 +1131,9 @@ run_autodream_if_needed() {
1050
1131
  local code=$?
1051
1132
  log "Auto-dream consolidation failed (exit code $code). Rolling back gate timestamp"
1052
1133
  rollback_consolidation_lock "$CONSOLIDATION_PRIOR_MTIME"
1053
- return 0
1054
1134
  fi
1135
+
1136
+ cleanup_forked_sessions "$pre_fork_json"
1055
1137
  }
1056
1138
 
1057
1139
  run_post_session_tasks() {
@@ -1093,6 +1175,13 @@ if [ -z "$session_id" ]; then
1093
1175
  exit $opencode_exit
1094
1176
  fi
1095
1177
 
1178
+ # Step 3.5: Skip if session had no real conversation (e.g. user opened TUI and exited)
1179
+ if ! session_has_conversation "$session_id"; then
1180
+ log "Session $session_id has no conversation, skipping post-session memory maintenance"
1181
+ cleanup_timestamp
1182
+ exit $opencode_exit
1183
+ fi
1184
+
1096
1185
  # Step 4: Check whether main session already wrote memory files
1097
1186
  memory_written_during_session=0
1098
1187
  if has_new_memories; then
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const MemoryPlugin: Plugin;
package/dist/index.js ADDED
@@ -0,0 +1,239 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { buildMemorySystemPrompt } from "./prompt.js";
3
+ import { recallRelevantMemories, formatRecalledMemories } from "./recall.js";
4
+ import { saveMemory, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
5
+ import { getMemoryDir } from "./paths.js";
6
+ const turnContextBySession = new Map();
7
+ function shouldIgnoreMemoryContext(query) {
8
+ if (process.env.OPENCODE_MEMORY_IGNORE === "1")
9
+ return true;
10
+ if (!query)
11
+ return false;
12
+ const normalized = query.toLowerCase();
13
+ return (/(ignore|don't use|do not use|without|skip)\s+(the\s+)?memory/.test(normalized) ||
14
+ /memory\s+(should be|must be)?\s*ignored/.test(normalized));
15
+ }
16
+ function extractUserQuery(message) {
17
+ if (!message || typeof message !== "object")
18
+ return undefined;
19
+ if ("content" in message) {
20
+ const content = message.content;
21
+ if (typeof content === "string")
22
+ return content;
23
+ if (content !== undefined)
24
+ return JSON.stringify(content);
25
+ }
26
+ if ("parts" in message) {
27
+ const parts = message.parts;
28
+ if (Array.isArray(parts)) {
29
+ const text = parts
30
+ .map((part) => {
31
+ if (!part || typeof part !== "object")
32
+ return "";
33
+ return typeof part.text === "string"
34
+ ? part.text
35
+ : "";
36
+ })
37
+ .filter(Boolean)
38
+ .join("\n")
39
+ .trim();
40
+ if (text)
41
+ return text;
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+ function getLastUserQuery(messages) {
47
+ for (let i = messages.length - 1; i >= 0; i--) {
48
+ const message = messages[i];
49
+ if (message?.info?.role !== "user")
50
+ continue;
51
+ const query = extractUserQuery(message);
52
+ const sessionID = typeof message.info?.sessionID === "string" ? message.info.sessionID : undefined;
53
+ return { query, sessionID };
54
+ }
55
+ return {};
56
+ }
57
+ function isAutoMemoryPart(part) {
58
+ if (!part || typeof part !== "object")
59
+ return false;
60
+ return typeof part.text === "string" &&
61
+ part.text.includes("# Auto Memory");
62
+ }
63
+ // Parses "### <name> (<type>)" headers from the ## Recalled Memories section
64
+ // of system prompts. After compaction old system messages disappear, so
65
+ // the returned set naturally shrinks — no manual reset needed.
66
+ function extractSurfacedMemoryKeys(systemText) {
67
+ const keys = new Set();
68
+ const recalledSection = systemText.indexOf("## Recalled Memories");
69
+ if (recalledSection === -1)
70
+ return keys;
71
+ const headerPattern = /^### (.+?) \((\w+)\)/gm;
72
+ const section = systemText.slice(recalledSection);
73
+ for (let match = headerPattern.exec(section); match !== null; match = headerPattern.exec(section)) {
74
+ keys.add(`${match[1]}|${match[2]}`);
75
+ }
76
+ return keys;
77
+ }
78
+ // Only completed tools — matches Claude Code's collectRecentSuccessfulTools().
79
+ function extractRecentTools(messages) {
80
+ const tools = [];
81
+ const seen = new Set();
82
+ for (const message of messages) {
83
+ if (!message.parts || !Array.isArray(message.parts))
84
+ continue;
85
+ for (const part of message.parts) {
86
+ if (!part || typeof part !== "object")
87
+ continue;
88
+ const p = part;
89
+ if (p.type !== "tool" || !p.tool)
90
+ continue;
91
+ if (p.state?.status !== "completed")
92
+ continue;
93
+ if (seen.has(p.tool))
94
+ continue;
95
+ seen.add(p.tool);
96
+ tools.push(p.tool);
97
+ }
98
+ }
99
+ return tools;
100
+ }
101
+ export const MemoryPlugin = async ({ worktree }) => {
102
+ getMemoryDir(worktree);
103
+ return {
104
+ "experimental.chat.messages.transform": async (_input, output) => {
105
+ const { query, sessionID } = getLastUserQuery(output.messages);
106
+ if (sessionID) {
107
+ const alreadySurfaced = new Set();
108
+ for (const message of output.messages) {
109
+ const role = String(message.info.role);
110
+ if (role !== "system")
111
+ continue;
112
+ for (const part of message.parts) {
113
+ if (!part || typeof part !== "object")
114
+ continue;
115
+ const text = part.text;
116
+ if (typeof text === "string") {
117
+ for (const key of extractSurfacedMemoryKeys(text)) {
118
+ alreadySurfaced.add(key);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ const recentTools = extractRecentTools(output.messages);
124
+ turnContextBySession.set(sessionID, { query, alreadySurfaced, recentTools });
125
+ }
126
+ if (shouldIgnoreMemoryContext(query)) {
127
+ output.messages = output.messages
128
+ .map((message) => {
129
+ const role = String(message.info.role);
130
+ if (role !== "system")
131
+ return message;
132
+ const parts = message.parts.filter((part) => !isAutoMemoryPart(part));
133
+ return { ...message, parts };
134
+ })
135
+ .filter((message) => message.parts.length > 0);
136
+ }
137
+ },
138
+ "experimental.chat.system.transform": async (_input, output) => {
139
+ let sessionID;
140
+ if (_input && typeof _input === "object") {
141
+ sessionID = (typeof _input.sessionID === "string"
142
+ ? _input.sessionID
143
+ : undefined);
144
+ }
145
+ const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined;
146
+ const query = ctx?.query;
147
+ const alreadySurfaced = ctx?.alreadySurfaced ?? new Set();
148
+ const recentTools = ctx?.recentTools ?? [];
149
+ const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
150
+ const recalled = ignoreMemoryContext ? [] : recallRelevantMemories(worktree, query, alreadySurfaced, recentTools);
151
+ const recalledSection = formatRecalledMemories(recalled);
152
+ const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection, {
153
+ includeIndex: !ignoreMemoryContext,
154
+ });
155
+ output.system.push(memoryPrompt);
156
+ },
157
+ tool: {
158
+ memory_save: tool({
159
+ description: "Save or update a memory for future conversations. " +
160
+ "Each memory is stored as a markdown file with frontmatter. " +
161
+ "Use this when the user explicitly asks you to remember something, " +
162
+ "or when you observe important information worth preserving across sessions " +
163
+ "(user preferences, feedback, project context, external references). " +
164
+ "Check existing memories first with memory_list or memory_search to avoid duplicates.",
165
+ args: {
166
+ file_name: tool.schema
167
+ .string()
168
+ .describe('File name for the memory (without .md extension). Use snake_case, e.g. "user_role", "feedback_testing_style", "project_auth_rewrite"'),
169
+ name: tool.schema.string().describe("Human-readable name for this memory"),
170
+ description: tool.schema
171
+ .string()
172
+ .describe("One-line description — used to decide relevance in future conversations, so be specific"),
173
+ type: tool.schema
174
+ .enum(MEMORY_TYPES)
175
+ .describe("Memory type: user (about the person), feedback (guidance on approach), project (ongoing work context), reference (pointers to external systems)"),
176
+ content: tool.schema
177
+ .string()
178
+ .describe("Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines"),
179
+ },
180
+ async execute(args) {
181
+ const filePath = saveMemory(worktree, args.file_name, args.name, args.description, args.type, args.content);
182
+ return `Memory saved to ${filePath}`;
183
+ },
184
+ }),
185
+ memory_delete: tool({
186
+ description: "Delete a memory that is outdated, wrong, or no longer relevant. Also removes it from the index.",
187
+ args: {
188
+ file_name: tool.schema.string().describe("File name of the memory to delete (with or without .md extension)"),
189
+ },
190
+ async execute(args) {
191
+ const deleted = deleteMemory(worktree, args.file_name);
192
+ return deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`;
193
+ },
194
+ }),
195
+ memory_list: tool({
196
+ description: "List all saved memories with their names, types, and descriptions. " +
197
+ "Use this to check what memories exist before saving a new one (to avoid duplicates) " +
198
+ "or when you need to recall what's been stored.",
199
+ args: {},
200
+ async execute() {
201
+ const entries = listMemories(worktree);
202
+ if (entries.length === 0) {
203
+ return "No memories saved yet.";
204
+ }
205
+ const lines = entries.map((e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}`);
206
+ return `${entries.length} memories found:\n${lines.join("\n")}`;
207
+ },
208
+ }),
209
+ memory_search: tool({
210
+ description: "Search memories by keyword. Searches across names, descriptions, and content. " +
211
+ "Use this to find relevant memories before answering questions or when the user references past conversations.",
212
+ args: {
213
+ query: tool.schema.string().describe("Search query — searches across name, description, and content"),
214
+ },
215
+ async execute(args) {
216
+ const results = searchMemories(worktree, args.query);
217
+ if (results.length === 0) {
218
+ return `No memories matching "${args.query}".`;
219
+ }
220
+ const lines = results.map((e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}\n Content: ${e.content.slice(0, 200)}${e.content.length > 200 ? "..." : ""}`);
221
+ return `${results.length} matches for "${args.query}":\n${lines.join("\n")}`;
222
+ },
223
+ }),
224
+ memory_read: tool({
225
+ description: "Read the full content of a specific memory file.",
226
+ args: {
227
+ file_name: tool.schema.string().describe("File name of the memory to read (with or without .md extension)"),
228
+ },
229
+ async execute(args) {
230
+ const entry = readMemory(worktree, args.file_name);
231
+ if (!entry) {
232
+ return `Memory "${args.file_name}" not found.`;
233
+ }
234
+ return `# ${entry.name}\n**Type:** ${entry.type}\n**Description:** ${entry.description}\n\n${entry.content}`;
235
+ },
236
+ }),
237
+ },
238
+ };
239
+ };
@@ -0,0 +1,25 @@
1
+ export declare const MEMORY_TYPES: readonly ["user", "feedback", "project", "reference"];
2
+ export type MemoryType = (typeof MEMORY_TYPES)[number];
3
+ export type MemoryEntry = {
4
+ filePath: string;
5
+ fileName: string;
6
+ name: string;
7
+ description: string;
8
+ type: MemoryType;
9
+ content: string;
10
+ rawContent: string;
11
+ };
12
+ export declare function listMemories(worktree: string): MemoryEntry[];
13
+ export declare function readMemory(worktree: string, fileName: string): MemoryEntry | null;
14
+ export declare function saveMemory(worktree: string, fileName: string, name: string, description: string, type: MemoryType, content: string): string;
15
+ export declare function deleteMemory(worktree: string, fileName: string): boolean;
16
+ export declare function searchMemories(worktree: string, query: string): MemoryEntry[];
17
+ export declare function readIndex(worktree: string): string;
18
+ export type EntrypointTruncation = {
19
+ content: string;
20
+ lineCount: number;
21
+ byteCount: number;
22
+ wasLineTruncated: boolean;
23
+ wasByteTruncated: boolean;
24
+ };
25
+ export declare function truncateEntrypoint(raw: string): EntrypointTruncation;
package/dist/memory.js ADDED
@@ -0,0 +1,200 @@
1
+ import { readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs";
2
+ import { join, basename } from "path";
3
+ import { getMemoryDir, getMemoryEntrypoint, ENTRYPOINT_NAME, validateMemoryFileName, MAX_MEMORY_FILES, MAX_MEMORY_FILE_BYTES, MAX_ENTRYPOINT_LINES, MAX_ENTRYPOINT_BYTES, FRONTMATTER_MAX_LINES, } from "./paths.js";
4
+ export const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
5
+ function parseFrontmatter(raw) {
6
+ const trimmed = raw.trim();
7
+ if (!trimmed.startsWith("---")) {
8
+ return { frontmatter: {}, content: trimmed };
9
+ }
10
+ const lines = trimmed.split("\n");
11
+ let closingLineIdx = -1;
12
+ for (let i = 1; i < Math.min(lines.length, FRONTMATTER_MAX_LINES); i++) {
13
+ if (lines[i].trimEnd() === "---") {
14
+ closingLineIdx = i;
15
+ break;
16
+ }
17
+ }
18
+ if (closingLineIdx === -1) {
19
+ return { frontmatter: {}, content: trimmed };
20
+ }
21
+ const endIndex = lines.slice(0, closingLineIdx).join("\n").length + 1;
22
+ const frontmatterBlock = trimmed.slice(3, endIndex).trim();
23
+ const content = trimmed.slice(endIndex + 3).trim();
24
+ const frontmatter = {};
25
+ for (const line of frontmatterBlock.split("\n")) {
26
+ const colonIdx = line.indexOf(":");
27
+ if (colonIdx === -1)
28
+ continue;
29
+ const key = line.slice(0, colonIdx).trim();
30
+ const value = line.slice(colonIdx + 1).trim();
31
+ if (key && value) {
32
+ frontmatter[key] = value;
33
+ }
34
+ }
35
+ return { frontmatter, content };
36
+ }
37
+ function buildFrontmatter(name, description, type) {
38
+ return `---\nname: ${name}\ndescription: ${description}\ntype: ${type}\n---`;
39
+ }
40
+ function parseMemoryType(raw) {
41
+ if (!raw)
42
+ return undefined;
43
+ return MEMORY_TYPES.find((t) => t === raw);
44
+ }
45
+ export function listMemories(worktree) {
46
+ const memDir = getMemoryDir(worktree);
47
+ const entries = [];
48
+ let files;
49
+ try {
50
+ files = readdirSync(memDir, { encoding: "utf-8" })
51
+ .filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
52
+ .sort()
53
+ .slice(0, MAX_MEMORY_FILES);
54
+ }
55
+ catch {
56
+ return entries;
57
+ }
58
+ for (const fileName of files) {
59
+ const filePath = join(memDir, fileName);
60
+ try {
61
+ const rawContent = readFileSync(filePath, "utf-8");
62
+ const { frontmatter, content } = parseFrontmatter(rawContent);
63
+ entries.push({
64
+ filePath,
65
+ fileName,
66
+ name: frontmatter.name ?? fileName.replace(/\.md$/, ""),
67
+ description: frontmatter.description ?? "",
68
+ type: parseMemoryType(frontmatter.type) ?? "user",
69
+ content,
70
+ rawContent,
71
+ });
72
+ }
73
+ catch {
74
+ }
75
+ }
76
+ return entries;
77
+ }
78
+ export function readMemory(worktree, fileName) {
79
+ const safeName = validateMemoryFileName(fileName);
80
+ const memDir = getMemoryDir(worktree);
81
+ const filePath = join(memDir, safeName);
82
+ try {
83
+ const rawContent = readFileSync(filePath, "utf-8");
84
+ const { frontmatter, content } = parseFrontmatter(rawContent);
85
+ return {
86
+ filePath,
87
+ fileName: basename(filePath),
88
+ name: frontmatter.name ?? fileName.replace(/\.md$/, ""),
89
+ description: frontmatter.description ?? "",
90
+ type: parseMemoryType(frontmatter.type) ?? "user",
91
+ content,
92
+ rawContent,
93
+ };
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ export function saveMemory(worktree, fileName, name, description, type, content) {
100
+ const safeName = validateMemoryFileName(fileName);
101
+ const memDir = getMemoryDir(worktree);
102
+ const filePath = join(memDir, safeName);
103
+ const fileContent = `${buildFrontmatter(name, description, type)}\n\n${content.trim()}\n`;
104
+ if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
105
+ throw new Error(`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`);
106
+ }
107
+ writeFileSync(filePath, fileContent, "utf-8");
108
+ updateIndex(worktree, safeName, name, description);
109
+ return filePath;
110
+ }
111
+ export function deleteMemory(worktree, fileName) {
112
+ const safeName = validateMemoryFileName(fileName);
113
+ const memDir = getMemoryDir(worktree);
114
+ const filePath = join(memDir, safeName);
115
+ try {
116
+ unlinkSync(filePath);
117
+ removeFromIndex(worktree, safeName);
118
+ return true;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ export function searchMemories(worktree, query) {
125
+ const all = listMemories(worktree);
126
+ const lowerQuery = query.toLowerCase();
127
+ return all.filter((entry) => entry.name.toLowerCase().includes(lowerQuery) ||
128
+ entry.description.toLowerCase().includes(lowerQuery) ||
129
+ entry.content.toLowerCase().includes(lowerQuery));
130
+ }
131
+ export function readIndex(worktree) {
132
+ const entrypoint = getMemoryEntrypoint(worktree);
133
+ try {
134
+ return readFileSync(entrypoint, "utf-8");
135
+ }
136
+ catch {
137
+ return "";
138
+ }
139
+ }
140
+ function updateIndex(worktree, fileName, name, description) {
141
+ const entrypoint = getMemoryEntrypoint(worktree);
142
+ const existing = readIndex(worktree);
143
+ const lines = existing.split("\n").filter((l) => l.trim());
144
+ const pointer = `- [${name}](${fileName}) — ${description}`;
145
+ const existingIdx = lines.findIndex((l) => l.includes(`(${fileName})`));
146
+ if (existingIdx >= 0) {
147
+ lines[existingIdx] = pointer;
148
+ }
149
+ else {
150
+ lines.push(pointer);
151
+ }
152
+ writeFileSync(entrypoint, lines.join("\n") + "\n", "utf-8");
153
+ }
154
+ function removeFromIndex(worktree, fileName) {
155
+ const entrypoint = getMemoryEntrypoint(worktree);
156
+ const existing = readIndex(worktree);
157
+ const lines = existing
158
+ .split("\n")
159
+ .filter((l) => l.trim() && !l.includes(`(${fileName})`));
160
+ writeFileSync(entrypoint, lines.length > 0 ? lines.join("\n") + "\n" : "", "utf-8");
161
+ }
162
+ function formatFileSize(bytes) {
163
+ if (bytes < 1024)
164
+ return `${bytes}B`;
165
+ return `${(bytes / 1024).toFixed(1)}KB`;
166
+ }
167
+ // Port of Claude Code's truncateEntrypointContent() from memdir.ts.
168
+ // Uses .length (char count, same as Claude Code) for byte measurement.
169
+ export function truncateEntrypoint(raw) {
170
+ const trimmed = raw.trim();
171
+ if (!trimmed)
172
+ return { content: "", lineCount: 0, byteCount: 0, wasLineTruncated: false, wasByteTruncated: false };
173
+ const contentLines = trimmed.split("\n");
174
+ const lineCount = contentLines.length;
175
+ const byteCount = trimmed.length;
176
+ const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES;
177
+ const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES;
178
+ if (!wasLineTruncated && !wasByteTruncated) {
179
+ return { content: trimmed, lineCount, byteCount, wasLineTruncated, wasByteTruncated };
180
+ }
181
+ let truncated = wasLineTruncated
182
+ ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join("\n")
183
+ : trimmed;
184
+ if (truncated.length > MAX_ENTRYPOINT_BYTES) {
185
+ const cutAt = truncated.lastIndexOf("\n", MAX_ENTRYPOINT_BYTES);
186
+ truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES);
187
+ }
188
+ const reason = wasByteTruncated && !wasLineTruncated
189
+ ? `${formatFileSize(byteCount)} (limit: ${formatFileSize(MAX_ENTRYPOINT_BYTES)}) — index entries are too long`
190
+ : wasLineTruncated && !wasByteTruncated
191
+ ? `${lineCount} lines (limit: ${MAX_ENTRYPOINT_LINES})`
192
+ : `${lineCount} lines and ${formatFileSize(byteCount)}`;
193
+ return {
194
+ content: truncated + `\n\n> WARNING: ${ENTRYPOINT_NAME} is ${reason}. Only part of it was loaded. Keep index entries to one line under ~200 chars; move detail into topic files.`,
195
+ lineCount,
196
+ byteCount,
197
+ wasLineTruncated,
198
+ wasByteTruncated,
199
+ };
200
+ }
@@ -0,0 +1,20 @@
1
+ import type { MemoryType } from "./memory.js";
2
+ export type MemoryHeader = {
3
+ filename: string;
4
+ filePath: string;
5
+ mtimeMs: number;
6
+ name: string | null;
7
+ description: string | null;
8
+ type: MemoryType | undefined;
9
+ };
10
+ /**
11
+ * Recursive scan of memory directory. Reads only frontmatter (first N lines),
12
+ * returns headers sorted by mtime desc, capped at MAX_MEMORY_FILES.
13
+ * Port of Claude Code's scanMemoryFiles().
14
+ */
15
+ export declare function scanMemoryFiles(memoryDir: string): MemoryHeader[];
16
+ export declare function formatMemoryManifest(memories: MemoryHeader[]): string;
17
+ export declare function getMemoryManifest(worktree: string): {
18
+ headers: MemoryHeader[];
19
+ manifest: string;
20
+ };