open-memex 0.2.0-alpha → 0.3.0-alpha
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/AGENTS.md +32 -6
- package/README.md +239 -37
- package/README.zh-CN.md +307 -0
- package/bin/open-memex.js +28 -0
- package/docs/V2-DESIGN.md +107 -3
- package/package.json +12 -3
- package/scripts/smoke-mcp.ts +135 -0
- package/scripts/smoke-pure.ts +42 -1
- package/src/capture/keywords.ts +28 -15
- package/src/cli.ts +214 -9
- package/src/config.ts +89 -4
- package/src/doctor.ts +161 -0
- package/src/index.ts +19 -10
- package/src/init.ts +304 -0
- package/src/mcp.ts +133 -0
- package/src/redact.ts +121 -17
- package/src/tools/memory.ts +27 -202
- package/src/tools/ops.ts +259 -0
- package/PLAN.md +0 -168
package/src/tools/memory.ts
CHANGED
|
@@ -1,233 +1,58 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
2
|
import type { Scope } from "../scope.ts";
|
|
3
3
|
import type { MyOMemoryConfig } from "../config.ts";
|
|
4
|
-
import { PERSONAL_SCOPE } from "../scope.ts";
|
|
5
|
-
import { search, list } from "../retrieve/search.ts";
|
|
6
4
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const z = tool.schema;
|
|
21
|
-
|
|
22
|
-
/** v2 content-kind taxonomy (V2-DESIGN §3.1). */
|
|
23
|
-
const MEMORY_TYPES = MEMORY_TYPE_TAXONOMY;
|
|
5
|
+
addMemory,
|
|
6
|
+
searchMemories,
|
|
7
|
+
listMemories,
|
|
8
|
+
supersedeMemory,
|
|
9
|
+
forgetMemory,
|
|
10
|
+
memoryAddArgs,
|
|
11
|
+
memorySearchArgs,
|
|
12
|
+
memoryListArgs,
|
|
13
|
+
memorySupersedeArgs,
|
|
14
|
+
memoryForgetArgs,
|
|
15
|
+
TOOL_DESCRIPTIONS,
|
|
16
|
+
} from "./ops.ts";
|
|
24
17
|
|
|
25
18
|
export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
|
|
26
|
-
const scopeArg = z
|
|
27
|
-
.enum(["project", "personal", "user"])
|
|
28
|
-
.optional()
|
|
29
|
-
.describe(
|
|
30
|
-
"Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.",
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
function resolveScope(kind?: "project" | "personal" | "user"): Scope {
|
|
34
|
-
return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
19
|
const memory_add = tool({
|
|
38
|
-
description:
|
|
39
|
-
|
|
40
|
-
args: {
|
|
41
|
-
content: z.string().min(1).describe("The fact to remember. One idea per memory."),
|
|
42
|
-
type: z
|
|
43
|
-
.enum(MEMORY_TYPES)
|
|
44
|
-
.optional()
|
|
45
|
-
.describe("Category of memory. Default: note."),
|
|
46
|
-
scope: scopeArg,
|
|
47
|
-
tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
|
|
48
|
-
},
|
|
20
|
+
description: TOOL_DESCRIPTIONS.memory_add,
|
|
21
|
+
args: memoryAddArgs,
|
|
49
22
|
async execute(args) {
|
|
50
|
-
|
|
51
|
-
args.content,
|
|
52
|
-
cfg.redactPatterns,
|
|
53
|
-
);
|
|
54
|
-
if (hadSecret) {
|
|
55
|
-
return {
|
|
56
|
-
title: "memory: rejected (secret detected)",
|
|
57
|
-
output: `Refused to save: content matched a secret pattern (${matchedPattern}). Wrap the sensitive part in <private>...</private> tags or paraphrase, then try again.`,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
const s = resolveScope(args.scope);
|
|
61
|
-
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
|
|
62
|
-
// are reported so the caller can supersede instead of duplicating.
|
|
63
|
-
const dups = findDuplicates(s.key, redacted);
|
|
64
|
-
if (dups.exact) {
|
|
65
|
-
return {
|
|
66
|
-
title: "memory: already exists",
|
|
67
|
-
output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
const rfc = msToRfc3339(Date.now());
|
|
71
|
-
const fm: Frontmatter = {
|
|
72
|
-
id: ulid(),
|
|
73
|
-
schema_version: 2,
|
|
74
|
-
scope_key: s.key,
|
|
75
|
-
scope: s.kind === "project" ? "project" : "personal",
|
|
76
|
-
visibility: s.kind === "project" ? "internal" : "private",
|
|
77
|
-
project_name: s.projectName,
|
|
78
|
-
type: args.type ?? "fact",
|
|
79
|
-
role: "knowledge",
|
|
80
|
-
importance: "normal",
|
|
81
|
-
status: "active",
|
|
82
|
-
tags: args.tags ?? [],
|
|
83
|
-
source: "tool",
|
|
84
|
-
created_at: rfc,
|
|
85
|
-
updated_at: rfc,
|
|
86
|
-
supersedes: null,
|
|
87
|
-
superseded_by: null,
|
|
88
|
-
};
|
|
89
|
-
const { filePath } = writeMemoryFile(fm, redacted);
|
|
90
|
-
const mf = readMemoryFile(filePath);
|
|
91
|
-
if (mf) upsertFromFile(mf);
|
|
92
|
-
let output = `Saved to ${s.key} as ${fm.type}. id=${fm.id}`;
|
|
93
|
-
for (const n of dups.near) {
|
|
94
|
-
output += `\nNote: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id} — ${n.snippet}. Use memory_supersede if this replaces it.`;
|
|
95
|
-
}
|
|
96
|
-
return {
|
|
97
|
-
title: `memory: saved ${fm.id}`,
|
|
98
|
-
output,
|
|
99
|
-
};
|
|
23
|
+
return addMemory(getScope, cfg, args);
|
|
100
24
|
},
|
|
101
25
|
});
|
|
102
26
|
|
|
103
27
|
const memory_search = tool({
|
|
104
|
-
description:
|
|
105
|
-
|
|
106
|
-
args: {
|
|
107
|
-
query: z
|
|
108
|
-
.string()
|
|
109
|
-
.min(1)
|
|
110
|
-
.describe("Free-text query. File paths, error strings, identifiers work well."),
|
|
111
|
-
scope: z
|
|
112
|
-
.enum(["project", "personal", "user", "both"])
|
|
113
|
-
.optional()
|
|
114
|
-
.describe("Which scope(s) to search. Default: both."),
|
|
115
|
-
type: z.string().optional().describe("Restrict to memories of this type."),
|
|
116
|
-
limit: z.number().int().min(1).max(50).optional(),
|
|
117
|
-
},
|
|
28
|
+
description: TOOL_DESCRIPTIONS.memory_search,
|
|
29
|
+
args: memorySearchArgs,
|
|
118
30
|
async execute(args) {
|
|
119
|
-
|
|
120
|
-
const scopeKeys =
|
|
121
|
-
args.scope === "personal" || args.scope === "user"
|
|
122
|
-
? [PERSONAL_SCOPE.key]
|
|
123
|
-
: args.scope === "project"
|
|
124
|
-
? [project.key]
|
|
125
|
-
: [project.key, PERSONAL_SCOPE.key];
|
|
126
|
-
const hits = search(args.query, {
|
|
127
|
-
scopeKeys,
|
|
128
|
-
limit: args.limit,
|
|
129
|
-
type: args.type,
|
|
130
|
-
});
|
|
131
|
-
if (hits.length === 0) {
|
|
132
|
-
return {
|
|
133
|
-
title: "memory: 0 results",
|
|
134
|
-
output: `No memories matched "${args.query}".`,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
const lines = hits.map(
|
|
138
|
-
(h) =>
|
|
139
|
-
`- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`,
|
|
140
|
-
);
|
|
141
|
-
return {
|
|
142
|
-
title: `memory: ${hits.length} result(s)`,
|
|
143
|
-
output: lines.join("\n"),
|
|
144
|
-
};
|
|
31
|
+
return searchMemories(getScope, args);
|
|
145
32
|
},
|
|
146
33
|
});
|
|
147
34
|
|
|
148
35
|
const memory_list = tool({
|
|
149
|
-
description:
|
|
150
|
-
args:
|
|
151
|
-
scope: scopeArg,
|
|
152
|
-
type: z.string().optional(),
|
|
153
|
-
limit: z.number().int().min(1).max(100).optional(),
|
|
154
|
-
},
|
|
36
|
+
description: TOOL_DESCRIPTIONS.memory_list,
|
|
37
|
+
args: memoryListArgs,
|
|
155
38
|
async execute(args) {
|
|
156
|
-
|
|
157
|
-
const hits = list(s.key, { type: args.type, limit: args.limit });
|
|
158
|
-
if (hits.length === 0) {
|
|
159
|
-
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
160
|
-
}
|
|
161
|
-
const lines = hits.map(
|
|
162
|
-
(h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`,
|
|
163
|
-
);
|
|
164
|
-
return {
|
|
165
|
-
title: `memory: ${hits.length} in ${s.key}`,
|
|
166
|
-
output: lines.join("\n"),
|
|
167
|
-
};
|
|
39
|
+
return listMemories(getScope, args);
|
|
168
40
|
},
|
|
169
41
|
});
|
|
170
42
|
|
|
171
43
|
const memory_supersede = tool({
|
|
172
|
-
description:
|
|
173
|
-
|
|
174
|
-
args: {
|
|
175
|
-
id: z.string().min(1).describe("ID of the memory being replaced."),
|
|
176
|
-
content: z.string().min(1).describe("The new, corrected content."),
|
|
177
|
-
type: z
|
|
178
|
-
.enum(MEMORY_TYPES)
|
|
179
|
-
.optional()
|
|
180
|
-
.describe("Category of the new memory. Defaults to the old memory's type."),
|
|
181
|
-
tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
|
|
182
|
-
},
|
|
44
|
+
description: TOOL_DESCRIPTIONS.memory_supersede,
|
|
45
|
+
args: memorySupersedeArgs,
|
|
183
46
|
async execute(args) {
|
|
184
|
-
|
|
185
|
-
args.content,
|
|
186
|
-
cfg.redactPatterns,
|
|
187
|
-
);
|
|
188
|
-
if (hadSecret) {
|
|
189
|
-
return {
|
|
190
|
-
title: "memory: rejected (secret detected)",
|
|
191
|
-
output: `Refused to save: content matched a secret pattern (${matchedPattern}). Wrap the sensitive part in <private>...</private> tags or paraphrase, then try again.`,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
try {
|
|
195
|
-
const { oldMf, newMf } = supersede(args.id, {
|
|
196
|
-
body: redacted,
|
|
197
|
-
type: args.type,
|
|
198
|
-
tags: args.tags,
|
|
199
|
-
source: "tool",
|
|
200
|
-
});
|
|
201
|
-
upsertFromFile(oldMf);
|
|
202
|
-
upsertFromFile(newMf);
|
|
203
|
-
return {
|
|
204
|
-
title: `memory: superseded ${oldMf.fm.id}`,
|
|
205
|
-
output: `Replaced ${oldMf.fm.id} with ${newMf.fm.id} (old kept as history).`,
|
|
206
|
-
};
|
|
207
|
-
} catch (e) {
|
|
208
|
-
return {
|
|
209
|
-
title: "memory: supersede failed",
|
|
210
|
-
output: (e as Error).message,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
47
|
+
return supersedeMemory(cfg, args);
|
|
213
48
|
},
|
|
214
49
|
});
|
|
215
50
|
|
|
216
51
|
const memory_forget = tool({
|
|
217
|
-
description:
|
|
218
|
-
args:
|
|
219
|
-
id: z.string().min(1),
|
|
220
|
-
},
|
|
52
|
+
description: TOOL_DESCRIPTIONS.memory_forget,
|
|
53
|
+
args: memoryForgetArgs,
|
|
221
54
|
async execute(args) {
|
|
222
|
-
|
|
223
|
-
.prepare(`SELECT scope_key FROM memories WHERE id = ?`)
|
|
224
|
-
.get(args.id) as { scope_key: string } | undefined;
|
|
225
|
-
if (!row) {
|
|
226
|
-
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
227
|
-
}
|
|
228
|
-
deleteMemoryFile(row.scope_key, args.id);
|
|
229
|
-
deleteFromIndex(args.id);
|
|
230
|
-
return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
|
|
55
|
+
return forgetMemory(args);
|
|
231
56
|
},
|
|
232
57
|
});
|
|
233
58
|
|
package/src/tools/ops.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic memory operations.
|
|
3
|
+
*
|
|
4
|
+
* The same functions back both the opencode plugin tools
|
|
5
|
+
* (`src/tools/memory.ts`) and the generic MCP server (`src/mcp.ts`).
|
|
6
|
+
* They take plain validated args and return a plain { title, output }
|
|
7
|
+
* result; each host adapts that to its own tool-result shape.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import type { Scope } from "../scope.ts";
|
|
11
|
+
import type { MyOMemoryConfig } from "../config.ts";
|
|
12
|
+
import { PERSONAL_SCOPE } from "../scope.ts";
|
|
13
|
+
import { search, list } from "../retrieve/search.ts";
|
|
14
|
+
import {
|
|
15
|
+
writeMemoryFile,
|
|
16
|
+
deleteMemoryFile,
|
|
17
|
+
readMemoryFile,
|
|
18
|
+
ulid,
|
|
19
|
+
msToRfc3339,
|
|
20
|
+
MEMORY_TYPE_TAXONOMY,
|
|
21
|
+
type Frontmatter,
|
|
22
|
+
} from "../store/markdown.ts";
|
|
23
|
+
import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
|
|
24
|
+
import { findDuplicates, supersede } from "../store/lifecycle.ts";
|
|
25
|
+
import { db } from "../store/db.ts";
|
|
26
|
+
import { redact } from "../redact.ts";
|
|
27
|
+
|
|
28
|
+
export interface ToolResult {
|
|
29
|
+
title: string;
|
|
30
|
+
output: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
|
|
34
|
+
export const TOOL_DESCRIPTIONS = {
|
|
35
|
+
memory_add:
|
|
36
|
+
"Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Do not wait to be asked. Keep each memory to one self-contained statement. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
|
|
37
|
+
memory_search:
|
|
38
|
+
"Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Call before asking the user about past decisions, conventions, or preferences they may have told you before — try a few keyword variants, including the user's own language, when the first search comes up empty.",
|
|
39
|
+
memory_list:
|
|
40
|
+
"List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
|
|
41
|
+
memory_supersede:
|
|
42
|
+
"Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
|
|
43
|
+
memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/** Shared zod input shapes (raw shape, not z.object — hosts wrap as needed). */
|
|
47
|
+
export const scopeArg = z
|
|
48
|
+
.enum(["project", "personal", "user"])
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
"Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.",
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export const memoryAddArgs = {
|
|
55
|
+
content: z.string().min(1).describe("The fact to remember. One idea per memory."),
|
|
56
|
+
type: z
|
|
57
|
+
.enum(MEMORY_TYPE_TAXONOMY)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Category of memory. Default: note."),
|
|
60
|
+
scope: scopeArg,
|
|
61
|
+
tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
|
|
62
|
+
};
|
|
63
|
+
export type MemoryAddArgs = z.infer<z.ZodObject<typeof memoryAddArgs>>;
|
|
64
|
+
|
|
65
|
+
export const memorySearchArgs = {
|
|
66
|
+
query: z
|
|
67
|
+
.string()
|
|
68
|
+
.min(1)
|
|
69
|
+
.describe("Free-text query. File paths, error strings, identifiers work well."),
|
|
70
|
+
scope: z
|
|
71
|
+
.enum(["project", "personal", "user", "both"])
|
|
72
|
+
.optional()
|
|
73
|
+
.describe("Which scope(s) to search. Default: both."),
|
|
74
|
+
type: z.string().optional().describe("Restrict to memories of this type."),
|
|
75
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
76
|
+
};
|
|
77
|
+
export type MemorySearchArgs = z.infer<z.ZodObject<typeof memorySearchArgs>>;
|
|
78
|
+
|
|
79
|
+
export const memoryListArgs = {
|
|
80
|
+
scope: scopeArg,
|
|
81
|
+
type: z.string().optional(),
|
|
82
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
83
|
+
};
|
|
84
|
+
export type MemoryListArgs = z.infer<z.ZodObject<typeof memoryListArgs>>;
|
|
85
|
+
|
|
86
|
+
export const memorySupersedeArgs = {
|
|
87
|
+
id: z.string().min(1).describe("ID of the memory being replaced."),
|
|
88
|
+
content: z.string().min(1).describe("The new, corrected content."),
|
|
89
|
+
type: z
|
|
90
|
+
.enum(MEMORY_TYPE_TAXONOMY)
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("Category of the new memory. Defaults to the old memory's type."),
|
|
93
|
+
tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
|
|
94
|
+
};
|
|
95
|
+
export type MemorySupersedeArgs = z.infer<z.ZodObject<typeof memorySupersedeArgs>>;
|
|
96
|
+
|
|
97
|
+
export const memoryForgetArgs = {
|
|
98
|
+
id: z.string().min(1),
|
|
99
|
+
};
|
|
100
|
+
export type MemoryForgetArgs = z.infer<z.ZodObject<typeof memoryForgetArgs>>;
|
|
101
|
+
|
|
102
|
+
function resolveScope(
|
|
103
|
+
getScope: () => Scope,
|
|
104
|
+
kind?: "project" | "personal" | "user",
|
|
105
|
+
): Scope {
|
|
106
|
+
return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildFrontmatter(
|
|
110
|
+
s: Scope,
|
|
111
|
+
body: {
|
|
112
|
+
type: Frontmatter["type"];
|
|
113
|
+
tags: string[];
|
|
114
|
+
source: Frontmatter["source"];
|
|
115
|
+
},
|
|
116
|
+
): Frontmatter {
|
|
117
|
+
const rfc = msToRfc3339(Date.now());
|
|
118
|
+
return {
|
|
119
|
+
id: ulid(),
|
|
120
|
+
schema_version: 2,
|
|
121
|
+
scope_key: s.key,
|
|
122
|
+
scope: s.kind === "project" ? "project" : "personal",
|
|
123
|
+
visibility: s.kind === "project" ? "internal" : "private",
|
|
124
|
+
project_name: s.projectName,
|
|
125
|
+
type: body.type,
|
|
126
|
+
role: "knowledge",
|
|
127
|
+
importance: "normal",
|
|
128
|
+
status: "active",
|
|
129
|
+
tags: body.tags,
|
|
130
|
+
source: body.source,
|
|
131
|
+
created_at: rfc,
|
|
132
|
+
updated_at: rfc,
|
|
133
|
+
supersedes: null,
|
|
134
|
+
superseded_by: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function addMemory(
|
|
139
|
+
getScope: () => Scope,
|
|
140
|
+
cfg: MyOMemoryConfig,
|
|
141
|
+
args: MemoryAddArgs,
|
|
142
|
+
): Promise<ToolResult> {
|
|
143
|
+
const { content: redacted, hadSecret, matchedPattern } = redact(
|
|
144
|
+
args.content,
|
|
145
|
+
cfg.redactPatterns,
|
|
146
|
+
);
|
|
147
|
+
const secretNote = hadSecret
|
|
148
|
+
? `\nNote: content matched a secret pattern (${matchedPattern}); it was saved with the secret masked (first 4 chars kept, rest replaced with x).`
|
|
149
|
+
: "";
|
|
150
|
+
const s = resolveScope(getScope, args.scope);
|
|
151
|
+
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
|
|
152
|
+
// are reported so the caller can supersede instead of duplicating.
|
|
153
|
+
const dups = findDuplicates(s.key, redacted);
|
|
154
|
+
if (dups.exact) {
|
|
155
|
+
return {
|
|
156
|
+
title: "memory: already exists",
|
|
157
|
+
output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const fm = buildFrontmatter(s, {
|
|
161
|
+
type: args.type ?? "fact",
|
|
162
|
+
tags: args.tags ?? [],
|
|
163
|
+
source: "tool",
|
|
164
|
+
});
|
|
165
|
+
const { filePath } = writeMemoryFile(fm, redacted);
|
|
166
|
+
const mf = readMemoryFile(filePath);
|
|
167
|
+
if (mf) upsertFromFile(mf);
|
|
168
|
+
let output = `Saved to ${s.kind} scope (${s.key}) as ${fm.type}. id=${fm.id}${secretNote}`;
|
|
169
|
+
for (const n of dups.near) {
|
|
170
|
+
output += `\nNote: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id} — ${n.snippet}. Use memory_supersede if this replaces it.`;
|
|
171
|
+
}
|
|
172
|
+
return { title: `memory: saved ${fm.id}`, output };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function searchMemories(
|
|
176
|
+
getScope: () => Scope,
|
|
177
|
+
args: MemorySearchArgs,
|
|
178
|
+
): Promise<ToolResult> {
|
|
179
|
+
const project = getScope();
|
|
180
|
+
const scopeKeys =
|
|
181
|
+
args.scope === "personal" || args.scope === "user"
|
|
182
|
+
? [PERSONAL_SCOPE.key]
|
|
183
|
+
: args.scope === "project"
|
|
184
|
+
? [project.key]
|
|
185
|
+
: [project.key, PERSONAL_SCOPE.key];
|
|
186
|
+
const hits = search(args.query, {
|
|
187
|
+
scopeKeys,
|
|
188
|
+
limit: args.limit,
|
|
189
|
+
type: args.type,
|
|
190
|
+
});
|
|
191
|
+
if (hits.length === 0) {
|
|
192
|
+
return {
|
|
193
|
+
title: "memory: 0 results",
|
|
194
|
+
output: `No memories matched "${args.query}".`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const lines = hits.map(
|
|
198
|
+
(h) =>
|
|
199
|
+
`- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`,
|
|
200
|
+
);
|
|
201
|
+
return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function listMemories(
|
|
205
|
+
getScope: () => Scope,
|
|
206
|
+
args: MemoryListArgs,
|
|
207
|
+
): Promise<ToolResult> {
|
|
208
|
+
const s = resolveScope(getScope, args.scope);
|
|
209
|
+
const hits = list(s.key, { type: args.type, limit: args.limit });
|
|
210
|
+
if (hits.length === 0) {
|
|
211
|
+
return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
|
|
212
|
+
}
|
|
213
|
+
const lines = hits.map(
|
|
214
|
+
(h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`,
|
|
215
|
+
);
|
|
216
|
+
return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function supersedeMemory(
|
|
220
|
+
cfg: MyOMemoryConfig,
|
|
221
|
+
args: MemorySupersedeArgs,
|
|
222
|
+
): Promise<ToolResult> {
|
|
223
|
+
const { content: redacted, hadSecret, matchedPattern } = redact(
|
|
224
|
+
args.content,
|
|
225
|
+
cfg.redactPatterns,
|
|
226
|
+
);
|
|
227
|
+
const secretNote = hadSecret ? ` (secret masked: ${matchedPattern})` : "";
|
|
228
|
+
try {
|
|
229
|
+
const { oldMf, newMf } = supersede(args.id, {
|
|
230
|
+
body: redacted,
|
|
231
|
+
type: args.type,
|
|
232
|
+
tags: args.tags,
|
|
233
|
+
source: "tool",
|
|
234
|
+
});
|
|
235
|
+
upsertFromFile(oldMf);
|
|
236
|
+
upsertFromFile(newMf);
|
|
237
|
+
return {
|
|
238
|
+
title: `memory: superseded ${oldMf.fm.id}`,
|
|
239
|
+
output: `Replaced ${oldMf.fm.id} with ${newMf.fm.id} (old kept as history).${secretNote}`,
|
|
240
|
+
};
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return {
|
|
243
|
+
title: "memory: supersede failed",
|
|
244
|
+
output: (e as Error).message,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function forgetMemory(args: MemoryForgetArgs): Promise<ToolResult> {
|
|
250
|
+
const row = db()
|
|
251
|
+
.prepare(`SELECT scope_key FROM memories WHERE id = ?`)
|
|
252
|
+
.get(args.id) as { scope_key: string } | undefined;
|
|
253
|
+
if (!row) {
|
|
254
|
+
return { title: "memory: not found", output: `No memory with id ${args.id}.` };
|
|
255
|
+
}
|
|
256
|
+
deleteMemoryFile(row.scope_key, args.id);
|
|
257
|
+
deleteFromIndex(args.id);
|
|
258
|
+
return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
|
|
259
|
+
}
|
package/PLAN.md
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
# open-memex — Design & Roadmap
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
|
|
5
|
-
A local-only persistent memory plugin for opencode. No SaaS, no account, no
|
|
6
|
-
network calls. Memories live as human-readable markdown files, indexed by
|
|
7
|
-
SQLite FTS5 for BM25 keyword search.
|
|
8
|
-
|
|
9
|
-
## Non-goals (MVP)
|
|
10
|
-
|
|
11
|
-
- No cloud sync
|
|
12
|
-
- No embeddings / vector search (v2)
|
|
13
|
-
- No LLM-driven memory extraction (v2)
|
|
14
|
-
- No knowledge graph / entity linking
|
|
15
|
-
- No multi-user collaboration
|
|
16
|
-
|
|
17
|
-
## Architecture
|
|
18
|
-
|
|
19
|
-
```
|
|
20
|
-
+-----------------------------+
|
|
21
|
-
| opencode (Bun) |
|
|
22
|
-
| |
|
|
23
|
-
| chat.message hook ------> keyword capture ---+
|
|
24
|
-
| system.transform ------> context inject <---|--- memory files
|
|
25
|
-
| tool.memory_* ------> add/search/list/forget (source of truth)
|
|
26
|
-
+-----------------------------+ |
|
|
27
|
-
v
|
|
28
|
-
+----------------+
|
|
29
|
-
| SQLite index |
|
|
30
|
-
| (FTS5, BM25) |
|
|
31
|
-
+----------------+
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
- `memories` — one file per memory. YAML frontmatter + body.
|
|
35
|
-
- `index.db` — SQLite (via `better-sqlite3`) with FTS5 virtual table over content/tags/type.
|
|
36
|
-
- Loads under opencode's embedded Bun runtime (better-sqlite3 works via N-API compat).
|
|
37
|
-
- CLI runs under Node 22+ with `--experimental-strip-types` — no Bun required, no build step.
|
|
38
|
-
|
|
39
|
-
## Data model
|
|
40
|
-
|
|
41
|
-
### File on disk
|
|
42
|
-
|
|
43
|
-
`memories/<scope_key>/<id>.md`
|
|
44
|
-
|
|
45
|
-
```markdown
|
|
46
|
-
---
|
|
47
|
-
id: 01HZ...
|
|
48
|
-
scope_key: project__open-memex__a1b2c3d4e5f6
|
|
49
|
-
scope_kind: project
|
|
50
|
-
project_name: open-memex
|
|
51
|
-
type: project-config
|
|
52
|
-
tags: [bun, sqlite]
|
|
53
|
-
source: tool
|
|
54
|
-
created_at: 1770000000000
|
|
55
|
-
updated_at: 1770000000000
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
This project runs under Bun and uses bun:sqlite. Do not add better-sqlite3.
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
### SQLite tables
|
|
62
|
-
|
|
63
|
-
- `memories(id PK, scope_key, scope_kind, project_name, type, tags CSV, content, source, file_path, mtime_ms, created_at, updated_at)`
|
|
64
|
-
- `memories_fts` — FTS5 virtual table over `content`, `tags`, `type`, external-content mode pointing at `memories.rowid`.
|
|
65
|
-
- Triggers keep `memories_fts` in sync on insert/update/delete.
|
|
66
|
-
|
|
67
|
-
## Scoping
|
|
68
|
-
|
|
69
|
-
- **user** scope key: literal `"user"`
|
|
70
|
-
- **project** scope key: `project__<sanitized-name>__<12-hex-of-sha256>`
|
|
71
|
-
- Seed: normalized git origin URL if present, else absolute cwd path (lowercased)
|
|
72
|
-
- Same repo across machines -> same key, so memories can be `git`-committed (future)
|
|
73
|
-
|
|
74
|
-
### Scope-key drift and migration
|
|
75
|
-
|
|
76
|
-
Because the scope key changes when a repo's git origin appears or changes,
|
|
77
|
-
memories captured *before* a remote was added end up orphaned under the
|
|
78
|
-
cwd-based key. The plugin logs a one-shot warning at load when it detects
|
|
79
|
-
this, and ships two CLI commands to resolve it:
|
|
80
|
-
|
|
81
|
-
- `cli scopes` — list every project scope directory with a file count.
|
|
82
|
-
- `cli migrate --from <old> [--to <current>] [--dry-run] [--on-conflict newer|overwrite|skip]`
|
|
83
|
-
— rewrites `scope_key` in each file's frontmatter, moves the files, and
|
|
84
|
-
reindexes. Default conflict strategy is "keep the newer `updated_at`".
|
|
85
|
-
|
|
86
|
-
Auto-migration on load is intentionally *not* done: two unrelated repos at
|
|
87
|
-
the same cwd would silently merge.
|
|
88
|
-
|
|
89
|
-
## Capture mechanisms
|
|
90
|
-
|
|
91
|
-
MVP:
|
|
92
|
-
1. **Explicit tool call** — agent calls `memory_add`.
|
|
93
|
-
2. **Keyword trigger** — regex against the user message parts. Captured group 1 becomes the memory body. Defaults: `remember`, `note that`, `TIL`, `save this`.
|
|
94
|
-
|
|
95
|
-
Not in MVP:
|
|
96
|
-
3. Auto-capture every N turns via LLM summary.
|
|
97
|
-
4. LLM-decided "should I recall?" per turn (supermemory's reasoned-recall).
|
|
98
|
-
|
|
99
|
-
## Retrieval
|
|
100
|
-
|
|
101
|
-
MVP: **BM25 via SQLite FTS5.** Query is tokenized, punctuation stripped, each token becomes a prefix match. `bm25(memories_fts)` used as the score. Snippets via `snippet(memories_fts, ...)`.
|
|
102
|
-
|
|
103
|
-
Not in MVP:
|
|
104
|
-
- Local embeddings (Transformers.js `Xenova/bge-small-en-v1.5`) + sqlite-vec.
|
|
105
|
-
- Reciprocal Rank Fusion across BM25 + vector lanes.
|
|
106
|
-
- Cross-encoder rerank (FastEmbed jina-reranker-tiny).
|
|
107
|
-
|
|
108
|
-
## Context injection
|
|
109
|
-
|
|
110
|
-
On the first `experimental.chat.system.transform` for each session, push a
|
|
111
|
-
`[OPEN-MEMEX]` block containing:
|
|
112
|
-
- User profile / preferences (top N from `user` scope)
|
|
113
|
-
- Project knowledge (top N from current project scope, newest first)
|
|
114
|
-
|
|
115
|
-
Injected once per session per plugin load (in-memory set keyed by sessionID).
|
|
116
|
-
|
|
117
|
-
## Redaction
|
|
118
|
-
|
|
119
|
-
Applied to any content on the way in (tool + keyword capture):
|
|
120
|
-
1. Strip `<private>...</private>` -> `[REDACTED]`.
|
|
121
|
-
2. Match remaining text against secret regex list (OpenAI, GitHub, AWS, Slack...). If any hit -> refuse write, tell agent to redact.
|
|
122
|
-
|
|
123
|
-
## Config surface
|
|
124
|
-
|
|
125
|
-
`~/.config/opencode/open-memex.jsonc`:
|
|
126
|
-
|
|
127
|
-
```jsonc
|
|
128
|
-
{
|
|
129
|
-
"maxProjectMemories": 8,
|
|
130
|
-
"maxProfileItems": 5,
|
|
131
|
-
"injectOnFirstTurn": true,
|
|
132
|
-
"keywordCaptureEnabled": true,
|
|
133
|
-
"keywordPatterns": ["^\\s*remember...", ...],
|
|
134
|
-
"redactPatterns": ["sk-[A-Za-z0-9]{20,}", ...],
|
|
135
|
-
"logLevel": "info"
|
|
136
|
-
}
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Env overrides:
|
|
140
|
-
- `MY_O_MEMORY_HOME` — override storage root
|
|
141
|
-
- `MY_O_MEMORY_CONFIG` — override config file path
|
|
142
|
-
|
|
143
|
-
## v2 roadmap (in rough priority)
|
|
144
|
-
|
|
145
|
-
1. **Local embeddings** — Xenova/bge-small ONNX via Transformers.js; sqlite-vec table; RRF fuse with BM25.
|
|
146
|
-
2. **Auto-capture** every N turns using the host LLM (opt-in).
|
|
147
|
-
3. **Preemptive compaction hook** — inject memories into `experimental.session.compacting` context.
|
|
148
|
-
4. **Deduplication** on write (hash + fuzzy).
|
|
149
|
-
5. **`/memory-init`** slash command — walk the repo, summarize modules, save.
|
|
150
|
-
6. **Recall receipts** — return which lane matched (BM25 / vector / recency) for debuggability.
|
|
151
|
-
7. **Bitemporal** valid_from/valid_to for facts that supersede each other.
|
|
152
|
-
8. **Admission barrier** — reject retrieved memories from being re-ingested, reject assistant self-writes, block prompt-injection in file content.
|
|
153
|
-
|
|
154
|
-
## v3+
|
|
155
|
-
|
|
156
|
-
- Ollama detection -> use `nomic-embed-text` when present.
|
|
157
|
-
- Cross-encoder rerank.
|
|
158
|
-
- Memory decay / consolidation.
|
|
159
|
-
- Import/export to markdown archives.
|
|
160
|
-
- Optional knowledge-graph layer for entity linking.
|
|
161
|
-
|
|
162
|
-
## Prior art referenced
|
|
163
|
-
|
|
164
|
-
- `opencode-supermemory` — plugin lifecycle, capture heuristics
|
|
165
|
-
- `doobidoo/mcp-memory-service` — local sqlite-vec + BM25 hybrid, decay
|
|
166
|
-
- `memoripy` — RRF, admission barrier, dependency-free hash embeddings
|
|
167
|
-
- `basic-memory` — markdown-first store, obsidian-compatible
|
|
168
|
-
- Anthropic client-managed memory tool pattern — filesystem primitives
|