opencode-claude-memory 2.0.1 → 2.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.
Files changed (64) hide show
  1. package/README.md +68 -11
  2. package/dist/agents.d.ts +8 -10
  3. package/dist/agents.js +4 -43
  4. package/dist/extraction/ExtractionCoordinator.d.ts +10 -12
  5. package/dist/extraction/ExtractionCoordinator.js +66 -50
  6. package/dist/extraction/autodream.d.ts +2 -5
  7. package/dist/extraction/autodream.js +3 -7
  8. package/dist/host/types.d.ts +52 -0
  9. package/dist/host/types.js +6 -0
  10. package/dist/host/v1/agents.d.ts +11 -0
  11. package/dist/host/v1/agents.js +48 -0
  12. package/dist/host/v1/coordinators.d.ts +12 -0
  13. package/dist/host/v1/coordinators.js +43 -0
  14. package/dist/{extraction/forkSession.d.ts → host/v1/fork.d.ts} +3 -2
  15. package/dist/{extraction/forkSession.js → host/v1/fork.js} +2 -2
  16. package/dist/host/v1/host.d.ts +9 -0
  17. package/dist/host/v1/host.js +79 -0
  18. package/dist/{hooks → host/v1}/ignore.d.ts +2 -3
  19. package/dist/host/v1/ignore.js +29 -0
  20. package/dist/host/v1/log.d.ts +4 -0
  21. package/dist/host/v1/log.js +20 -0
  22. package/dist/{hooks → host/v1}/messages.d.ts +3 -2
  23. package/dist/{hooks → host/v1}/messages.js +3 -17
  24. package/dist/host/v1/plugin.d.ts +2 -0
  25. package/dist/host/v1/plugin.js +58 -0
  26. package/dist/host/v1/selector.d.ts +18 -0
  27. package/dist/host/v1/selector.js +12 -0
  28. package/dist/host/v1/tools.d.ts +5 -0
  29. package/dist/host/v1/tools.js +17 -0
  30. package/dist/host/v2/agents.d.ts +8 -0
  31. package/dist/host/v2/agents.js +41 -0
  32. package/dist/host/v2/events.d.ts +40 -0
  33. package/dist/host/v2/events.js +137 -0
  34. package/dist/host/v2/fork.d.ts +76 -0
  35. package/dist/host/v2/fork.js +136 -0
  36. package/dist/host/v2/host.d.ts +33 -0
  37. package/dist/host/v2/host.js +87 -0
  38. package/dist/host/v2/log.d.ts +4 -0
  39. package/dist/host/v2/log.js +40 -0
  40. package/dist/host/v2/plugin.d.ts +26 -0
  41. package/dist/host/v2/plugin.js +148 -0
  42. package/dist/host/v2/transcript.d.ts +3 -0
  43. package/dist/host/v2/transcript.js +50 -0
  44. package/dist/index.d.ts +7 -2
  45. package/dist/index.js +5 -55
  46. package/dist/recall/RecallCoordinator.d.ts +15 -10
  47. package/dist/recall/RecallCoordinator.js +26 -35
  48. package/dist/recall/selector.d.ts +14 -19
  49. package/dist/recall/selector.js +37 -47
  50. package/dist/recall/turn.d.ts +4 -0
  51. package/dist/recall/turn.js +49 -0
  52. package/dist/store/MemoryStore.js +50 -6
  53. package/dist/store/frontmatter.d.ts +1 -0
  54. package/dist/store/frontmatter.js +19 -0
  55. package/dist/tools.d.ts +16 -3
  56. package/dist/tools.js +59 -41
  57. package/dist/util/exclusiveFile.d.ts +1 -1
  58. package/dist/util/exclusiveFile.js +102 -10
  59. package/dist/util/log.d.ts +0 -2
  60. package/dist/util/log.js +2 -18
  61. package/package.json +16 -4
  62. package/dist/hooks/ignore.js +0 -53
  63. /package/dist/{sdk.d.ts → host/v1/sdk.d.ts} +0 -0
  64. /package/dist/{sdk.js → host/v1/sdk.js} +0 -0
