opencode-claude-memory 1.7.7 → 2.0.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 +104 -214
- package/dist/agents.d.ts +12 -0
- package/dist/agents.js +69 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.js +79 -0
- package/dist/extraction/ExtractionCoordinator.d.ts +62 -0
- package/dist/extraction/ExtractionCoordinator.js +386 -0
- package/dist/extraction/autodream.d.ts +33 -0
- package/dist/extraction/autodream.js +85 -0
- package/dist/extraction/forkSession.d.ts +37 -0
- package/dist/extraction/forkSession.js +133 -0
- package/dist/extraction/lock.d.ts +29 -0
- package/dist/extraction/lock.js +191 -0
- package/dist/extraction/prompts.d.ts +5 -0
- package/dist/extraction/prompts.js +78 -0
- package/dist/extraction/state.d.ts +38 -0
- package/dist/extraction/state.js +159 -0
- package/dist/hooks/ignore.d.ts +6 -0
- package/dist/hooks/ignore.js +53 -0
- package/dist/hooks/messages.d.ts +14 -0
- package/dist/hooks/messages.js +96 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +50 -665
- package/dist/prompt/sections.d.ts +7 -0
- package/dist/{prompt.js → prompt/sections.js} +16 -72
- package/dist/prompt/systemPrompt.d.ts +7 -0
- package/dist/prompt/systemPrompt.js +64 -0
- package/dist/recall/RecallCoordinator.d.ts +36 -0
- package/dist/recall/RecallCoordinator.js +140 -0
- package/dist/recall/format.d.ts +17 -0
- package/dist/{recall.js → recall/format.js} +13 -36
- package/dist/{recallSelector.d.ts → recall/selector.d.ts} +26 -16
- package/dist/recall/selector.js +92 -0
- package/dist/sdk.d.ts +12 -0
- package/dist/sdk.js +8 -0
- package/dist/store/MemoryStore.d.ts +41 -0
- package/dist/store/MemoryStore.js +105 -0
- package/dist/store/frontmatter.d.ts +25 -0
- package/dist/store/frontmatter.js +61 -0
- package/dist/store/indexFile.d.ts +15 -0
- package/dist/store/indexFile.js +153 -0
- package/dist/{paths.d.ts → store/paths.d.ts} +7 -6
- package/dist/store/paths.js +187 -0
- package/dist/store/scan.d.ts +29 -0
- package/dist/store/scan.js +112 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +142 -0
- package/dist/util/exclusiveFile.d.ts +11 -0
- package/dist/util/exclusiveFile.js +108 -0
- package/dist/util/legacyShellHook.d.ts +3 -0
- package/dist/util/legacyShellHook.js +23 -0
- package/dist/util/log.d.ts +6 -0
- package/dist/util/log.js +29 -0
- package/dist/util/ownedSessions.d.ts +7 -0
- package/dist/util/ownedSessions.js +39 -0
- package/dist/util/timeout.d.ts +5 -0
- package/dist/util/timeout.js +39 -0
- package/package.json +15 -8
- package/bin/opencode-memory +0 -1436
- package/dist/memory.d.ts +0 -31
- package/dist/memory.js +0 -224
- package/dist/memoryScan.d.ts +0 -20
- package/dist/memoryScan.js +0 -106
- package/dist/nativeExtraction.d.ts +0 -9
- package/dist/nativeExtraction.js +0 -80
- package/dist/paths.js +0 -141
- package/dist/prompt.d.ts +0 -4
- package/dist/recall.d.ts +0 -12
- package/dist/recallSelector.js +0 -154
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Claude Code compatible memory directory path resolution.
|
|
2
|
+
// Directory: <CLAUDE_CONFIG_DIR>/projects/<sanitizePath(canonicalGitRoot)>/memory/
|
|
3
|
+
// Pure functions only: no directory creation, no environment access. MemoryStore owns the side effects.
|
|
4
|
+
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
6
|
+
export const ENTRYPOINT_NAME = "MEMORY.md";
|
|
7
|
+
export const MAX_ENTRYPOINT_LINES = 200;
|
|
8
|
+
export const MAX_ENTRYPOINT_BYTES = 25_000;
|
|
9
|
+
export const MAX_MEMORY_FILES = 200;
|
|
10
|
+
export const MAX_MEMORY_FILE_BYTES = 40_000;
|
|
11
|
+
const MAX_SANITIZED_LENGTH = 200;
|
|
12
|
+
// Memory file names may be relative sub-paths (`team/conventions`), matching Claude Code, which reads
|
|
13
|
+
// memory directories recursively. Every segment is validated; the result always uses `/`.
|
|
14
|
+
export function validateMemoryFileName(fileName) {
|
|
15
|
+
if (typeof fileName !== "string" || fileName.length === 0) {
|
|
16
|
+
throw new Error("Memory file name cannot be empty");
|
|
17
|
+
}
|
|
18
|
+
if (fileName.includes("\0")) {
|
|
19
|
+
throw new Error(`Memory file name must not contain null bytes: ${fileName}`);
|
|
20
|
+
}
|
|
21
|
+
if (isAbsolute(fileName) || /^[A-Za-z]:/.test(fileName) || /^[\\/]/.test(fileName)) {
|
|
22
|
+
throw new Error(`Memory file name must be relative to the memory directory: ${fileName}`);
|
|
23
|
+
}
|
|
24
|
+
const segments = fileName.split(/[\\/]/);
|
|
25
|
+
const last = segments.length - 1;
|
|
26
|
+
for (let i = 0; i < segments.length; i++) {
|
|
27
|
+
let segment = segments[i] ?? "";
|
|
28
|
+
if (i === last && segment.endsWith(".md"))
|
|
29
|
+
segment = segment.slice(0, -3);
|
|
30
|
+
if (segment.length === 0) {
|
|
31
|
+
throw new Error(`Memory file name must not contain empty path segments: ${fileName}`);
|
|
32
|
+
}
|
|
33
|
+
if (segment === "." || segment.includes("..")) {
|
|
34
|
+
throw new Error(`Memory file name must not contain path traversal: ${fileName}`);
|
|
35
|
+
}
|
|
36
|
+
if (segment.startsWith(".")) {
|
|
37
|
+
throw new Error(`Memory file name segments must not start with '.': ${fileName}`);
|
|
38
|
+
}
|
|
39
|
+
segments[i] = segment;
|
|
40
|
+
}
|
|
41
|
+
if ((segments[last] ?? "").toUpperCase() === "MEMORY") {
|
|
42
|
+
throw new Error("'MEMORY' is a reserved name and cannot be used as a memory file name");
|
|
43
|
+
}
|
|
44
|
+
return `${segments.join("/")}.md`;
|
|
45
|
+
}
|
|
46
|
+
function canonical(path) {
|
|
47
|
+
try {
|
|
48
|
+
return realpathSync.native(path);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function existsNoFollow(path) {
|
|
55
|
+
try {
|
|
56
|
+
lstatSync(path);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function isInside(path, root) {
|
|
64
|
+
return path === root || path.startsWith(root + sep);
|
|
65
|
+
}
|
|
66
|
+
// Validates the name and resolves it inside `memoryDir`, refusing anything that escapes it.
|
|
67
|
+
//
|
|
68
|
+
// Lexical containment (`resolve` + prefix) is not enough once sub-directories are allowed: a
|
|
69
|
+
// symbolic link inside the memory directory (`team -> /elsewhere`) would let `team/x` read, overwrite
|
|
70
|
+
// or delete a file outside it. The deepest existing path component is therefore resolved through
|
|
71
|
+
// the filesystem and must land inside the *real* memory directory. Links that stay inside the
|
|
72
|
+
// memory directory are fine; a dangling link is rejected because writing through it would create
|
|
73
|
+
// a file wherever it points.
|
|
74
|
+
export function resolveMemoryFilePath(memoryDir, fileName) {
|
|
75
|
+
const relativePath = validateMemoryFileName(fileName);
|
|
76
|
+
const root = resolve(memoryDir);
|
|
77
|
+
const filePath = resolve(root, ...relativePath.split("/"));
|
|
78
|
+
if (!filePath.startsWith(root + sep)) {
|
|
79
|
+
throw new Error(`Memory file name resolves outside the memory directory: ${fileName}`);
|
|
80
|
+
}
|
|
81
|
+
const realRoot = canonical(root);
|
|
82
|
+
if (realRoot !== undefined) {
|
|
83
|
+
let probe = filePath;
|
|
84
|
+
while (probe !== root && !existsNoFollow(probe))
|
|
85
|
+
probe = dirname(probe);
|
|
86
|
+
const realProbe = canonical(probe);
|
|
87
|
+
if (realProbe === undefined || !isInside(realProbe, realRoot)) {
|
|
88
|
+
throw new Error(`Memory file name resolves outside the memory directory: ${fileName}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { relativePath, filePath };
|
|
92
|
+
}
|
|
93
|
+
// Exact copy of Claude Code's djb2Hash() from utils/hash.ts
|
|
94
|
+
function djb2Hash(str) {
|
|
95
|
+
let hash = 0;
|
|
96
|
+
for (let i = 0; i < str.length; i++) {
|
|
97
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
98
|
+
}
|
|
99
|
+
return hash;
|
|
100
|
+
}
|
|
101
|
+
function simpleHash(str) {
|
|
102
|
+
return Math.abs(djb2Hash(str)).toString(36);
|
|
103
|
+
}
|
|
104
|
+
// Exact copy of Claude Code's sanitizePath() from utils/sessionStoragePortable.ts
|
|
105
|
+
export function sanitizePath(name) {
|
|
106
|
+
const sanitized = name.replace(/[^a-zA-Z0-9]/g, "-");
|
|
107
|
+
if (sanitized.length <= MAX_SANITIZED_LENGTH) {
|
|
108
|
+
return sanitized;
|
|
109
|
+
}
|
|
110
|
+
const hash = simpleHash(name);
|
|
111
|
+
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${hash}`;
|
|
112
|
+
}
|
|
113
|
+
// Matches Claude Code's findGitRoot() from utils/git.ts
|
|
114
|
+
export function findGitRoot(startPath) {
|
|
115
|
+
let current = resolve(startPath);
|
|
116
|
+
const root = current.substring(0, current.indexOf(sep) + 1) || sep;
|
|
117
|
+
while (current !== root) {
|
|
118
|
+
try {
|
|
119
|
+
const gitPath = join(current, ".git");
|
|
120
|
+
const s = statSync(gitPath);
|
|
121
|
+
if (s.isDirectory() || s.isFile()) {
|
|
122
|
+
return current.normalize("NFC");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch { }
|
|
126
|
+
const parent = dirname(current);
|
|
127
|
+
if (parent === current)
|
|
128
|
+
break;
|
|
129
|
+
current = parent;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const gitPath = join(root, ".git");
|
|
133
|
+
const s = statSync(gitPath);
|
|
134
|
+
if (s.isDirectory() || s.isFile()) {
|
|
135
|
+
return root.normalize("NFC");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { }
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
// Matches Claude Code's resolveCanonicalRoot() from utils/git.ts
|
|
142
|
+
// Resolves worktrees to the main repo root via .git -> gitdir -> commondir chain
|
|
143
|
+
function resolveCanonicalRoot(gitRoot) {
|
|
144
|
+
try {
|
|
145
|
+
const gitContent = readFileSync(join(gitRoot, ".git"), "utf-8").trim();
|
|
146
|
+
if (!gitContent.startsWith("gitdir:")) {
|
|
147
|
+
return gitRoot;
|
|
148
|
+
}
|
|
149
|
+
const worktreeGitDir = resolve(gitRoot, gitContent.slice("gitdir:".length).trim());
|
|
150
|
+
const commonDir = resolve(worktreeGitDir, readFileSync(join(worktreeGitDir, "commondir"), "utf-8").trim());
|
|
151
|
+
// SECURITY: validate worktreeGitDir is a direct child of <commonDir>/worktrees/
|
|
152
|
+
if (resolve(dirname(worktreeGitDir)) !== join(commonDir, "worktrees")) {
|
|
153
|
+
return gitRoot;
|
|
154
|
+
}
|
|
155
|
+
// SECURITY: validate gitdir back-link points to our .git
|
|
156
|
+
const backlink = realpathSync(readFileSync(join(worktreeGitDir, "gitdir"), "utf-8").trim());
|
|
157
|
+
if (backlink !== join(realpathSync(gitRoot), ".git")) {
|
|
158
|
+
return gitRoot;
|
|
159
|
+
}
|
|
160
|
+
// `commondir` is written by git with `/` separators even on Windows, so both the platform
|
|
161
|
+
// separator and `/` are checked (they are the same check on POSIX).
|
|
162
|
+
if (commonDir.endsWith(`${sep}.git`) || commonDir.endsWith("/.git")) {
|
|
163
|
+
return dirname(commonDir).normalize("NFC");
|
|
164
|
+
}
|
|
165
|
+
return commonDir.normalize("NFC");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return gitRoot;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function findCanonicalGitRoot(startPath) {
|
|
172
|
+
const root = findGitRoot(startPath);
|
|
173
|
+
if (!root)
|
|
174
|
+
return null;
|
|
175
|
+
return resolveCanonicalRoot(root);
|
|
176
|
+
}
|
|
177
|
+
export function isRootPath(path) {
|
|
178
|
+
const resolved = resolve(path);
|
|
179
|
+
return resolved === parse(resolved).root;
|
|
180
|
+
}
|
|
181
|
+
// OpenCode reports worktree "/" for directories outside any git repository; fall back to the
|
|
182
|
+
// directory so memory is not shared by every non-git project on the machine.
|
|
183
|
+
export function resolveMemoryRoot(worktree, directory) {
|
|
184
|
+
if (isRootPath(worktree) && !isRootPath(directory))
|
|
185
|
+
return directory;
|
|
186
|
+
return worktree;
|
|
187
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type MemoryType } from "./frontmatter.js";
|
|
2
|
+
export type MemoryHeader = {
|
|
3
|
+
filename: string;
|
|
4
|
+
filePath: string;
|
|
5
|
+
mtimeMs: number;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
type: MemoryType;
|
|
9
|
+
hasFrontmatter: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type MemoryEntry = MemoryHeader & {
|
|
12
|
+
body: string;
|
|
13
|
+
raw: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function toPosixRelative(relativePath: string): string;
|
|
16
|
+
export declare function nameFromFilename(filename: string): string;
|
|
17
|
+
export declare function surfaceKey(header: Pick<MemoryHeader, "name" | "type">): string;
|
|
18
|
+
export declare function readMemoryHeader(memoryDir: string, filename: string): MemoryHeader | null;
|
|
19
|
+
export declare function readMemoryEntry(memoryDir: string, filename: string): MemoryEntry | null;
|
|
20
|
+
export declare function readMemoryBody(filePath: string): string;
|
|
21
|
+
export type ScanOptions = {
|
|
22
|
+
recursive?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Port of Claude Code's scanMemoryFiles(): recursive scan of the memory directory, header-only
|
|
26
|
+
* parsing, sorted by mtime desc and capped at MAX_MEMORY_FILES.
|
|
27
|
+
*/
|
|
28
|
+
export declare function scanMemoryFiles(memoryDir: string, options?: ScanOptions): MemoryHeader[];
|
|
29
|
+
export declare function formatMemoryManifest(memories: readonly MemoryHeader[]): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join, sep } from "node:path";
|
|
3
|
+
import { parseFrontmatter, parseFrontmatterHeader, parseMemoryType } from "./frontmatter.js";
|
|
4
|
+
import { ENTRYPOINT_NAME, MAX_MEMORY_FILES } from "./paths.js";
|
|
5
|
+
export function toPosixRelative(relativePath) {
|
|
6
|
+
return sep === "/" ? relativePath : relativePath.split(sep).join("/");
|
|
7
|
+
}
|
|
8
|
+
export function nameFromFilename(filename) {
|
|
9
|
+
return basename(filename).replace(/\.md$/, "");
|
|
10
|
+
}
|
|
11
|
+
// Identity used to avoid surfacing the same memory twice in one conversation.
|
|
12
|
+
export function surfaceKey(header) {
|
|
13
|
+
return `${header.name}|${header.type}`;
|
|
14
|
+
}
|
|
15
|
+
function headerFrom(filename, filePath, mtimeMs, parsed) {
|
|
16
|
+
return {
|
|
17
|
+
filename,
|
|
18
|
+
filePath,
|
|
19
|
+
mtimeMs,
|
|
20
|
+
name: parsed.frontmatter.name ?? nameFromFilename(filename),
|
|
21
|
+
description: parsed.frontmatter.description ?? "",
|
|
22
|
+
type: parseMemoryType(parsed.frontmatter.type) ?? "user",
|
|
23
|
+
hasFrontmatter: parsed.hasFrontmatter,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function readMemoryHeader(memoryDir, filename) {
|
|
27
|
+
const filePath = join(memoryDir, ...filename.split("/"));
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
30
|
+
const mtimeMs = statSync(filePath).mtimeMs;
|
|
31
|
+
return headerFrom(filename, filePath, mtimeMs, parseFrontmatterHeader(raw));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function readMemoryEntry(memoryDir, filename) {
|
|
38
|
+
const filePath = join(memoryDir, ...filename.split("/"));
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
41
|
+
const mtimeMs = statSync(filePath).mtimeMs;
|
|
42
|
+
const parsed = parseFrontmatter(raw);
|
|
43
|
+
return { ...headerFrom(filename, filePath, mtimeMs, parsed), body: parsed.body, raw };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Reads a memory body through the same parser the scanner uses.
|
|
50
|
+
export function readMemoryBody(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
return parseFrontmatter(readFileSync(filePath, "utf-8")).body;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Own directory walk instead of `readdirSync({ recursive })`: symbolic links (files or directories)
|
|
59
|
+
// are never followed, so a link planted in the memory directory cannot pull outside files into the
|
|
60
|
+
// manifest or into recall. Whether the runtime's recursive readdir follows links is not relied on.
|
|
61
|
+
function listMarkdownFiles(memoryDir, recursive) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const walk = (dir, prefix) => {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const name = String(entry.name);
|
|
73
|
+
const relative = prefix ? `${prefix}/${name}` : name;
|
|
74
|
+
if (entry.isSymbolicLink())
|
|
75
|
+
continue;
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
if (recursive)
|
|
78
|
+
walk(join(dir, name), relative);
|
|
79
|
+
}
|
|
80
|
+
else if (entry.isFile() && name.endsWith(".md") && name !== ENTRYPOINT_NAME) {
|
|
81
|
+
out.push(relative);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
walk(memoryDir, "");
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Port of Claude Code's scanMemoryFiles(): recursive scan of the memory directory, header-only
|
|
90
|
+
* parsing, sorted by mtime desc and capped at MAX_MEMORY_FILES.
|
|
91
|
+
*/
|
|
92
|
+
export function scanMemoryFiles(memoryDir, options = {}) {
|
|
93
|
+
const headers = [];
|
|
94
|
+
for (const entry of listMarkdownFiles(memoryDir, options.recursive ?? true)) {
|
|
95
|
+
const header = readMemoryHeader(memoryDir, entry);
|
|
96
|
+
if (header)
|
|
97
|
+
headers.push(header);
|
|
98
|
+
}
|
|
99
|
+
return headers.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_MEMORY_FILES);
|
|
100
|
+
}
|
|
101
|
+
// Port of Claude Code's formatMemoryManifest():
|
|
102
|
+
// `- [type] filename (ISO timestamp): description` per line
|
|
103
|
+
export function formatMemoryManifest(memories) {
|
|
104
|
+
return memories
|
|
105
|
+
.map((m) => {
|
|
106
|
+
const ts = new Date(m.mtimeMs).toISOString();
|
|
107
|
+
return m.description
|
|
108
|
+
? `- [${m.type}] ${m.filename} (${ts}): ${m.description}`
|
|
109
|
+
: `- [${m.type}] ${m.filename} (${ts})`;
|
|
110
|
+
})
|
|
111
|
+
.join("\n");
|
|
112
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Hooks } from "@opencode-ai/plugin";
|
|
2
|
+
import type { ExtractionCoordinator } from "./extraction/ExtractionCoordinator.js";
|
|
3
|
+
import type { MemoryStore, SaveMemoryResult } from "./store/MemoryStore.js";
|
|
4
|
+
export declare function formatMemorySaveResult(outcome: SaveMemoryResult, savedThisRun?: readonly string[]): string;
|
|
5
|
+
export declare function memorySaveTitle(type: string, name: string): string | undefined;
|
|
6
|
+
export declare function memoryListTitle(count: number): string;
|
|
7
|
+
export declare function memorySearchTitle(query: string, count: number): string;
|
|
8
|
+
export type MemoryTools = NonNullable<Hooks["tool"]>;
|
|
9
|
+
export declare function buildMemoryTools(store: MemoryStore, extraction: Pick<ExtractionCoordinator, "recordSave">): MemoryTools;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { MEMORY_TYPES } from "./store/frontmatter.js";
|
|
3
|
+
// Tool result for memory_save. Inside an extraction fork, `savedThisRun` (file names already saved
|
|
4
|
+
// by this fork, in order) is appended as an explicit done-signal so the model does not lose track on
|
|
5
|
+
// long transcripts and re-save the same memories until the timeout kills it (#35).
|
|
6
|
+
export function formatMemorySaveResult(outcome, savedThisRun) {
|
|
7
|
+
const inExtractionRun = savedThisRun !== undefined;
|
|
8
|
+
// The current file is recorded before formatting, so "earlier" means it appeared before this call.
|
|
9
|
+
const savedEarlierThisRun = inExtractionRun && savedThisRun.indexOf(outcome.fileName) < savedThisRun.length - 1;
|
|
10
|
+
let head;
|
|
11
|
+
if (outcome.unchanged) {
|
|
12
|
+
head = savedEarlierThisRun
|
|
13
|
+
? `Skipped: "${outcome.fileName}" was already saved earlier in this extraction run with identical content — nothing written.`
|
|
14
|
+
: `Skipped: "${outcome.fileName}" already exists with identical content — nothing written (${outcome.filePath}).`;
|
|
15
|
+
}
|
|
16
|
+
else if (savedEarlierThisRun) {
|
|
17
|
+
head = `Updated "${outcome.fileName}" (first saved earlier in this extraction run) at ${outcome.filePath}`;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
head = `Memory saved to ${outcome.filePath}`;
|
|
21
|
+
}
|
|
22
|
+
if (!inExtractionRun)
|
|
23
|
+
return head;
|
|
24
|
+
const unique = Array.from(new Set(savedThisRun));
|
|
25
|
+
return (`${head}\n\n` +
|
|
26
|
+
`Saved so far in this extraction run (${unique.length}): ${unique.join(", ")}\n` +
|
|
27
|
+
"These memories are already persisted — do not call memory_save for them again. " +
|
|
28
|
+
"Once every distinct memory worth keeping is saved, stop calling tools and reply with a one-line summary.");
|
|
29
|
+
}
|
|
30
|
+
function plural(count, singular, pluralForm) {
|
|
31
|
+
return `${count} ${count === 1 ? singular : pluralForm}`;
|
|
32
|
+
}
|
|
33
|
+
export function memorySaveTitle(type, name) {
|
|
34
|
+
if (type && name)
|
|
35
|
+
return `${type}: ${name}`;
|
|
36
|
+
return name || undefined;
|
|
37
|
+
}
|
|
38
|
+
export function memoryListTitle(count) {
|
|
39
|
+
return plural(count, "memory", "memories");
|
|
40
|
+
}
|
|
41
|
+
export function memorySearchTitle(query, count) {
|
|
42
|
+
return `"${query}" · ${plural(count, "match", "matches")}`;
|
|
43
|
+
}
|
|
44
|
+
const FILE_NAME_HINT = 'with or without the .md extension; sub-directories are allowed, e.g. "team/conventions"';
|
|
45
|
+
export function buildMemoryTools(store, extraction) {
|
|
46
|
+
return {
|
|
47
|
+
memory_save: tool({
|
|
48
|
+
description: "Save or update a memory for future conversations. " +
|
|
49
|
+
"Each memory is stored as a markdown file with frontmatter. " +
|
|
50
|
+
"Use this when the user explicitly asks you to remember something, " +
|
|
51
|
+
"or when you observe important information worth preserving across sessions " +
|
|
52
|
+
"(user preferences, feedback, project context, external references). " +
|
|
53
|
+
"Check existing memories first with memory_list or memory_search to avoid duplicates.",
|
|
54
|
+
args: {
|
|
55
|
+
file_name: tool.schema
|
|
56
|
+
.string()
|
|
57
|
+
.describe('File name for the memory (without .md extension). Use snake_case, e.g. "user_role", "feedback_testing_style", "project_auth_rewrite"; a sub-directory prefix such as "team/conventions" is allowed'),
|
|
58
|
+
name: tool.schema.string().describe("Human-readable name for this memory"),
|
|
59
|
+
description: tool.schema
|
|
60
|
+
.string()
|
|
61
|
+
.describe("One-line description — used to decide relevance in future conversations, so be specific"),
|
|
62
|
+
type: tool.schema
|
|
63
|
+
.enum(MEMORY_TYPES)
|
|
64
|
+
.describe("Memory type: user (about the person), feedback (guidance on approach), project (ongoing work context), reference (pointers to external systems)"),
|
|
65
|
+
content: tool.schema
|
|
66
|
+
.string()
|
|
67
|
+
.describe("Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines"),
|
|
68
|
+
},
|
|
69
|
+
async execute(args, ctx) {
|
|
70
|
+
const outcome = store.save({
|
|
71
|
+
fileName: args.file_name,
|
|
72
|
+
name: args.name,
|
|
73
|
+
description: args.description,
|
|
74
|
+
type: args.type,
|
|
75
|
+
content: args.content,
|
|
76
|
+
});
|
|
77
|
+
const savedThisRun = extraction.recordSave(ctx?.sessionID, outcome.fileName);
|
|
78
|
+
return {
|
|
79
|
+
title: memorySaveTitle(args.type, args.name),
|
|
80
|
+
output: formatMemorySaveResult(outcome, savedThisRun),
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
memory_delete: tool({
|
|
85
|
+
description: "Delete a memory that is outdated, wrong, or no longer relevant. Also removes it from the index.",
|
|
86
|
+
args: {
|
|
87
|
+
file_name: tool.schema.string().describe(`File name of the memory to delete (${FILE_NAME_HINT})`),
|
|
88
|
+
},
|
|
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
|
+
};
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
memory_list: tool({
|
|
98
|
+
description: "List all saved memories with their names, types, and descriptions. " +
|
|
99
|
+
"Use this to check what memories exist before saving a new one (to avoid duplicates) " +
|
|
100
|
+
"or when you need to recall what's been stored.",
|
|
101
|
+
args: {},
|
|
102
|
+
async execute() {
|
|
103
|
+
const entries = store.list();
|
|
104
|
+
const title = memoryListTitle(entries.length);
|
|
105
|
+
if (entries.length === 0)
|
|
106
|
+
return { title, output: "No memories saved yet." };
|
|
107
|
+
const lines = entries.map((e) => `- **${e.name}** (${e.type}) [${e.filename}]: ${e.description}`);
|
|
108
|
+
return { title, output: `${entries.length} memories found:\n${lines.join("\n")}` };
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
memory_search: tool({
|
|
112
|
+
description: "Search memories by keyword. Searches across names, descriptions, and content. " +
|
|
113
|
+
"Use this to find relevant memories before answering questions or when the user references past conversations.",
|
|
114
|
+
args: {
|
|
115
|
+
query: tool.schema.string().describe("Search query — searches across name, description, and content"),
|
|
116
|
+
},
|
|
117
|
+
async execute(args) {
|
|
118
|
+
const results = store.search(args.query);
|
|
119
|
+
const title = memorySearchTitle(args.query, results.length);
|
|
120
|
+
if (results.length === 0)
|
|
121
|
+
return { title, output: `No memories matching "${args.query}".` };
|
|
122
|
+
const lines = results.map((e) => `- **${e.name}** (${e.type}) [${e.filename}]: ${e.description}\n Content: ${e.body.slice(0, 200)}${e.body.length > 200 ? "..." : ""}`);
|
|
123
|
+
return { title, output: `${results.length} matches for "${args.query}":\n${lines.join("\n")}` };
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
memory_read: tool({
|
|
127
|
+
description: "Read the full content of a specific memory file.",
|
|
128
|
+
args: {
|
|
129
|
+
file_name: tool.schema.string().describe(`File name of the memory to read (${FILE_NAME_HINT})`),
|
|
130
|
+
},
|
|
131
|
+
async execute(args) {
|
|
132
|
+
const entry = store.read(args.file_name);
|
|
133
|
+
if (!entry)
|
|
134
|
+
return { title: args.file_name, output: `Memory "${args.file_name}" not found.` };
|
|
135
|
+
return {
|
|
136
|
+
title: args.file_name,
|
|
137
|
+
output: `# ${entry.name}\n**Type:** ${entry.type}\n**Description:** ${entry.description}\n\n${entry.body}`,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function createExclusiveSync(path: string, content: string): boolean;
|
|
2
|
+
export declare function fileAgeMs(path: string, now?: number): number | undefined;
|
|
3
|
+
export declare function sleepSync(ms: number): void;
|
|
4
|
+
export declare function unlinkWithRetry(path: string, attempts?: number): boolean;
|
|
5
|
+
export type FileLockOptions = {
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
staleMs?: number;
|
|
8
|
+
};
|
|
9
|
+
export declare const FILE_LOCK_TIMEOUT_MS = 5000;
|
|
10
|
+
export declare const FILE_LOCK_STALE_MS = 10000;
|
|
11
|
+
export declare function withFileLock<T>(lockPath: string, fn: () => T, options?: FileLockOptions): T;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Atomic "create this file with this content, fail if it exists" and a short-lived cross-process
|
|
2
|
+
// mutex built on it. Used for the maintenance lock and for the extraction-state read-modify-write.
|
|
3
|
+
//
|
|
4
|
+
// `writeFileSync(path, content, { flag: "wx" })` creates the file first and writes the content
|
|
5
|
+
// afterwards, so another process can observe an empty file in between and mistake it for garbage.
|
|
6
|
+
// Writing the content to a private temp file and hard-linking it into place is atomic: `link(2)`
|
|
7
|
+
// either creates the full file or fails with EEXIST. Filesystems without hard links fall back to `wx`.
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { linkSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
function errorCode(error) {
|
|
12
|
+
return error?.code;
|
|
13
|
+
}
|
|
14
|
+
export function createExclusiveSync(path, content) {
|
|
15
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
16
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
17
|
+
writeFileSync(tmp, content, "utf-8");
|
|
18
|
+
try {
|
|
19
|
+
linkSync(tmp, path);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (errorCode(error) === "EEXIST")
|
|
24
|
+
return false;
|
|
25
|
+
// Hard links unsupported here (exFAT, some network mounts): keep the wx fallback.
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(path, content, { encoding: "utf-8", flag: "wx" });
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (fallbackError) {
|
|
31
|
+
if (errorCode(fallbackError) === "EEXIST")
|
|
32
|
+
return false;
|
|
33
|
+
throw fallbackError;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
try {
|
|
38
|
+
unlinkSync(tmp);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// already gone
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function fileAgeMs(path, now = Date.now()) {
|
|
46
|
+
try {
|
|
47
|
+
return now - statSync(path).mtimeMs;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Synchronous, bounded wait for lock retries. Deliberately a busy-wait on the clock rather than
|
|
54
|
+
// `Atomics.wait`: it is only reached while another process holds the state lock (a few
|
|
55
|
+
// milliseconds), and a plain loop cannot block forever on a runtime where the timed wait
|
|
56
|
+
// misbehaves (observed on Windows CI).
|
|
57
|
+
export function sleepSync(ms) {
|
|
58
|
+
const until = Date.now() + Math.max(0, ms);
|
|
59
|
+
while (Date.now() < until) {
|
|
60
|
+
// spin
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Windows can refuse to unlink a file another process has open at that instant; retry briefly.
|
|
64
|
+
export function unlinkWithRetry(path, attempts = 5) {
|
|
65
|
+
for (let i = 0; i < attempts; i++) {
|
|
66
|
+
try {
|
|
67
|
+
unlinkSync(path);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (errorCode(error) === "ENOENT")
|
|
72
|
+
return true;
|
|
73
|
+
if (i < attempts - 1)
|
|
74
|
+
sleepSync(2);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
export const FILE_LOCK_TIMEOUT_MS = 5_000;
|
|
80
|
+
export const FILE_LOCK_STALE_MS = 10_000;
|
|
81
|
+
// Runs `fn` while holding `lockPath`. Contention waits (with short sleeps) instead of skipping,
|
|
82
|
+
// because the critical sections are tiny (one JSON read-modify-write). A crashed holder is reaped
|
|
83
|
+
// once its lock file is older than `staleMs`; two reapers can theoretically both succeed inside
|
|
84
|
+
// that window, which is the same trade-off `proper-lockfile` makes.
|
|
85
|
+
export function withFileLock(lockPath, fn, options = {}) {
|
|
86
|
+
const timeoutMs = options.timeoutMs ?? FILE_LOCK_TIMEOUT_MS;
|
|
87
|
+
const staleMs = options.staleMs ?? FILE_LOCK_STALE_MS;
|
|
88
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
|
+
for (;;) {
|
|
90
|
+
if (createExclusiveSync(lockPath, String(process.pid))) {
|
|
91
|
+
try {
|
|
92
|
+
return fn();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
unlinkWithRetry(lockPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const age = fileAgeMs(lockPath);
|
|
99
|
+
if (age !== undefined && age > staleMs) {
|
|
100
|
+
unlinkWithRetry(lockPath);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (Date.now() >= deadline) {
|
|
104
|
+
throw new Error(`Timed out waiting for lock ${lockPath}`);
|
|
105
|
+
}
|
|
106
|
+
sleepSync(2 + Math.floor(Math.random() * 8));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// v1 installed a shell wrapper (`opencode-memory install`) that wrapped the `opencode` command and
|
|
2
|
+
// ran extraction after the process exited. v2 extracts in-process, so a leftover wrapper would run
|
|
3
|
+
// a second, competing extraction. The plugin only detects and warns; it never edits rc files.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
export const V1_HOOK_START_MARKER = "# >>> opencode-memory auto-initialization >>>";
|
|
7
|
+
// The rc files v1's installer wrote to (zsh / bash), plus the two bash fallbacks it could be
|
|
8
|
+
// sourced from. Fixed list: the plugin does not read `SHELL` or any other environment variable.
|
|
9
|
+
export const V1_HOOK_RC_FILES = [".zshrc", ".bashrc", ".bash_profile", ".profile"];
|
|
10
|
+
export function findLegacyShellHooks(homeDir) {
|
|
11
|
+
const found = [];
|
|
12
|
+
for (const name of V1_HOOK_RC_FILES) {
|
|
13
|
+
const path = join(homeDir, name);
|
|
14
|
+
try {
|
|
15
|
+
if (readFileSync(path, "utf-8").includes(V1_HOOK_START_MARKER))
|
|
16
|
+
found.push(path);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// rc file absent or unreadable
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OpencodeClient } from "../sdk.js";
|
|
2
|
+
export declare const LOG_SERVICE = "opencode-claude-memory";
|
|
3
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
4
|
+
export type Logger = (level: LogLevel, message: string, extra?: Record<string, unknown>) => void;
|
|
5
|
+
export declare function getErrorMessage(error: unknown): string;
|
|
6
|
+
export declare function createLogger(client: OpencodeClient | undefined, directory: string): Logger;
|
package/dist/util/log.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const LOG_SERVICE = "opencode-claude-memory";
|
|
2
|
+
export function getErrorMessage(error) {
|
|
3
|
+
if (error instanceof Error)
|
|
4
|
+
return error.message;
|
|
5
|
+
if (error && typeof error === "object") {
|
|
6
|
+
const message = error.message;
|
|
7
|
+
if (typeof message === "string")
|
|
8
|
+
return message;
|
|
9
|
+
}
|
|
10
|
+
return String(error);
|
|
11
|
+
}
|
|
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
|
+
}
|