open-memex 0.2.0-alpha → 0.3.0-alpha.1

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,158 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { paths } from "../paths.js";
4
+ import { parseRawFrontmatter, normalizeFrontmatter, isTaxonomyType, serialize, } from "./markdown.js";
5
+ /**
6
+ * v1 → v2 file migration (V2-DESIGN §19). Explicit only — run via
7
+ * `open-memex migrate --to-v2`. Converts frontmatter in place:
8
+ *
9
+ * - scope rename: v1 `user` → v2 `personal` (scope, scope_key, and the
10
+ * storage dir `memories/user/` → `memories/personal/`)
11
+ * - times: epoch ms → RFC 3339
12
+ * - `priority: N` → `importance: low|normal|high`
13
+ * - `type: instruction` → `type: knowledge` + `role: instruction`
14
+ * - unknown v1 types → `knowledge` (v2 taxonomy, §3.1)
15
+ * - adds `schema_version: 2`, `visibility`, `role`, `status`
16
+ *
17
+ * Legacy `my-o-memory` data roots (§19/D13: `.my-o-memory/` →
18
+ * `.open-memex/`, `~/.my-o-memory/` → `~/.open-memex/`) are merged into the
19
+ * current root; the old dir is kept as a dated backup.
20
+ */
21
+ const V2_MARKER = /^schema_version:\s*2(\s|$)/m;
22
+ export function isV2File(raw) {
23
+ return V2_MARKER.test(raw);
24
+ }
25
+ /**
26
+ * Plan the v1 → v2 conversion of one file. Returns null when the file is
27
+ * already v2 (nothing to do). Pure — no disk writes.
28
+ */
29
+ export function planConversion(filePath, memoriesRoot) {
30
+ const raw = fs.readFileSync(filePath, "utf8");
31
+ if (isV2File(raw))
32
+ return null;
33
+ const parsed = parseRawFrontmatter(raw);
34
+ if (!parsed)
35
+ return null;
36
+ const { rawFm, body } = parsed;
37
+ const fm = normalizeFrontmatter(rawFm);
38
+ const changes = [];
39
+ // scope rename
40
+ if (rawFm.scope_kind === "user" || rawFm.scope_key === "user") {
41
+ changes.push("scope user → personal");
42
+ }
43
+ else if (!rawFm.scope) {
44
+ changes.push(`scope → ${fm.scope} (default)`);
45
+ }
46
+ // storage dir move follows scope_key
47
+ const toPath = path.join(memoriesRoot, fm.scope_key, path.basename(filePath));
48
+ if (toPath !== filePath) {
49
+ changes.push(`file moved: ${path.basename(path.dirname(filePath))}/ → ${fm.scope_key}/`);
50
+ }
51
+ // visibility default
52
+ if (!rawFm.visibility) {
53
+ changes.push(`visibility → ${fm.visibility} (default)`);
54
+ }
55
+ // type taxonomy
56
+ const rawType = rawFm.type;
57
+ if (rawType === "instruction") {
58
+ changes.push("type instruction → knowledge, role → instruction (D11)");
59
+ }
60
+ else if (typeof rawType === "string" && rawType && !isTaxonomyType(rawType)) {
61
+ changes.push(`type "${rawType}" → knowledge (not in v2 taxonomy)`);
62
+ fm.type = "knowledge";
63
+ }
64
+ else if (!rawType) {
65
+ changes.push(`type → ${fm.type} (default)`);
66
+ }
67
+ // priority → importance
68
+ if (typeof rawFm.priority === "number") {
69
+ changes.push(`priority ${rawFm.priority} → importance ${fm.importance}`);
70
+ }
71
+ else if (!rawFm.importance) {
72
+ changes.push(`importance → ${fm.importance} (default)`);
73
+ }
74
+ // times
75
+ if (typeof rawFm.created_at === "number") {
76
+ changes.push("created_at/updated_at: epoch ms → RFC 3339");
77
+ }
78
+ // status default
79
+ if (!rawFm.status) {
80
+ changes.push(`status → ${fm.status} (default)`);
81
+ }
82
+ changes.push("schema_version → 2");
83
+ return { fm, body, fromPath: filePath, toPath, changes };
84
+ }
85
+ /**
86
+ * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
87
+ * The old dir is renamed to a dated backup — never deleted. Returns the
88
+ * backup path, or null when no legacy dir exists.
89
+ */
90
+ function migrateLegacyDataRoot(dryRun) {
91
+ const { root } = paths();
92
+ const legacy = path.join(path.dirname(root), "my-o-memory");
93
+ if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory())
94
+ return null;
95
+ const stamp = new Date().toISOString().slice(0, 10);
96
+ const backup = `${legacy}.backup-${stamp}`;
97
+ if (!dryRun) {
98
+ const legacyMem = path.join(legacy, "memories");
99
+ if (fs.existsSync(legacyMem)) {
100
+ for (const entry of fs.readdirSync(legacyMem)) {
101
+ const src = path.join(legacyMem, entry);
102
+ const dst = path.join(root, "memories", entry);
103
+ if (!fs.existsSync(dst)) {
104
+ fs.renameSync(src, dst);
105
+ }
106
+ else {
107
+ // merge file-by-file, never overwrite
108
+ for (const name of fs.readdirSync(src)) {
109
+ const s = path.join(src, name);
110
+ const d = path.join(dst, name);
111
+ if (!fs.existsSync(d))
112
+ fs.renameSync(s, d);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ fs.renameSync(legacy, backup);
118
+ }
119
+ return backup;
120
+ }
121
+ export function migrateV2(opts) {
122
+ const { memories: memoriesRoot } = paths();
123
+ const result = {
124
+ scanned: 0,
125
+ converted: 0,
126
+ skippedV2: 0,
127
+ plans: [],
128
+ legacyBackup: null,
129
+ };
130
+ result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
131
+ if (!fs.existsSync(memoriesRoot))
132
+ return result;
133
+ for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
134
+ if (!entry.isDirectory())
135
+ continue;
136
+ const dir = path.join(memoriesRoot, entry.name);
137
+ for (const name of fs.readdirSync(dir)) {
138
+ if (!name.endsWith(".md"))
139
+ continue;
140
+ const filePath = path.join(dir, name);
141
+ result.scanned++;
142
+ const plan = planConversion(filePath, memoriesRoot);
143
+ if (!plan) {
144
+ result.skippedV2++;
145
+ continue;
146
+ }
147
+ if (!opts.dryRun) {
148
+ fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
149
+ fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
150
+ if (plan.toPath !== plan.fromPath)
151
+ fs.unlinkSync(plan.fromPath);
152
+ }
153
+ result.converted++;
154
+ result.plans.push(plan);
155
+ }
156
+ }
157
+ return result;
158
+ }
@@ -0,0 +1,40 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+ import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, } from "./ops.js";
3
+ export function makeTools(getScope, cfg) {
4
+ const memory_add = tool({
5
+ description: TOOL_DESCRIPTIONS.memory_add,
6
+ args: memoryAddArgs,
7
+ async execute(args) {
8
+ return addMemory(getScope, cfg, args);
9
+ },
10
+ });
11
+ const memory_search = tool({
12
+ description: TOOL_DESCRIPTIONS.memory_search,
13
+ args: memorySearchArgs,
14
+ async execute(args) {
15
+ return searchMemories(getScope, args);
16
+ },
17
+ });
18
+ const memory_list = tool({
19
+ description: TOOL_DESCRIPTIONS.memory_list,
20
+ args: memoryListArgs,
21
+ async execute(args) {
22
+ return listMemories(getScope, args);
23
+ },
24
+ });
25
+ const memory_supersede = tool({
26
+ description: TOOL_DESCRIPTIONS.memory_supersede,
27
+ args: memorySupersedeArgs,
28
+ async execute(args) {
29
+ return supersedeMemory(cfg, args);
30
+ },
31
+ });
32
+ const memory_forget = tool({
33
+ description: TOOL_DESCRIPTIONS.memory_forget,
34
+ args: memoryForgetArgs,
35
+ async execute(args) {
36
+ return forgetMemory(args);
37
+ },
38
+ });
39
+ return { memory_add, memory_search, memory_list, memory_forget, memory_supersede };
40
+ }
@@ -0,0 +1,186 @@
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 { PERSONAL_SCOPE } from "../scope.js";
11
+ import { search, list } from "../retrieve/search.js";
12
+ import { writeMemoryFile, deleteMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
13
+ import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
14
+ import { findDuplicates, supersede } from "../store/lifecycle.js";
15
+ import { db } from "../store/db.js";
16
+ import { redact } from "../redact.js";
17
+ /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
18
+ export const TOOL_DESCRIPTIONS = {
19
+ memory_add: "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.",
20
+ memory_search: "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.",
21
+ memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
22
+ memory_supersede: "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.",
23
+ memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
24
+ };
25
+ /** Shared zod input shapes (raw shape, not z.object — hosts wrap as needed). */
26
+ export const scopeArg = z
27
+ .enum(["project", "personal", "user"])
28
+ .optional()
29
+ .describe("Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.");
30
+ export const memoryAddArgs = {
31
+ content: z.string().min(1).describe("The fact to remember. One idea per memory."),
32
+ type: z
33
+ .enum(MEMORY_TYPE_TAXONOMY)
34
+ .optional()
35
+ .describe("Category of memory. Default: note."),
36
+ scope: scopeArg,
37
+ tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
38
+ };
39
+ export const memorySearchArgs = {
40
+ query: z
41
+ .string()
42
+ .min(1)
43
+ .describe("Free-text query. File paths, error strings, identifiers work well."),
44
+ scope: z
45
+ .enum(["project", "personal", "user", "both"])
46
+ .optional()
47
+ .describe("Which scope(s) to search. Default: both."),
48
+ type: z.string().optional().describe("Restrict to memories of this type."),
49
+ limit: z.number().int().min(1).max(50).optional(),
50
+ };
51
+ export const memoryListArgs = {
52
+ scope: scopeArg,
53
+ type: z.string().optional(),
54
+ limit: z.number().int().min(1).max(100).optional(),
55
+ };
56
+ export const memorySupersedeArgs = {
57
+ id: z.string().min(1).describe("ID of the memory being replaced."),
58
+ content: z.string().min(1).describe("The new, corrected content."),
59
+ type: z
60
+ .enum(MEMORY_TYPE_TAXONOMY)
61
+ .optional()
62
+ .describe("Category of the new memory. Defaults to the old memory's type."),
63
+ tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
64
+ };
65
+ export const memoryForgetArgs = {
66
+ id: z.string().min(1),
67
+ };
68
+ function resolveScope(getScope, kind) {
69
+ return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
70
+ }
71
+ function buildFrontmatter(s, body) {
72
+ const rfc = msToRfc3339(Date.now());
73
+ return {
74
+ id: ulid(),
75
+ schema_version: 2,
76
+ scope_key: s.key,
77
+ scope: s.kind === "project" ? "project" : "personal",
78
+ visibility: s.kind === "project" ? "internal" : "private",
79
+ project_name: s.projectName,
80
+ type: body.type,
81
+ role: "knowledge",
82
+ importance: "normal",
83
+ status: "active",
84
+ tags: body.tags,
85
+ source: body.source,
86
+ created_at: rfc,
87
+ updated_at: rfc,
88
+ supersedes: null,
89
+ superseded_by: null,
90
+ };
91
+ }
92
+ export async function addMemory(getScope, cfg, args) {
93
+ const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
94
+ const secretNote = hadSecret
95
+ ? `\nNote: content matched a secret pattern (${matchedPattern}); it was saved with the secret masked (first 4 chars kept, rest replaced with x).`
96
+ : "";
97
+ const s = resolveScope(getScope, args.scope);
98
+ // Dedup on write (§3.4): identical content is idempotent; near-duplicates
99
+ // are reported so the caller can supersede instead of duplicating.
100
+ const dups = findDuplicates(s.key, redacted);
101
+ if (dups.exact) {
102
+ return {
103
+ title: "memory: already exists",
104
+ output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
105
+ };
106
+ }
107
+ const fm = buildFrontmatter(s, {
108
+ type: args.type ?? "fact",
109
+ tags: args.tags ?? [],
110
+ source: "tool",
111
+ });
112
+ const { filePath } = writeMemoryFile(fm, redacted);
113
+ const mf = readMemoryFile(filePath);
114
+ if (mf)
115
+ upsertFromFile(mf);
116
+ let output = `Saved to ${s.kind} scope (${s.key}) as ${fm.type}. id=${fm.id}${secretNote}`;
117
+ for (const n of dups.near) {
118
+ output += `\nNote: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id} — ${n.snippet}. Use memory_supersede if this replaces it.`;
119
+ }
120
+ return { title: `memory: saved ${fm.id}`, output };
121
+ }
122
+ export async function searchMemories(getScope, args) {
123
+ const project = getScope();
124
+ const scopeKeys = args.scope === "personal" || args.scope === "user"
125
+ ? [PERSONAL_SCOPE.key]
126
+ : args.scope === "project"
127
+ ? [project.key]
128
+ : [project.key, PERSONAL_SCOPE.key];
129
+ const hits = search(args.query, {
130
+ scopeKeys,
131
+ limit: args.limit,
132
+ type: args.type,
133
+ });
134
+ if (hits.length === 0) {
135
+ return {
136
+ title: "memory: 0 results",
137
+ output: `No memories matched "${args.query}".`,
138
+ };
139
+ }
140
+ const lines = hits.map((h) => `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`);
141
+ return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
142
+ }
143
+ export async function listMemories(getScope, args) {
144
+ const s = resolveScope(getScope, args.scope);
145
+ const hits = list(s.key, { type: args.type, limit: args.limit });
146
+ if (hits.length === 0) {
147
+ return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
148
+ }
149
+ const lines = hits.map((h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
150
+ return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
151
+ }
152
+ export async function supersedeMemory(cfg, args) {
153
+ const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
154
+ const secretNote = hadSecret ? ` (secret masked: ${matchedPattern})` : "";
155
+ try {
156
+ const { oldMf, newMf } = supersede(args.id, {
157
+ body: redacted,
158
+ type: args.type,
159
+ tags: args.tags,
160
+ source: "tool",
161
+ });
162
+ upsertFromFile(oldMf);
163
+ upsertFromFile(newMf);
164
+ return {
165
+ title: `memory: superseded ${oldMf.fm.id}`,
166
+ output: `Replaced ${oldMf.fm.id} with ${newMf.fm.id} (old kept as history).${secretNote}`,
167
+ };
168
+ }
169
+ catch (e) {
170
+ return {
171
+ title: "memory: supersede failed",
172
+ output: e.message,
173
+ };
174
+ }
175
+ }
176
+ export async function forgetMemory(args) {
177
+ const row = db()
178
+ .prepare(`SELECT scope_key FROM memories WHERE id = ?`)
179
+ .get(args.id);
180
+ if (!row) {
181
+ return { title: "memory: not found", output: `No memory with id ${args.id}.` };
182
+ }
183
+ deleteMemoryFile(row.scope_key, args.id);
184
+ deleteFromIndex(args.id);
185
+ return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
186
+ }
package/docs/V2-DESIGN.md CHANGED
@@ -40,6 +40,11 @@ Decisions log; Prior Art; solo-dev adoption path.
40
40
  - **MCP is an interface, not the identity.** MCP / CLI / REST / SDK are access layers over the protocol,
41
41
  so the project is never locked to one transport or one agent tool (opencode, VS Code Copilot, Cursor,
42
42
  Claude Code, Windsurf, …).
43
+ - **Company lens:** at organizational scale the same pain is tribal knowledge — senior engineers'
44
+ hard-won experience evaporates when they move on, and every incident gets re-debugged by someone
45
+ new. The current phase therefore prioritizes *capture*: valuable knowledge must land in memory
46
+ first, because team/org sharing, onboarding, and incident learning all build on that foundation.
47
+ (No capture, nothing to inherit.)
43
48
 
44
49
  ### Non-goals
45
50
 
@@ -287,7 +292,7 @@ instructions are never candidates.)
287
292
  | Explicit tools | `memory_add/update/forget` (soft delete) · `search/get/list/status` · `propose/promote` · `resolve`. Write tools confirm with the user; read tools are open. |
288
293
  | Keyword triggers | `remember …`, `note that …`, `TIL …`, `save this: …` + Chinese `记住` `记得` `保存一下` … |
289
294
  | Implicit (opt-in) | end-of-session "should I remember X?"; implicit captures default to `confidence: low` and appear in a separate list view for batch cleanup (regret window). |
290
- | Redaction (hard) | `<private>…</private>` stripped; secret patterns refuse the write; pre-commit hook scans shared scopes. |
295
+ | Redaction (hard) | `<private>…</private>` stripped; secret patterns are **masked in place** (first 4 chars kept, rest → `x`) and the write proceeds (D14); pre-commit hook scans shared scopes. |
291
296
 
292
297
  ---
293
298
 
@@ -410,11 +415,47 @@ Zero-config is survival for an open-source project. The opencode plugin remains
410
415
  VS Code MCP-server support; corporate Copilot local-tool support. **Hard gate before Phase 2.**
411
416
  - **Phase 1 — Local hardening (1–2 wks).** CJK default (bigram+FTS5) · v1→v2 migration · dedup +
412
417
  lifecycle · redaction hardening · scope docs. No external dependencies.
413
- - **Phase 2A — Read-only MCP.** Core/adapters split · MCP server (search/get/list/status) ·
414
- query-aware injection.
418
+ - **Phase 2A — MCP server (shipped 2026-09-27, D15).** Core/adapters split
419
+ (`src/tools/ops.ts`) · MCP server (`src/mcp.ts`, stdio) exposing all five memory tools —
420
+ read-only-first phasing dropped per D15 · query-aware injection stays host-side.
421
+ Ships in **`0.3.0-alpha`** (with bin/npx user-friendliness polish per §17 adoption path).
415
422
  - **Phase 2B — Team sync.** GitProvider · `propose/promote/resolve` · in-repo dir · 1–2 colleague pilot
416
423
  (pilot project selection is maintainer-private, not tracked in this doc).
424
+ Ships in **`0.3.0-beta`**.
417
425
  Embeddings/rerank run as a **parallel benchmark-gated experiment**, not on the critical path.
426
+ - **Phase 2C — Native agent plugins (candidates, not committed).** Claude Code plugin and/or
427
+ Codex plugin as hook-enhanced paths over the same MCP tool surface (`SessionStart` →
428
+ context injection, `UserPromptSubmit` → keyword-triggered search, `Stop`/`PostToolUse` →
429
+ capture); per D16, no host-specific extraction intelligence — opencode is likewise
430
+ supported as a plain MCP consumer. Gated on real-world signal from 0.3.0-alpha MCP
431
+ dogfooding.
432
+
433
+ ### Agent integration matrix
434
+
435
+ | Agent | Integration path | Native hooks? | Status |
436
+ |---|---|---|---|
437
+ | opencode | native plugin (`src/index.ts`) | ✅ keyword capture + first-turn injection | shipped (Phase 1) |
438
+ | VS Code Copilot | MCP server + `.github/copilot-instructions.md` | ❌ — VS Code extension API cannot intercept Copilot Chat (researched 2026-09-27); an extension would add no hook capability, so not worth building | ships `0.3.0-alpha` |
439
+ | Cursor | MCP server + rules | ❌ no chat plugin API | ships `0.3.0-alpha` |
440
+ | Claude Code | MCP server today; plugin + hooks candidate | ✅ `SessionStart` / `UserPromptSubmit` / `PostToolUse` | Phase 2C candidate |
441
+ | Codex (CLI/IDE) | MCP server (`[mcp_servers]` in config.toml / `codex mcp add`) today; plugin + hooks + marketplace candidate | ✅ hooks mirror Claude Code's | Phase 2C candidate |
442
+
443
+ ### Competitive landscape (for future positioning)
444
+
445
+ Coding-agent memory is crowded; open-memex's wedge is **zero-cloud, zero-account,
446
+ zero-embedding-download**, with repo-native markdown as source of truth (maintainer
447
+ requirement: personal data never touches third-party services). Benchmarks to track:
448
+
449
+ | Product | Scale / backing (Sep 2026) | Shape | Gap vs open-memex |
450
+ |---|---|---|---|
451
+ | Mem0 | ~50k+★, $24M Series A (YC) | universal memory SDK/API, vector+graph, cloud-first | cloud dependency; not repo-native for coding agents |
452
+ | Letta (ex-MemGPT) | ~24k★, $10M seed | stateful agent platform, memory blocks | agent runtime, not a drop-in coding-agent memory |
453
+ | Zep / Graphiti | ~20–30k★, $12M seed | temporal knowledge graph, enterprise | heavy infra; overkill as a coding vault |
454
+ | Cognee | ~15–30k★, $7.5M seed | graph ECL pipelines | ingest-oriented, no coding-agent hooks |
455
+ | Supermemory | ~15k★, $2.6M seed | consumer second-brain + SaaS API | cloud SaaS |
456
+ | atlaso-labs/codex | Codex marketplace | long-term memory plugin for Codex (hooks + MCP + cloud-sync upsell) | **direct comparable** for a future Codex plugin; their cloud upsell vs our local-first |
457
+
458
+ (Star counts / funding as of Sep 2026 — re-verify before quoting publicly.)
418
459
  - **Phase 3 — Org layer.** Org memory repo · curator convention · `examples/remote-server/` ·
419
460
  distill-to-AGENTS.md assist.
420
461
  - **Phase 4 — Future, signal-gated.** Cloud `RemoteProvider` customization only on: multi-private-repo
@@ -474,6 +515,78 @@ Zero-config is survival for an open-source project. The opencode plugin remains
474
515
  `autoPull: true`, a failed pull never blocks the session and every pull emits a receipt.
475
516
  *Rationale: a memory pull can change agent behavior, so it must be a deliberate, visible act —
476
517
  predictable offline-first beats silent freshness. Unanimous 5/5 in round-4 AI review, 2026-09-26.*
518
+ - **D14** — Secret detection masks instead of refusing. A write-path secret hit is **masked in
519
+ place** (first 4 characters kept, the rest replaced with `x`, length-preserving) and the write
520
+ proceeds with a notice; `<private>…</private>` spans are still stripped to `[REDACTED]`.
521
+ Amends the v0.2 rule "secret patterns refuse the write" (§8, §11). *Rationale: a refused write
522
+ loses the surrounding context the user asked to remember; a prefix-masked secret stays
523
+ recognizable (which key it was) while the credential itself is not recoverable from the file.
524
+ Supersedes the refusal behavior; applies to every write path (tools, keyword capture, CLI).
525
+ 2026-09-27.*
526
+ - **D15** — Phase 2A MCP server ships with all five tools, not read-only first. The MCP server
527
+ (`src/mcp.ts`, stdio) exposes `memory_add` / `memory_search` / `memory_list` /
528
+ `memory_supersede` / `memory_forget` — amends the §18 roadmap's "Read-only MCP" phasing.
529
+ *Rationale: the write path is the same Core (redact/D14, dedup, lifecycle) already shipped and
530
+ dogfooded in the opencode plugin, so a separate read-only stage adds process cost without
531
+ reducing risk. Core/adapters split implemented as `src/tools/ops.ts` (host-agnostic logic +
532
+ shared zod schemas); the opencode plugin and the MCP server are thin adapters over it.
533
+ Query-aware injection stays host-side: MCP is request/response and offers no hooks, so
534
+ proactive memory use depends on the host's agent instructions. 2026-09-27.*
535
+ - **D16** — No separate LLM extraction pass; memory intelligence lives in model-driven tool
536
+ calls. A dedicated post-session extraction (opencode `session.idle` hook → hidden session
537
+ → host model) was evaluated and rejected: the model's own decision to call `memory_add`
538
+ *is* the LLM judgment of "worth remembering", so a second pass is redundant and
539
+ host-specific. Investment goes into the shared layer instead — `TOOL_DESCRIPTIONS` in
540
+ `src/tools/ops.ts` and each host's agent instructions — so every host benefits at once.
541
+ Native plugins (opencode now; Claude Code / Codex as Phase 2C candidates) remain as
542
+ hook-enhanced paths, but opencode is also supported as a plain MCP consumer of
543
+ `open-memex mcp`, keeping one unified tool surface. 2026-09-27.
544
+ - **D17** — `open-memex init` resolves the MCP server command at init time. A durable
545
+ `open-memex` on PATH (outside npm's ephemeral `_npx` cache) → `command: "open-memex"`;
546
+ otherwise (one-shot `npx open-memex@alpha init`) → `command: "npx", args: ["-y",
547
+ "open-memex@alpha", "mcp"]` plus a hint to `npm i -g` + re-run `init --force`.
548
+ `mcp --print-config` uses the same resolution. *Rationale: a one-shot npx run leaves
549
+ no bin behind, so writing `command: "open-memex"` would produce a dead MCP server on
550
+ the next editor launch; the npx fallback keeps the one-command setup actually
551
+ one-command. 2026-09-27.*
552
+
553
+ - **D18** — Keyword scope routing: 我 → personal, 我们 → project. Chinese capture
554
+ keywords are split into two pattern lists: personal patterns (`记住我`/`替我记`/
555
+ `我觉得`/`我喜欢`, plus legacy `remember for me`/`记住(个人)`) route to the personal
556
+ scope, while project patterns (`我们认为`/`我们决定`/`帮我们记住`, and the generic
557
+ `记住…` for `记住我们的…`) route to the current project scope. Personal patterns are
558
+ scanned first and *claim* the line so the generic `记住…` pattern cannot double-fire;
559
+ `记住我` uses a `(?!们)` guard so it never swallows `记住我们…`. *Rationale: the
560
+ user's own rule — "我" is personal, "我们" is the current project — stated 2026-09-27;
561
+ scanning user messages (never assistant output) with personal-first claim keeps one
562
+ utterance to one memory. README + repo AGENTS.md keyword sections updated in the same
563
+ commit. 2026-09-27.*
564
+ - **D19** — `open-memex init` asks setup questions; `open-memex config set` edits settings
565
+ after install. `init` prompts on a TTY (editor: vscode/cursor/opencode; keyword
566
+ auto-capture on/off; first-turn injection on/off), `--yes` accepts all defaults, and
567
+ non-terminal runs never prompt (scripts keep the historical vscode default).
568
+ Non-default answers persist to the JSONC config file; `open-memex config set <key>
569
+ <value>` changes them later (validated keys: `maxProjectMemories`, `maxProfileItems`,
570
+ `injectOnFirstTurn`, `keywordCaptureEnabled`, `logLevel`). `init --client opencode`
571
+ merges a `type: "local"` MCP entry into project-level `opencode.jsonc` (v1 format).
572
+ *Rationale: install time is the only moment the user's attention is guaranteed, and a
573
+ print-only `config` left no path to change settings afterwards. 2026-09-27.*
574
+ - **D20** — `init` / `mcp --print-config` support Visual Studio. Writes solution-level
575
+ `.mcp.json` with the `"servers"` section (`{ "type": "stdio", "command", "args" }`),
576
+ per Microsoft Learn (VS 2022 17.14+ / VS 2026, Windows-only). `.github/copilot-
577
+ instructions.md` is still written — VS's Copilot reads it too. Note VS also
578
+ auto-discovers `.vscode/mcp.json` and `.cursor/mcp.json`, so repos already set up for
579
+ VS Code get VS support for free; the explicit `.mcp.json` is the source-controllable
580
+ option. 2026-09-27.*
581
+ - **D21** — The published npm package ships pre-compiled JS (`tsc -p tsconfig.build.json`
582
+ → `dist/`, via `prepublishOnly`; bin points at `dist/cli.js`). *Rationale: Node's
583
+ `--experimental-strip-types` refuses files under `node_modules`
584
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so the old launcher
585
+ (`bin/open-memex.js` re-execing `src/cli.ts`) crashed on every global install —
586
+ reported 2026-09-27 on Node v22.12.0. Development stays build-free (`npm run cli`
587
+ / `npm run mcp` run `src/` directly); `doctor`'s MCP self-check resolves its server
588
+ entry the same way it is running (`dist/mcp.js` vs `src/mcp.ts`). The opencode
589
+ native plugin still loads `src/index.ts` (Bun strips types anywhere). 2026-09-27.*
477
590
 
478
591
  ## Open Questions
479
592
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.2.0-alpha",
3
+ "version": "0.3.0-alpha.1",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -15,11 +15,16 @@
15
15
  "main": "src/index.ts",
16
16
  "scripts": {
17
17
  "typecheck": "tsc --noEmit",
18
- "cli": "node --experimental-strip-types src/cli.ts"
18
+ "cli": "node --experimental-strip-types src/cli.ts",
19
+ "mcp": "node --experimental-strip-types src/mcp.ts",
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "prepublishOnly": "npm run build"
19
22
  },
20
23
  "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.30.1",
21
25
  "better-sqlite3": "^11.7.0",
22
- "js-yaml": "^4.1.0"
26
+ "js-yaml": "^4.1.0",
27
+ "zod": "^4.1.8"
23
28
  },
24
29
  "devDependencies": {
25
30
  "@opencode-ai/plugin": "^1.18.30",
@@ -27,5 +32,11 @@
27
32
  "@types/js-yaml": "^4.0.9",
28
33
  "@types/node": "^22.0.0",
29
34
  "typescript": "^5.6.0"
35
+ },
36
+ "bin": {
37
+ "open-memex": "dist/cli.js"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.6"
30
41
  }
31
42
  }