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.
- package/README.md +68 -11
- package/dist/agents.d.ts +8 -10
- package/dist/agents.js +4 -43
- package/dist/extraction/ExtractionCoordinator.d.ts +10 -12
- package/dist/extraction/ExtractionCoordinator.js +66 -50
- package/dist/extraction/autodream.d.ts +2 -5
- package/dist/extraction/autodream.js +3 -7
- package/dist/host/types.d.ts +52 -0
- package/dist/host/types.js +6 -0
- package/dist/host/v1/agents.d.ts +11 -0
- package/dist/host/v1/agents.js +48 -0
- package/dist/host/v1/coordinators.d.ts +12 -0
- package/dist/host/v1/coordinators.js +43 -0
- package/dist/{extraction/forkSession.d.ts → host/v1/fork.d.ts} +3 -2
- package/dist/{extraction/forkSession.js → host/v1/fork.js} +2 -2
- package/dist/host/v1/host.d.ts +9 -0
- package/dist/host/v1/host.js +79 -0
- package/dist/{hooks → host/v1}/ignore.d.ts +2 -3
- package/dist/host/v1/ignore.js +29 -0
- package/dist/host/v1/log.d.ts +4 -0
- package/dist/host/v1/log.js +20 -0
- package/dist/{hooks → host/v1}/messages.d.ts +3 -2
- package/dist/{hooks → host/v1}/messages.js +3 -17
- package/dist/host/v1/plugin.d.ts +2 -0
- package/dist/host/v1/plugin.js +58 -0
- package/dist/host/v1/selector.d.ts +18 -0
- package/dist/host/v1/selector.js +12 -0
- package/dist/host/v1/tools.d.ts +5 -0
- package/dist/host/v1/tools.js +17 -0
- package/dist/host/v2/agents.d.ts +8 -0
- package/dist/host/v2/agents.js +41 -0
- package/dist/host/v2/events.d.ts +40 -0
- package/dist/host/v2/events.js +137 -0
- package/dist/host/v2/fork.d.ts +76 -0
- package/dist/host/v2/fork.js +136 -0
- package/dist/host/v2/host.d.ts +33 -0
- package/dist/host/v2/host.js +87 -0
- package/dist/host/v2/log.d.ts +4 -0
- package/dist/host/v2/log.js +40 -0
- package/dist/host/v2/plugin.d.ts +26 -0
- package/dist/host/v2/plugin.js +148 -0
- package/dist/host/v2/transcript.d.ts +3 -0
- package/dist/host/v2/transcript.js +50 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -55
- package/dist/recall/RecallCoordinator.d.ts +15 -10
- package/dist/recall/RecallCoordinator.js +26 -35
- package/dist/recall/selector.d.ts +14 -19
- package/dist/recall/selector.js +37 -47
- package/dist/recall/turn.d.ts +4 -0
- package/dist/recall/turn.js +49 -0
- package/dist/store/MemoryStore.js +50 -6
- package/dist/store/frontmatter.d.ts +1 -0
- package/dist/store/frontmatter.js +19 -0
- package/dist/tools.d.ts +16 -3
- package/dist/tools.js +59 -41
- package/dist/util/exclusiveFile.d.ts +1 -1
- package/dist/util/exclusiveFile.js +102 -10
- package/dist/util/log.d.ts +0 -2
- package/dist/util/log.js +2 -18
- package/package.json +16 -4
- package/dist/hooks/ignore.js +0 -53
- /package/dist/{sdk.d.ts → host/v1/sdk.d.ts} +0 -0
- /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
|
|
86
|
-
// keeps a copy under the plugin's state
|
|
87
|
-
// prune can never silently destroy
|
|
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 (
|
|
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 {
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
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:
|
|
59
|
-
description:
|
|
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:
|
|
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:
|
|
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
|
|
72
|
-
name: args
|
|
73
|
-
description: args
|
|
74
|
-
type
|
|
75
|
-
content: args
|
|
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
|
|
88
|
+
const savedThisRun = extraction.recordSave(ctx.sessionID, outcome.fileName);
|
|
78
89
|
return {
|
|
79
|
-
title: memorySaveTitle(
|
|
90
|
+
title: memorySaveTitle(type, str(args, "name")),
|
|
80
91
|
output: formatMemorySaveResult(outcome, savedThisRun),
|
|
81
92
|
};
|
|
82
93
|
},
|
|
83
|
-
}
|
|
84
|
-
|
|
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:
|
|
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
|
|
91
|
-
|
|
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 "${
|
|
95
|
-
: `Memory "${
|
|
107
|
+
? `Memory "${fileName}" deleted (a copy was kept at ${trashedTo}).`
|
|
108
|
+
: `Memory "${fileName}" deleted.`;
|
|
96
109
|
}
|
|
97
|
-
return { title:
|
|
110
|
+
return { title: fileName, output };
|
|
98
111
|
},
|
|
99
|
-
}
|
|
100
|
-
|
|
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
|
-
|
|
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:
|
|
133
|
+
query: z.string().describe("Search query — searches across name, description, and content"),
|
|
119
134
|
},
|
|
120
135
|
async execute(args) {
|
|
121
|
-
const
|
|
122
|
-
const
|
|
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 "${
|
|
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 "${
|
|
142
|
+
return { title, output: `${results.length} matches for "${query}":\n${lines.join("\n")}` };
|
|
127
143
|
},
|
|
128
|
-
}
|
|
129
|
-
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "memory_read",
|
|
130
147
|
description: "Read the full content of a specific memory file.",
|
|
131
148
|
args: {
|
|
132
|
-
file_name:
|
|
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
|
|
152
|
+
const fileName = str(args, "file_name");
|
|
153
|
+
const entry = store.read(fileName);
|
|
136
154
|
if (!entry)
|
|
137
|
-
return { title:
|
|
155
|
+
return { title: fileName, output: `Memory "${fileName}" not found.` };
|
|
138
156
|
return {
|
|
139
|
-
title:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
144
|
+
rename(tmp, target);
|
|
145
|
+
return true;
|
|
57
146
|
}
|
|
58
|
-
catch {
|
|
59
|
-
|
|
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/dist/util/log.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
package/dist/hooks/ignore.js
DELETED
|
@@ -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
|