open-memex 0.1.0 → 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.
@@ -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