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.
@@ -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
- writeMemoryFile,
8
- deleteMemoryFile,
9
- readMemoryFile,
10
- ulid,
11
- msToRfc3339,
12
- MEMORY_TYPE_TAXONOMY,
13
- type Frontmatter,
14
- } from "../store/markdown.ts";
15
- import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
16
- import { findDuplicates, supersede } from "../store/lifecycle.ts";
17
- import { db } from "../store/db.ts";
18
- import { redact } from "../redact.ts";
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
- "Save a fact, preference, decision, or note to persistent local memory. Call this whenever the user tells you something you should remember in future sessions (project conventions, tool choices, personal preferences, error fixes). Keep each memory to one self-contained statement.",
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
- const { content: redacted, hadSecret, matchedPattern } = redact(
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
- "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Use before asking the user something they may have told you before.",
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
- const project = getScope();
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: "List memories in a scope, newest first. Useful for browsing.",
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
- const s = resolveScope(args.scope);
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
- "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.",
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
- const { content: redacted, hadSecret, matchedPattern } = redact(
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: "Delete a memory by id. Use when the user asks to forget something.",
218
- args: {
219
- id: z.string().min(1),
220
- },
52
+ description: TOOL_DESCRIPTIONS.memory_forget,
53
+ args: memoryForgetArgs,
221
54
  async execute(args) {
222
- const row = db()
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
 
@@ -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