opencode-claude-memory 2.0.0 → 2.0.1

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>/`.
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.
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
 
@@ -12,11 +12,16 @@ export type SaveMemoryResult = {
12
12
  fileName: string;
13
13
  unchanged: boolean;
14
14
  };
15
+ export type DeleteMemoryResult = {
16
+ deleted: boolean;
17
+ trashedTo?: string;
18
+ };
15
19
  export type ListOptions = {
16
20
  sort?: "name" | "mtime";
17
21
  };
18
22
  export type MemoryStoreOptions = {
19
23
  claudeConfigDir: string;
24
+ now?: () => Date;
20
25
  };
21
26
  export declare class MemoryStore {
22
27
  readonly memoryRoot: string;
@@ -27,13 +32,14 @@ export declare class MemoryStore {
27
32
  readonly memoryDir: string;
28
33
  readonly entrypoint: string;
29
34
  readonly stateDir: string;
35
+ private readonly now;
30
36
  constructor(memoryRoot: string, options: MemoryStoreOptions);
31
37
  scan(): MemoryHeader[];
32
38
  manifest(): string;
33
39
  list(options?: ListOptions): MemoryEntry[];
34
40
  read(fileName: string): MemoryEntry | null;
35
41
  save(input: SaveMemoryInput): SaveMemoryResult;
36
- delete(fileName: string): boolean;
42
+ delete(fileName: string): DeleteMemoryResult;
37
43
  search(query: string): MemoryEntry[];
38
44
  readIndex(): string;
39
45
  private writeIndex;
@@ -1,9 +1,10 @@
1
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { cpSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { buildFrontmatter } from "./frontmatter.js";
3
+ import { writeFileAtomicSync } from "../util/exclusiveFile.js";
4
+ import { buildFrontmatter, editFrontmatter, FRONTMATTER_MAX_LINES, ORIGIN, parseFrontmatter, parseMemoryType, } from "./frontmatter.js";
4
5
  import { buildIndexPointer, indexHasPointer, readIndexFile, removeIndexLine, upsertIndexLine } from "./indexFile.js";
5
6
  import { ENTRYPOINT_NAME, findCanonicalGitRoot, findGitRoot, MAX_MEMORY_FILE_BYTES, resolveMemoryFilePath, sanitizePath, } from "./paths.js";
6
- import { formatMemoryManifest, readMemoryEntry, scanMemoryFiles } from "./scan.js";
7
+ import { formatMemoryManifest, nameFromFilename, readMemoryEntry, scanMemoryFiles, } from "./scan.js";
7
8
  // Owns the resolved memory paths for one project. Path resolution (git root, worktree canonical
8
9
  // root, sanitised project key) happens exactly once, in the constructor.
9
10
  export class MemoryStore {
@@ -17,11 +18,13 @@ export class MemoryStore {
17
18
  // Plugin-private state (extraction watermarks, auto-dream gate) lives next to, not inside, the
18
19
  // Claude Code project directory so Claude Code never sees it.
19
20
  stateDir;
21
+ now;
20
22
  constructor(memoryRoot, options) {
21
23
  this.memoryRoot = memoryRoot;
22
24
  this.gitRoot = findGitRoot(memoryRoot);
23
25
  this.canonicalRoot = findCanonicalGitRoot(memoryRoot) ?? memoryRoot;
24
26
  this.claudeConfigDir = options.claudeConfigDir;
27
+ this.now = options.now ?? (() => new Date());
25
28
  const projectKey = sanitizePath(this.canonicalRoot);
26
29
  this.projectDir = join(this.claudeConfigDir, "projects", projectKey);
27
30
  this.memoryDir = join(this.projectDir, "memory");
@@ -56,29 +59,52 @@ export class MemoryStore {
56
59
  if (typeof input.name !== "string" || !input.name.trim()) {
57
60
  throw new Error("Memory name is required");
58
61
  }
59
- const fileContent = `${buildFrontmatter(input)}\n\n${input.content.trim()}\n`;
62
+ const existing = readTextFile(filePath);
63
+ const parsed = existing === null ? null : parseFrontmatter(existing);
64
+ const pointer = buildIndexPointer(relativePath, input.name, input.description);
65
+ if (parsed !== null && this.isUnchanged(parsed, relativePath, input, pointer)) {
66
+ return { filePath, fileName: relativePath, unchanged: true };
67
+ }
68
+ const modified = this.now().toISOString();
69
+ const fileContent = existing === null || parsed === null
70
+ ? `${buildFrontmatter({ ...input, modified })}\n\n${input.content.trim()}\n`
71
+ : updatedFileContent(existing, parsed, input, modified);
60
72
  if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
61
73
  throw new Error(`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`);
62
74
  }
63
- const pointer = buildIndexPointer(relativePath, input.name, input.description);
64
- if (this.isUnchanged(filePath, fileContent, pointer)) {
65
- return { filePath, fileName: relativePath, unchanged: true };
75
+ // Line-level edits keep every other frontmatter line, so the block can outgrow the window both
76
+ // this plugin and Claude Code read it in; refuse rather than write a file they would both
77
+ // treat as having no frontmatter.
78
+ if (!parseFrontmatter(fileContent).hasFrontmatter) {
79
+ throw new Error(`Memory "${relativePath}" frontmatter would exceed ${FRONTMATTER_MAX_LINES} lines; trim its frontmatter first`);
66
80
  }
67
- mkdirSync(dirname(filePath), { recursive: true });
68
- writeFileSync(filePath, fileContent, "utf-8");
81
+ writeFileAtomicSync(filePath, fileContent);
69
82
  this.writeIndex(upsertIndexLine(this.readIndex(), relativePath, pointer));
70
83
  return { filePath, fileName: relativePath, unchanged: false };
71
84
  }
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.
72
88
  delete(fileName) {
73
89
  const { relativePath, filePath } = resolveMemoryFilePath(this.memoryDir, fileName);
90
+ const existing = readTextFile(filePath);
91
+ if (existing === null)
92
+ return { deleted: false };
93
+ let trashedTo;
94
+ if (parseFrontmatter(existing).frontmatter.origin !== ORIGIN) {
95
+ const stamp = this.now().toISOString().replace(/[:.]/g, "-");
96
+ trashedTo = join(this.stateDir, "trash", stamp, relativePath);
97
+ mkdirSync(dirname(trashedTo), { recursive: true });
98
+ cpSync(filePath, trashedTo, { preserveTimestamps: true });
99
+ }
74
100
  try {
75
101
  unlinkSync(filePath);
76
102
  }
77
103
  catch {
78
- return false;
104
+ return { deleted: false };
79
105
  }
80
106
  this.writeIndex(removeIndexLine(this.readIndex(), relativePath));
81
- return true;
107
+ return trashedTo ? { deleted: true, trashedTo } : { deleted: true };
82
108
  }
83
109
  search(query) {
84
110
  const lowerQuery = query.toLowerCase();
@@ -90,16 +116,39 @@ export class MemoryStore {
90
116
  return readIndexFile(this.entrypoint);
91
117
  }
92
118
  writeIndex(content) {
93
- writeFileSync(this.entrypoint, content, "utf-8");
119
+ writeFileAtomicSync(this.entrypoint, content);
94
120
  }
95
- isUnchanged(filePath, fileContent, pointer) {
96
- try {
97
- if (readFileSync(filePath, "utf-8") !== fileContent)
98
- return false;
99
- }
100
- catch {
121
+ // Unchanged means the same name, description, type and body, whatever else the frontmatter
122
+ // holds, so an identical re-save neither bumps `modified` nor stamps provenance.
123
+ isUnchanged(parsed, relativePath, input, pointer) {
124
+ const { frontmatter, body, hasFrontmatter } = parsed;
125
+ if (!hasFrontmatter)
101
126
  return false;
102
- }
103
- return indexHasPointer(this.readIndex(), pointer);
127
+ // Missing fields compare as the defaults the scanner (and `read()`) reports for them.
128
+ const same = (frontmatter.name ?? nameFromFilename(relativePath)) === input.name &&
129
+ (frontmatter.description ?? "") === input.description &&
130
+ (parseMemoryType(frontmatter.type) ?? "user") === input.type &&
131
+ body.replace(/\r\n/g, "\n") === input.content.trim().replace(/\r\n/g, "\n");
132
+ return same && indexHasPointer(this.readIndex(), pointer);
104
133
  }
105
134
  }
135
+ function readTextFile(path) {
136
+ try {
137
+ return readFileSync(path, "utf-8");
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ // Updates an existing memory in place: name, description, type and body change, every other
144
+ // frontmatter line is kept. The type and `modified` are written wherever the file already keeps
145
+ // them (top level in older files, under `metadata:` otherwise, both when it has both), and a file
146
+ // this plugin did not create gains `metadata.updatedBy: opencode` while its own `origin` stays.
147
+ function updatedFileContent(existing, parsed, input, modified) {
148
+ return editFrontmatter(existing, {
149
+ set: { name: input.name, description: input.description },
150
+ setWhereExists: { type: input.type, modified },
151
+ setMeta: parsed.frontmatter.origin === ORIGIN ? {} : { updatedBy: ORIGIN },
152
+ body: input.content,
153
+ });
154
+ }
@@ -12,14 +12,25 @@ export type ParsedMemoryFile = {
12
12
  body: string;
13
13
  hasFrontmatter: boolean;
14
14
  };
15
+ export declare function quoteScalar(value: string): string;
15
16
  export declare function parseFrontmatter(raw: string): ParsedMemoryFile;
16
17
  export declare function parseFrontmatterHeader(raw: string): {
17
18
  frontmatter: Frontmatter;
18
19
  hasFrontmatter: boolean;
19
20
  };
20
- export declare function buildFrontmatter(input: {
21
+ export declare const ORIGIN = "opencode";
22
+ export type NewMemoryFields = {
21
23
  name: string;
22
24
  description: string;
23
25
  type: MemoryType;
24
- }): string;
26
+ modified: string;
27
+ };
28
+ export declare function buildFrontmatter(input: NewMemoryFields): string;
29
+ export type FrontmatterEdits = {
30
+ set?: Record<string, string>;
31
+ setWhereExists?: Record<string, string>;
32
+ setMeta?: Record<string, string>;
33
+ body?: string;
34
+ };
35
+ export declare function editFrontmatter(content: string, edits: FrontmatterEdits): string;
25
36
  export declare function parseMemoryType(raw: string | undefined): MemoryType | undefined;
@@ -10,12 +10,41 @@ function parseFields(lines) {
10
10
  if (colonIdx === -1)
11
11
  continue;
12
12
  const key = line.slice(0, colonIdx).trim();
13
- const value = line.slice(colonIdx + 1).trim();
13
+ const value = unquoteScalar(line.slice(colonIdx + 1).trim());
14
14
  if (key && value)
15
15
  frontmatter[key] = value;
16
16
  }
17
17
  return frontmatter;
18
18
  }
19
+ // Reads back a quoted YAML scalar (Claude Code and other tools quote values that contain `: `, `#`
20
+ // and the like); plain values are returned as they are.
21
+ function unquoteScalar(raw) {
22
+ if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
23
+ try {
24
+ const value = JSON.parse(raw);
25
+ if (typeof value === "string")
26
+ return value;
27
+ }
28
+ catch {
29
+ // Not a JSON-compatible quoted scalar (e.g. `"a" vs "b"` written verbatim): keep it as is.
30
+ return raw;
31
+ }
32
+ }
33
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'"))
34
+ return raw.slice(1, -1).replace(/''/g, "'");
35
+ return raw;
36
+ }
37
+ // Quotes a scalar only when YAML would otherwise misread it, so plain values stay plain.
38
+ export function quoteScalar(value) {
39
+ const needsQuoting = value === "" ||
40
+ /^-?\d+(\.\d+)?$/.test(value) ||
41
+ /^(true|false|null|~)$/.test(value) ||
42
+ /^[\s\-?:,[\]{}#&*!|>'"%@`]/.test(value) ||
43
+ /[:#]\s|\s#|:$/.test(value) ||
44
+ value.includes("\n") ||
45
+ value !== value.trim();
46
+ return needsQuoting ? JSON.stringify(value) : value;
47
+ }
19
48
  function findClosingLine(lines) {
20
49
  const limit = Math.min(lines.length, FRONTMATTER_MAX_LINES);
21
50
  for (let i = 1; i < limit; i++) {
@@ -51,8 +80,148 @@ export function parseFrontmatterHeader(raw) {
51
80
  const { frontmatter, hasFrontmatter } = parseFrontmatter(head);
52
81
  return { frontmatter, hasFrontmatter };
53
82
  }
83
+ // Provenance written into files this plugin creates or edits, in the same `metadata:` shape that
84
+ // Claude Code and dsh-unified-memory use, so any tool sharing the folder can tell who wrote what.
85
+ export const ORIGIN = "opencode";
86
+ // Frontmatter for a file this plugin creates. The type lives under `metadata:` as Claude Code
87
+ // writes it today; the flat parser above still reads it.
54
88
  export function buildFrontmatter(input) {
55
- return `---\nname: ${input.name}\ndescription: ${input.description}\ntype: ${input.type}\n---`;
89
+ return [
90
+ "---",
91
+ `name: ${quoteScalar(input.name)}`,
92
+ `description: ${quoteScalar(input.description)}`,
93
+ "metadata:",
94
+ ` type: ${input.type}`,
95
+ ` origin: ${ORIGIN}`,
96
+ ` modified: ${input.modified}`,
97
+ "---",
98
+ ].join("\n");
99
+ }
100
+ const FENCED_BLOCK = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/;
101
+ function indentOf(line) {
102
+ return line.length - line.trimStart().length;
103
+ }
104
+ function isKeyLine(line, key) {
105
+ const trimmed = line.trimStart();
106
+ if (!trimmed.startsWith(`${key}:`))
107
+ return false;
108
+ const next = trimmed[key.length + 1];
109
+ return next === undefined || /\s/.test(next);
110
+ }
111
+ // Index just past a key line and its continuation lines (more-indented lines, or blank lines
112
+ // followed by more-indented ones).
113
+ function endOfEntry(lines, start) {
114
+ const base = indentOf(lines[start] ?? "");
115
+ let end = start + 1;
116
+ while (end < lines.length) {
117
+ const line = lines[end] ?? "";
118
+ if (line.trim() === "") {
119
+ let next = end + 1;
120
+ while (next < lines.length && (lines[next] ?? "").trim() === "")
121
+ next++;
122
+ if (next < lines.length && indentOf(lines[next] ?? "") > base) {
123
+ end = next;
124
+ continue;
125
+ }
126
+ break;
127
+ }
128
+ if (indentOf(line) <= base)
129
+ break;
130
+ end++;
131
+ }
132
+ return end;
133
+ }
134
+ function findTopLevel(lines, key) {
135
+ return lines.findIndex((line) => indentOf(line) === 0 && isKeyLine(line, key));
136
+ }
137
+ function setTopLevel(lines, key, value) {
138
+ const at = findTopLevel(lines, key);
139
+ const rendered = `${key}: ${quoteScalar(value)}`;
140
+ if (at === -1)
141
+ lines.push(rendered);
142
+ else
143
+ lines.splice(at, endOfEntry(lines, at) - at, rendered);
144
+ }
145
+ // The `metadata:` line when it opens a block mapping; `undefined` when it is missing or holds an
146
+ // inline value (a flow map such as `{type: user}`) that block children cannot be added under.
147
+ function metadataBlock(lines) {
148
+ const at = findTopLevel(lines, "metadata");
149
+ if (at === -1)
150
+ return undefined;
151
+ const inline = (lines[at] ?? "").trimStart().slice("metadata:".length).trim();
152
+ return inline === "" || inline.startsWith("#") ? at : undefined;
153
+ }
154
+ function metadataChildIndent(lines, at) {
155
+ const children = lines.slice(at + 1, endOfEntry(lines, at)).filter((line) => line.trim() !== "");
156
+ return children.length > 0 ? " ".repeat(Math.min(...children.map(indentOf))) : " ";
157
+ }
158
+ function findMetadataChild(lines, at, key) {
159
+ const indent = metadataChildIndent(lines, at).length;
160
+ const end = endOfEntry(lines, at);
161
+ for (let i = at + 1; i < end; i++) {
162
+ if (indentOf(lines[i] ?? "") === indent && isKeyLine(lines[i] ?? "", key))
163
+ return i;
164
+ }
165
+ return -1;
166
+ }
167
+ // Sets `metadata.<key>`, adding the block when missing and turning an empty `metadata: {}` into
168
+ // one. Returns false, leaving the lines alone, when `metadata:` is a non-empty inline value.
169
+ function setMetadataKey(lines, key, value) {
170
+ let at = metadataBlock(lines);
171
+ if (at === undefined) {
172
+ const existing = findTopLevel(lines, "metadata");
173
+ if (existing === -1) {
174
+ lines.push("metadata:");
175
+ at = lines.length - 1;
176
+ }
177
+ else if (/^metadata:\s*\{\s*\}\s*$/.test((lines[existing] ?? "").trim())) {
178
+ lines[existing] = "metadata:";
179
+ at = existing;
180
+ }
181
+ else {
182
+ return false;
183
+ }
184
+ }
185
+ const rendered = `${metadataChildIndent(lines, at)}${key}: ${quoteScalar(value)}`;
186
+ const hit = findMetadataChild(lines, at, key);
187
+ if (hit === -1)
188
+ lines.splice(endOfEntry(lines, at), 0, rendered);
189
+ else
190
+ lines.splice(hit, endOfEntry(lines, hit) - hit, rendered);
191
+ return true;
192
+ }
193
+ // Edits an existing file's frontmatter line by line: only the keys being set are rewritten, and
194
+ // every other line (unknown keys, other tools' provenance, lists, comments) is kept byte for byte.
195
+ // A file without frontmatter gets a new block.
196
+ export function editFrontmatter(content, edits) {
197
+ const leading = content.length - content.trimStart().length;
198
+ const text = content.slice(leading);
199
+ const match = FENCED_BLOCK.exec(text);
200
+ const lines = match ? (match[1] ?? "").split(/\r?\n/) : [];
201
+ const newline = match?.[0].includes("\r\n") ? "\r\n" : "\n";
202
+ for (const [key, value] of Object.entries(edits.set ?? {}))
203
+ setTopLevel(lines, key, value);
204
+ for (const [key, value] of Object.entries(edits.setWhereExists ?? {})) {
205
+ const atTop = findTopLevel(lines, key) !== -1;
206
+ const meta = metadataBlock(lines);
207
+ const atMeta = meta !== undefined && findMetadataChild(lines, meta, key) !== -1;
208
+ if (atTop)
209
+ setTopLevel(lines, key, value);
210
+ if (atMeta)
211
+ setMetadataKey(lines, key, value);
212
+ if (!atTop && !atMeta && !setMetadataKey(lines, key, value))
213
+ setTopLevel(lines, key, value);
214
+ }
215
+ for (const [key, value] of Object.entries(edits.setMeta ?? {}))
216
+ setMetadataKey(lines, key, value);
217
+ const head = ["---", ...lines, "---"].join(newline);
218
+ const original = match ? text.slice(match[0].length) : text;
219
+ if (edits.body === undefined)
220
+ return `${head}${newline}${original}`;
221
+ // Replaced body: keep the file's own convention of a blank line after the fence and its own
222
+ // line endings.
223
+ const gap = !match || /^\r?\n/.test(original) ? newline : "";
224
+ return `${head}${newline}${gap}${edits.body.trim().replace(/\r?\n/g, newline)}${newline}`;
56
225
  }
57
226
  export function parseMemoryType(raw) {
58
227
  if (!raw)
package/dist/tools.js CHANGED
@@ -87,11 +87,14 @@ export function buildMemoryTools(store, extraction) {
87
87
  file_name: tool.schema.string().describe(`File name of the memory to delete (${FILE_NAME_HINT})`),
88
88
  },
89
89
  async execute(args) {
90
- const deleted = store.delete(args.file_name);
91
- return {
92
- title: args.file_name,
93
- output: deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`,
94
- };
90
+ const { deleted, trashedTo } = store.delete(args.file_name);
91
+ let output = `Memory "${args.file_name}" not found.`;
92
+ if (deleted) {
93
+ output = trashedTo
94
+ ? `Memory "${args.file_name}" deleted (a copy was kept at ${trashedTo}).`
95
+ : `Memory "${args.file_name}" deleted.`;
96
+ }
97
+ return { title: args.file_name, output };
95
98
  },
96
99
  }),
97
100
  memory_list: tool({
@@ -1,4 +1,5 @@
1
1
  export declare function createExclusiveSync(path: string, content: string): boolean;
2
+ export declare function writeFileAtomicSync(path: string, content: string): void;
2
3
  export declare function fileAgeMs(path: string, now?: number): number | undefined;
3
4
  export declare function sleepSync(ms: number): void;
4
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, statSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { linkSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { dirname } from "node:path";
11
11
  function errorCode(error) {
12
12
  return error?.code;
@@ -42,6 +42,25 @@ export function createExclusiveSync(path, content) {
42
42
  }
43
43
  }
44
44
  }
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) {
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
50
+ try {
51
+ writeFileSync(tmp, content, "utf-8");
52
+ renameSync(tmp, path);
53
+ }
54
+ catch (error) {
55
+ try {
56
+ unlinkSync(tmp);
57
+ }
58
+ catch {
59
+ // never created
60
+ }
61
+ throw error;
62
+ }
63
+ }
45
64
  export function fileAgeMs(path, now = Date.now()) {
46
65
  try {
47
66
  return now - statSync(path).mtimeMs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
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",