opencode-claude-memory 2.0.1 → 2.0.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
@@ -151,7 +151,7 @@ Everything the environment variables used to control now lives under `extract`,
151
151
 
152
152
  **Do I need to migrate existing memory?** No. Existing Claude Code memory files are used as they are.
153
153
 
154
- **Where is data stored?** `~/.claude/projects/<project>/memory/` (or `$CLAUDE_CONFIG_DIR/projects/...`). Plugin state lives in `$CLAUDE_CONFIG_DIR/opencode-memory/<project>/`. Memories the plugin deletes but did not create (Claude Code's, or another tool's) are copied to `trash/<timestamp>/` there first.
154
+ **Where is data stored?** `~/.claude/projects/<project>/memory/` (or `$CLAUDE_CONFIG_DIR/projects/...`). Plugin state lives in `$CLAUDE_CONFIG_DIR/opencode-memory/<project>/`. Memories the plugin deletes that are not purely its own (Claude Code's, another tool's, or one of its own that another tool has since edited) are copied to `trash/<timestamp>/` there first.
155
155
 
156
156
  **Can I disable extraction, auto-dream or recall?** Yes — `extract.enabled`, `autodream.enabled`, `recall.enabled` in the plugin options.
157
157
 
@@ -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).
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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",