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 +1 -1
- package/dist/store/MemoryStore.d.ts +7 -1
- package/dist/store/MemoryStore.js +69 -20
- package/dist/store/frontmatter.d.ts +13 -2
- package/dist/store/frontmatter.js +171 -2
- package/dist/tools.js +8 -5
- package/dist/util/exclusiveFile.d.ts +1 -0
- package/dist/util/exclusiveFile.js +20 -1
- package/package.json +1 -1
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):
|
|
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
|
|
1
|
+
import { cpSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
writeFileAtomicSync(this.entrypoint, content);
|
|
94
120
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
21
|
+
export declare const ORIGIN = "opencode";
|
|
22
|
+
export type NewMemoryFields = {
|
|
21
23
|
name: string;
|
|
22
24
|
description: string;
|
|
23
25
|
type: MemoryType;
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
output
|
|
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.
|
|
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",
|