@@ -0,0 +1,49 @@
1
+ // Host-independent text rules for a user turn: the "ignore memory" / "use memory again" phrases and
2
+ // the keys of memories already surfaced in a system prompt. The host adapters apply them to their
3
+ // own message shapes.
4
+ import { RECALLED_MEMORIES_HEADING } from "../prompt/systemPrompt.js";
5
+ // "Ignore memory" handling. Claude Code's semantics are session-scoped: once the user asks to
6
+ // ignore memory, it stays ignored until they explicitly ask for it back.
7
+ export function detectIgnoreMemory(query) {
8
+ if (!query)
9
+ return false;
10
+ const normalized = query.toLowerCase();
11
+ return (/(ignore|don't use|do not use|without|skip)\s+(the\s+|your\s+)?memory/.test(normalized) ||
12
+ /memory\s+(should be|must be)?\s*ignored/.test(normalized));
13
+ }
14
+ export function detectResumeMemory(query) {
15
+ if (!query)
16
+ return false;
17
+ const normalized = query.toLowerCase();
18
+ return (/(use|enable|resume|restore|bring back|turn on)\s+(the\s+|your\s+)?memory(\s+again)?/.test(normalized) ||
19
+ /memory\s+(back\s+)?on\b/.test(normalized) ||
20
+ /stop ignoring\s+(the\s+|your\s+)?memory/.test(normalized));
21
+ }
22
+ // Replays user queries in order and returns whether memory is ignored at the end. Used to rebuild
23
+ // session state the coordinator no longer holds (process restart, cache eviction), so an "ignore
24
+ // memory" said earlier in the session keeps applying without the user repeating it.
25
+ export function deriveIgnoredFromQueries(queries) {
26
+ let ignored = false;
27
+ for (const query of queries) {
28
+ if (detectIgnoreMemory(query))
29
+ ignored = true;
30
+ else if (ignored && detectResumeMemory(query))
31
+ ignored = false;
32
+ }
33
+ return ignored;
34
+ }
35
+ // Parses "### <name> (<type>)" headers from the ## Recalled Memories section
36
+ // of system prompts. After compaction old system messages disappear, so
37
+ // the returned set naturally shrinks — no manual reset needed.
38
+ export function extractSurfacedMemoryKeys(systemText) {
39
+ const keys = new Set();
40
+ const recalledSection = systemText.indexOf(RECALLED_MEMORIES_HEADING);
41
+ if (recalledSection === -1)
42
+ return keys;
43
+ const headerPattern = /^### (.+?) \((\w+)\)/gm;
44
+ const section = systemText.slice(recalledSection);
45
+ for (let match = headerPattern.exec(section); match !== null; match = headerPattern.exec(section)) {
46
+ keys.add(`${match[1]}|${match[2]}`);
47
+ }
48
+ return keys;
49
+ }
@@ -1,7 +1,7 @@
1
- import { cpSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { writeFileAtomicSync } from "../util/exclusiveFile.js";
4
- import { buildFrontmatter, editFrontmatter, FRONTMATTER_MAX_LINES, ORIGIN, parseFrontmatter, parseMemoryType, } from "./frontmatter.js";
4
+ import { buildFrontmatter, editFrontmatter, FRONTMATTER_MAX_LINES, frontmatterKeys, ORIGIN, parseFrontmatter, parseMemoryType, } from "./frontmatter.js";
5
5
  import { buildIndexPointer, indexHasPointer, readIndexFile, removeIndexLine, upsertIndexLine } from "./indexFile.js";
6
6
  import { ENTRYPOINT_NAME, findCanonicalGitRoot, findGitRoot, MAX_MEMORY_FILE_BYTES, resolveMemoryFilePath, sanitizePath, } from "./paths.js";
7
7
  import { formatMemoryManifest, nameFromFilename, readMemoryEntry, scanMemoryFiles, } from "./scan.js";
@@ -59,6 +59,10 @@ export class MemoryStore {
59
59
  if (typeof input.name !== "string" || !input.name.trim()) {
60
60
  throw new Error("Memory name is required");
61
61
  }
62
+ // Writes follow links, so a memory file that links to the index would overwrite MEMORY.md itself.
63
+ if (isLinkTo(filePath, this.entrypoint)) {
64
+ throw new Error(`Memory "${relativePath}" is a link to ${ENTRYPOINT_NAME}; saving it would overwrite the index`);
65
+ }
62
66
  const existing = readTextFile(filePath);
63
67
  const parsed = existing === null ? null : parseFrontmatter(existing);
64
68
  const pointer = buildIndexPointer(relativePath, input.name, input.description);
@@ -82,16 +86,17 @@ export class MemoryStore {
82
86
  this.writeIndex(upsertIndexLine(this.readIndex(), relativePath, pointer));
83
87
  return { filePath, fileName: relativePath, unchanged: false };
84
88
  }
85
- // Deleting a memory this plugin did not create (Claude Code's, dsh's, a hand-written one) first
86
- // keeps a copy under the plugin's state directory, as dsh-unified-memory does, so an auto-dream
87
- // prune can never silently destroy another tool's memory.
89
+ // Deleting a memory that is not purely this plugin's (Claude Code's, dsh's, a hand-written one, or
90
+ // one of ours that another tool has since edited) first keeps a copy under the plugin's state
91
+ // directory, as dsh-unified-memory does, so an auto-dream prune can never silently destroy
92
+ // another tool's work.
88
93
  delete(fileName) {
89
94
  const { relativePath, filePath } = resolveMemoryFilePath(this.memoryDir, fileName);
90
95
  const existing = readTextFile(filePath);
91
96
  if (existing === null)
92
97
  return { deleted: false };
93
98
  let trashedTo;
94
- if (parseFrontmatter(existing).frontmatter.origin !== ORIGIN) {
99
+ if (!isPurelyOwn(existing, filePath)) {
95
100
  const stamp = this.now().toISOString().replace(/[:.]/g, "-");
96
101
  trashedTo = join(this.stateDir, "trash", stamp, relativePath);
97
102
  mkdirSync(dirname(trashedTo), { recursive: true });
@@ -132,6 +137,45 @@ export class MemoryStore {
132
137
  return same && indexHasPointer(this.readIndex(), pointer);
133
138
  }
134
139
  }
140
+ // The frontmatter keys of a memory this plugin creates (buildFrontmatter), each on one line; its own
141
+ // later saves keep exactly this set.
142
+ const OWN_KEYS = ["name", "description", "metadata", "type", "origin", "modified"];
143
+ // How far a file's modification time may sit from this plugin's own `modified` stamp and still count
144
+ // as that write: the stamp is taken just before the write, and filesystems with coarse timestamps
145
+ // (FAT, HFS+) round the modification time down by up to 2 s.
146
+ const OWN_WRITE_SLACK_MS = 2_000;
147
+ // A memory is purely this plugin's when its frontmatter is exactly what this plugin writes, with
148
+ // `origin: opencode`, and the file has not changed since the plugin last wrote it. Other writers are
149
+ // recognised by what they leave: Claude Code's Write and Edit tools keep `origin` but add `node_type`
150
+ // and `originSessionId` and restamp `modified`, other tools add `updatedBy` or keys of their own
151
+ // (or a second `modified`), and an edit that stamps nothing moves the modification time away from
152
+ // our `modified`. Any doubt counts as "not ours": an unneeded copy in the trash is harmless, a
153
+ // missing one loses another tool's work.
154
+ function isPurelyOwn(content, filePath) {
155
+ const keys = frontmatterKeys(content);
156
+ if (keys?.length !== OWN_KEYS.length || !OWN_KEYS.every((key) => keys.includes(key)))
157
+ return false;
158
+ const { frontmatter } = parseFrontmatter(content);
159
+ if (frontmatter.origin !== ORIGIN)
160
+ return false;
161
+ const stamped = Date.parse(frontmatter.modified ?? "");
162
+ if (Number.isNaN(stamped))
163
+ return false;
164
+ try {
165
+ return Math.abs(statSync(filePath).mtimeMs - stamped) <= OWN_WRITE_SLACK_MS;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ function isLinkTo(path, other) {
172
+ try {
173
+ return lstatSync(path).isSymbolicLink() && realpathSync(path) === realpathSync(other);
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
135
179
  function readTextFile(path) {
136
180
  try {
137
181
  return readFileSync(path, "utf-8");
@@ -14,6 +14,7 @@ export type ParsedMemoryFile = {
14
14
  };
15
15
  export declare function quoteScalar(value: string): string;
16
16
  export declare function parseFrontmatter(raw: string): ParsedMemoryFile;
17
+ export declare function frontmatterKeys(raw: string): string[] | undefined;
17
18
  export declare function parseFrontmatterHeader(raw: string): {
18
19
  frontmatter: Frontmatter;
19
20
  hasFrontmatter: boolean;
@@ -72,6 +72,25 @@ export function parseFrontmatter(raw) {
72
72
  hasFrontmatter: true,
73
73
  };
74
74
  }
75
+ // The key of every non-blank frontmatter line, in order and with repeats, which the flat parser
76
+ // above merges away; a line that is not `key: value` (a list item, a comment) yields "". `undefined`
77
+ // when the file has no frontmatter.
78
+ export function frontmatterKeys(raw) {
79
+ const trimmed = raw.trim();
80
+ if (!trimmed.startsWith("---"))
81
+ return undefined;
82
+ const lines = trimmed.split("\n");
83
+ const closing = findClosingLine(lines);
84
+ if (closing === -1)
85
+ return undefined;
86
+ return lines
87
+ .slice(1, closing)
88
+ .filter((line) => line.trim() !== "")
89
+ .map((line) => {
90
+ const colonIdx = line.indexOf(":");
91
+ return colonIdx === -1 ? "" : line.slice(0, colonIdx).trim();
92
+ });
93
+ }
75
94
  // Header-only variant for the directory scanner: parses the first FRONTMATTER_MAX_LINES lines and
76
95
  // never materialises the body. Leading whitespace is dropped before counting lines, exactly as the
77
96
  // full parser does, so both entry points agree on every file (leading blank lines included).
package/dist/tools.d.ts CHANGED
@@ -1,9 +1,22 @@
1
- import { type Hooks } from "@opencode-ai/plugin";
1
+ import { z } from "zod/v4";
2
+ import type { MemoryToolName } from "./agents.js";
2
3
  import type { ExtractionCoordinator } from "./extraction/ExtractionCoordinator.js";
3
4
  import type { MemoryStore, SaveMemoryResult } from "./store/MemoryStore.js";
4
5
  export declare function formatMemorySaveResult(outcome: SaveMemoryResult, savedThisRun?: readonly string[]): string;
5
6
  export declare function memorySaveTitle(type: string, name: string): string | undefined;
6
7
  export declare function memoryListTitle(count: number): string;
7
8
  export declare function memorySearchTitle(query: string, count: number): string;
8
- export type MemoryTools = NonNullable<Hooks["tool"]>;
9
- export declare function buildMemoryTools(store: MemoryStore, extraction: Pick<ExtractionCoordinator, "recordSave">): MemoryTools;
9
+ export type MemoryToolResult = {
10
+ title?: string;
11
+ output: string;
12
+ };
13
+ export type MemoryToolContext = {
14
+ sessionID?: string;
15
+ };
16
+ export type MemoryToolSpec = {
17
+ name: MemoryToolName;
18
+ description: string;
19
+ args: z.ZodRawShape;
20
+ execute(args: Record<string, unknown>, ctx: MemoryToolContext): Promise<MemoryToolResult>;
21
+ };
22
+ export declare function buildMemoryToolSpecs(store: MemoryStore, extraction: Pick<ExtractionCoordinator, "recordSave">): MemoryToolSpec[];
package/dist/tools.js CHANGED
@@ -1,4 +1,9 @@
1
- import { tool } from "@opencode-ai/plugin";
1
+ // The five memory tools, defined once. Each host adapter wraps the specs into its own tool format
2
+ // (V1: `Hooks["tool"]`, V2: `ToolEditor.add`); results carry their own titles.
3
+ //
4
+ // Argument schemas use `zod/v4`: both hosts accept them (V1 duck-types Zod v4 schemas, V2 takes any
5
+ // Standard Schema), so the plugin needs no runtime import from either SDK.
6
+ import { z } from "zod/v4";
2
7
  import { MEMORY_TYPES } from "./store/frontmatter.js";
3
8
  // Tool result for memory_save. Inside an extraction fork, `savedThisRun` (file names already saved
4
9
  // by this fork, in order) is appended as an explicit done-signal so the model does not lose track on
@@ -42,9 +47,14 @@ export function memorySearchTitle(query, count) {
42
47
  return `"${query}" · ${plural(count, "match", "matches")}`;
43
48
  }
44
49
  const FILE_NAME_HINT = 'with or without the .md extension; sub-directories are allowed, e.g. "team/conventions"';
45
- export function buildMemoryTools(store, extraction) {
46
- return {
47
- memory_save: tool({
50
+ function str(args, key) {
51
+ const value = args[key];
52
+ return typeof value === "string" ? value : "";
53
+ }
54
+ export function buildMemoryToolSpecs(store, extraction) {
55
+ return [
56
+ {
57
+ name: "memory_save",
48
58
  description: "Save or update a memory for future conversations. " +
49
59
  "Each memory is stored as a markdown file with frontmatter. " +
50
60
  "Use this when the user explicitly asks you to remember something, " +
@@ -52,52 +62,56 @@ export function buildMemoryTools(store, extraction) {
52
62
  "(user preferences, feedback, project context, external references). " +
53
63
  "Check existing memories first with memory_list or memory_search to avoid duplicates.",
54
64
  args: {
55
- file_name: tool.schema
65
+ file_name: z
56
66
  .string()
57
67
  .describe('File name for the memory (without .md extension). Use snake_case, e.g. "user_role", "feedback_testing_style", "project_auth_rewrite"; a sub-directory prefix such as "team/conventions" is allowed'),
58
- name: tool.schema.string().describe("Human-readable name for this memory"),
59
- description: tool.schema
68
+ name: z.string().describe("Human-readable name for this memory"),
69
+ description: z
60
70
  .string()
61
71
  .describe("One-line description — used to decide relevance in future conversations, so be specific"),
62
- type: tool.schema
72
+ type: z
63
73
  .enum(MEMORY_TYPES)
64
74
  .describe("Memory type: user (about the person), feedback (guidance on approach), project (ongoing work context), reference (pointers to external systems)"),
65
- content: tool.schema
75
+ content: z
66
76
  .string()
67
77
  .describe("Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines"),
68
78
  },
69
79
  async execute(args, ctx) {
80
+ const type = str(args, "type");
70
81
  const outcome = store.save({
71
- fileName: args.file_name,
72
- name: args.name,
73
- description: args.description,
74
- type: args.type,
75
- content: args.content,
82
+ fileName: str(args, "file_name"),
83
+ name: str(args, "name"),
84
+ description: str(args, "description"),
85
+ type,
86
+ content: str(args, "content"),
76
87
  });
77
- const savedThisRun = extraction.recordSave(ctx?.sessionID, outcome.fileName);
88
+ const savedThisRun = extraction.recordSave(ctx.sessionID, outcome.fileName);
78
89
  return {
79
- title: memorySaveTitle(args.type, args.name),
90
+ title: memorySaveTitle(type, str(args, "name")),
80
91
  output: formatMemorySaveResult(outcome, savedThisRun),
81
92
  };
82
93
  },
83
- }),
84
- memory_delete: tool({
94
+ },
95
+ {
96
+ name: "memory_delete",
85
97
  description: "Delete a memory that is outdated, wrong, or no longer relevant. Also removes it from the index.",
86
98
  args: {
87
- file_name: tool.schema.string().describe(`File name of the memory to delete (${FILE_NAME_HINT})`),
99
+ file_name: z.string().describe(`File name of the memory to delete (${FILE_NAME_HINT})`),
88
100
  },
89
101
  async execute(args) {
90
- const { deleted, trashedTo } = store.delete(args.file_name);
91
- let output = `Memory "${args.file_name}" not found.`;
102
+ const fileName = str(args, "file_name");
103
+ const { deleted, trashedTo } = store.delete(fileName);
104
+ let output = `Memory "${fileName}" not found.`;
92
105
  if (deleted) {
93
106
  output = trashedTo
94
- ? `Memory "${args.file_name}" deleted (a copy was kept at ${trashedTo}).`
95
- : `Memory "${args.file_name}" deleted.`;
107
+ ? `Memory "${fileName}" deleted (a copy was kept at ${trashedTo}).`
108
+ : `Memory "${fileName}" deleted.`;
96
109
  }
97
- return { title: args.file_name, output };
110
+ return { title: fileName, output };
98
111
  },
99
- }),
100
- memory_list: tool({
112
+ },
113
+ {
114
+ name: "memory_list",
101
115
  description: "List all saved memories with their names, types, and descriptions. " +
102
116
  "Use this to check what memories exist before saving a new one (to avoid duplicates) " +
103
117
  "or when you need to recall what's been stored.",
@@ -110,36 +124,40 @@ export function buildMemoryTools(store, extraction) {
110
124
  const lines = entries.map((e) => `- **${e.name}** (${e.type}) [${e.filename}]: ${e.description}`);
111
125
  return { title, output: `${entries.length} memories found:\n${lines.join("\n")}` };
112
126
  },
113
- }),
114
- memory_search: tool({
127
+ },
128
+ {
129
+ name: "memory_search",
115
130
  description: "Search memories by keyword. Searches across names, descriptions, and content. " +
116
131
  "Use this to find relevant memories before answering questions or when the user references past conversations.",
117
132
  args: {
118
- query: tool.schema.string().describe("Search query — searches across name, description, and content"),
133
+ query: z.string().describe("Search query — searches across name, description, and content"),
119
134
  },
120
135
  async execute(args) {
121
- const results = store.search(args.query);
122
- const title = memorySearchTitle(args.query, results.length);
136
+ const query = str(args, "query");
137
+ const results = store.search(query);
138
+ const title = memorySearchTitle(query, results.length);
123
139
  if (results.length === 0)
124
- return { title, output: `No memories matching "${args.query}".` };
140
+ return { title, output: `No memories matching "${query}".` };
125
141
  const lines = results.map((e) => `- **${e.name}** (${e.type}) [${e.filename}]: ${e.description}\n Content: ${e.body.slice(0, 200)}${e.body.length > 200 ? "..." : ""}`);
126
- return { title, output: `${results.length} matches for "${args.query}":\n${lines.join("\n")}` };
142
+ return { title, output: `${results.length} matches for "${query}":\n${lines.join("\n")}` };
127
143
  },
128
- }),
129
- memory_read: tool({
144
+ },
145
+ {
146
+ name: "memory_read",
130
147
  description: "Read the full content of a specific memory file.",
131
148
  args: {
132
- file_name: tool.schema.string().describe(`File name of the memory to read (${FILE_NAME_HINT})`),
149
+ file_name: z.string().describe(`File name of the memory to read (${FILE_NAME_HINT})`),
133
150
  },
134
151
  async execute(args) {
135
- const entry = store.read(args.file_name);
152
+ const fileName = str(args, "file_name");
153
+ const entry = store.read(fileName);
136
154
  if (!entry)
137
- return { title: args.file_name, output: `Memory "${args.file_name}" not found.` };
155
+ return { title: fileName, output: `Memory "${fileName}" not found.` };
138
156
  return {
139
- title: args.file_name,
157
+ title: fileName,
140
158
  output: `# ${entry.name}\n**Type:** ${entry.type}\n**Description:** ${entry.description}\n\n${entry.body}`,
141
159
  };
142
160
  },
143
- }),
144
- };
161
+ },
162
+ ];
145
163
  }
@@ -1,5 +1,5 @@
1
1
  export declare function createExclusiveSync(path: string, content: string): boolean;
2
- export declare function writeFileAtomicSync(path: string, content: string): void;
2
+ export declare function writeFileAtomicSync(path: string, content: string, rename?: (from: string, to: string) => void): void;
3
3
  export declare function fileAgeMs(path: string, now?: number): number | undefined;
4
4
  export declare function sleepSync(ms: number): void;
5
5
  export declare function unlinkWithRetry(path: string, attempts?: number): boolean;
@@ -6,7 +6,7 @@
6
6
  // Writing the content to a private temp file and hard-linking it into place is atomic: `link(2)`
7
7
  // either creates the full file or fails with EEXIST. Filesystems without hard links fall back to `wx`.
8
8
  import { randomBytes } from "node:crypto";
9
- import { linkSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { chmodSync, linkSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
10
10
  import { dirname } from "node:path";
11
11
  function errorCode(error) {
12
12
  return error?.code;
@@ -43,23 +43,115 @@ export function createExclusiveSync(path, content) {
43
43
  }
44
44
  }
45
45
  // Replaces a file's content atomically (temp file + rename), so a reader such as Claude Code or
46
- // another plugin sharing the memory folder never sees a half-written file.
47
- export function writeFileAtomicSync(path, content) {
46
+ // another plugin sharing the memory folder never sees a half-written file. The file's permission
47
+ // bits are carried over, and a symbolic link (a dotfiles-managed MEMORY.md) stays a link: the regular
48
+ // file at the end of its chain is replaced instead. Where a rename cannot do the job it writes in
49
+ // place, which is not atomic but is what `writeFileSync` did before: through a link whose target is
50
+ // missing, is not a regular file or sits in a directory that takes no new files, and, on Windows,
51
+ // over a file another process keeps open. A replaced file is a new inode, so unlike `writeFileSync`
52
+ // it does not keep hard links, owner, ACLs or extended attributes, and write protection on the file
53
+ // itself (rather than on its directory) does not stop it.
54
+ //
55
+ // Callers own containment: the memory store checks memory-file names with `resolveMemoryFilePath`
56
+ // (links must stay inside the memory directory), while MEMORY.md is followed wherever it points.
57
+ export function writeFileAtomicSync(path, content, rename = renameSync) {
58
+ const link = isSymbolicLink(path);
59
+ const target = link ? replaceableLinkTarget(path) : path;
60
+ if (target === undefined) {
61
+ writeFileSync(path, content, "utf-8");
62
+ return;
63
+ }
48
64
  mkdirSync(dirname(path), { recursive: true });
49
- const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
65
+ const mode = existingMode(target);
66
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
67
+ try {
68
+ // Created with the target's mode (the umask can only narrow it), so the new content is never
69
+ // more readable than the file it replaces, not even for a moment.
70
+ writeFileSync(tmp, content, { encoding: "utf-8", mode });
71
+ }
72
+ catch (error) {
73
+ unlinkWithRetry(tmp);
74
+ if (!link || !TEMP_REFUSED_CODES.includes(errorCode(error) ?? ""))
75
+ throw error;
76
+ writeFileSync(path, content, "utf-8");
77
+ return;
78
+ }
79
+ let renamed = false;
80
+ try {
81
+ if (mode !== undefined)
82
+ restoreMode(tmp, mode);
83
+ renamed = renameWithRetry(tmp, target, rename);
84
+ if (!renamed)
85
+ writeFileSync(target, content, "utf-8");
86
+ }
87
+ finally {
88
+ if (!renamed)
89
+ unlinkWithRetry(tmp);
90
+ }
91
+ }
92
+ // Codes meaning no temp file can be created next to a link's target (a read-only dotfiles directory,
93
+ // /dev): the write then goes through the link instead.
94
+ const TEMP_REFUSED_CODES = ["EACCES", "EPERM", "EROFS"];
95
+ function isSymbolicLink(path) {
50
96
  try {
51
- writeFileSync(tmp, content, "utf-8");
52
- renameSync(tmp, path);
97
+ return lstatSync(path).isSymbolicLink();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ // The regular file at the end of a link's chain, which the rename replaces so the link survives.
104
+ // `undefined` when there is none (a dangling link, a link to a device or a directory, or one Bun
105
+ // cannot resolve, e.g. to an unreadable file): the write then goes through the link, which also
106
+ // creates a missing target as `writeFileSync` did. A looping link is replaced like a plain file.
107
+ function replaceableLinkTarget(path) {
108
+ try {
109
+ const target = realpathSync(path);
110
+ return statSync(target).isFile() ? target : undefined;
53
111
  }
54
112
  catch (error) {
113
+ return errorCode(error) === "ELOOP" ? path : undefined;
114
+ }
115
+ }
116
+ function existingMode(path) {
117
+ try {
118
+ return statSync(path).mode & 0o7777;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ // Puts back bits the umask stripped when the temp file was created. Best effort: some mounts (FAT,
125
+ // exFAT or CIFS owned by another user, some FUSE filesystems) refuse chmod, and the file then keeps
126
+ // the mode they give it, as it did before modes were carried over.
127
+ function restoreMode(path, mode) {
128
+ try {
129
+ chmodSync(path, mode);
130
+ }
131
+ catch {
132
+ // the mount decides the mode
133
+ }
134
+ }
135
+ // Windows refuses to replace a file that another process holds open without FILE_SHARE_DELETE, as
136
+ // EPERM, EBUSY or EACCES, usually for a few milliseconds. Elsewhere these codes are permanent
137
+ // (immutable files, sticky directories, ACLs), so they are thrown at once.
138
+ const RENAME_BUSY_CODES = ["EPERM", "EBUSY", "EACCES"];
139
+ const RENAME_ATTEMPTS = 5;
140
+ // False when Windows still refuses after RENAME_ATTEMPTS tries (2, 4, 6 and 8 ms apart).
141
+ function renameWithRetry(tmp, target, rename) {
142
+ for (let attempt = 1; attempt <= RENAME_ATTEMPTS; attempt++) {
55
143
  try {
56
- unlinkSync(tmp);
144
+ rename(tmp, target);
145
+ return true;
57
146
  }
58
- catch {
59
- // never created
147
+ catch (error) {
148
+ if (process.platform !== "win32" || !RENAME_BUSY_CODES.includes(errorCode(error) ?? ""))
149
+ throw error;
150
+ if (attempt < RENAME_ATTEMPTS)
151
+ sleepSync(2 * attempt);
60
152
  }
61
- throw error;
62
153
  }
154
+ return false;
63
155
  }
64
156
  export function fileAgeMs(path, now = Date.now()) {
65
157
  try {
@@ -1,6 +1,4 @@
1
- import type { OpencodeClient } from "../sdk.js";
2
1
  export declare const LOG_SERVICE = "opencode-claude-memory";
3
2
  export type LogLevel = "debug" | "info" | "warn" | "error";
4
3
  export type Logger = (level: LogLevel, message: string, extra?: Record<string, unknown>) => void;
5
4
  export declare function getErrorMessage(error: unknown): string;
6
- export declare function createLogger(client: OpencodeClient | undefined, directory: string): Logger;
package/dist/util/log.js CHANGED
@@ -1,3 +1,5 @@
1
+ // Host-independent logging types. Each host adapter supplies its own sink (V1: the OpenCode service
2
+ // log, V2: a file in the plugin state directory); stderr is never used because it ends up in the UI.
1
3
  export const LOG_SERVICE = "opencode-claude-memory";
2
4
  export function getErrorMessage(error) {
3
5
  if (error instanceof Error)
@@ -9,21 +11,3 @@ export function getErrorMessage(error) {
9
11
  }
10
12
  return String(error);
11
13
  }
12
- // Logging goes through the OpenCode service log only. stderr is rendered into the chat UI, so a
13
- // failing background task must never write there. Every call is best-effort and never throws.
14
- export function createLogger(client, directory) {
15
- return (level, message, extra) => {
16
- const log = client?.app?.log;
17
- if (typeof log !== "function")
18
- return;
19
- try {
20
- void Promise.resolve(log.call(client?.app, {
21
- body: { service: LOG_SERVICE, level, message, extra },
22
- query: { directory },
23
- })).catch(() => { });
24
- }
25
- catch {
26
- // best-effort
27
- }
28
- };
29
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Claude Code memory: persistent, local-first shared memory with Claude Code-compatible Markdown files, LLM recall, in-process auto extraction, and auto-dream consolidation",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,7 @@
16
16
  "dist"
17
17
  ],
18
18
  "engines": {
19
- "opencode": ">=1.18.0"
19
+ "opencode": ">=1.18.29"
20
20
  },
21
21
  "scripts": {
22
22
  "build": "tsc -p tsconfig.build.json",
@@ -58,14 +58,26 @@
58
58
  ],
59
59
  "license": "MIT",
60
60
  "peerDependencies": {
61
- "@opencode-ai/plugin": ">=1.18.0"
61
+ "@opencode-ai/plugin": ">=1.18.29",
62
+ "@opencode/plugin": ">=2.0.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@opencode-ai/plugin": {
66
+ "optional": true
67
+ },
68
+ "@opencode/plugin": {
69
+ "optional": true
70
+ }
62
71
  },
63
72
  "dependencies": {
64
- "zod": "^3.24.0"
73
+ "zod": "^3.25.0"
65
74
  },
66
75
  "devDependencies": {
67
76
  "@biomejs/biome": "^2",
68
77
  "@opencode-ai/plugin": "^1.18.16",
78
+ "@opencode/client": "2.0.22",
79
+ "@opencode/plugin": "2.0.22",
80
+ "@opencode/schema": "2.0.22",
69
81
  "bun-types": "^1.3.11",
70
82
  "conventional-changelog-conventionalcommits": "^9",
71
83
  "typescript": "^6.0.2"
@@ -1,53 +0,0 @@
1
- // "Ignore memory" handling. Claude Code's semantics are session-scoped: once the user asks to
2
- // ignore memory, it stays ignored until they explicitly ask for it back.
3
- import { AUTO_MEMORY_MARKER } from "../prompt/systemPrompt.js";
4
- import { extractUserQuery, roleOf } from "./messages.js";
5
- export function detectIgnoreMemory(query) {
6
- if (!query)
7
- return false;
8
- const normalized = query.toLowerCase();
9
- return (/(ignore|don't use|do not use|without|skip)\s+(the\s+|your\s+)?memory/.test(normalized) ||
10
- /memory\s+(should be|must be)?\s*ignored/.test(normalized));
11
- }
12
- export function detectResumeMemory(query) {
13
- if (!query)
14
- return false;
15
- const normalized = query.toLowerCase();
16
- return (/(use|enable|resume|restore|bring back|turn on)\s+(the\s+|your\s+)?memory(\s+again)?/.test(normalized) ||
17
- /memory\s+(back\s+)?on\b/.test(normalized) ||
18
- /stop ignoring\s+(the\s+|your\s+)?memory/.test(normalized));
19
- }
20
- // Replays every user message in order and returns whether memory is ignored at the end. Used to
21
- // rebuild session state that the coordinator no longer holds (process restart, cache eviction), so
22
- // an "ignore memory" said earlier in the session keeps applying without the user repeating it.
23
- export function deriveIgnoredFromHistory(messages) {
24
- let ignored = false;
25
- for (const message of messages) {
26
- if (roleOf(message) !== "user")
27
- continue;
28
- const query = extractUserQuery(message);
29
- if (detectIgnoreMemory(query))
30
- ignored = true;
31
- else if (ignored && detectResumeMemory(query))
32
- ignored = false;
33
- }
34
- return ignored;
35
- }
36
- export function isAutoMemoryPart(part) {
37
- if (!part || typeof part !== "object")
38
- return false;
39
- const text = part.text;
40
- return typeof text === "string" && text.trimStart().startsWith(AUTO_MEMORY_MARKER);
41
- }
42
- // Drops the plugin's own system segment from system-role messages; messages left without parts
43
- // are removed entirely.
44
- export function stripAutoMemoryParts(messages) {
45
- return messages
46
- .map((message) => {
47
- if (roleOf(message) !== "system" || !Array.isArray(message.parts))
48
- return message;
49
- const parts = message.parts.filter((part) => !isAutoMemoryPart(part));
50
- return parts.length === message.parts.length ? message : { ...message, parts };
51
- })
52
- .filter((message) => !Array.isArray(message.parts) || message.parts.length > 0);
53
- }
File without changes
File without changes