open-memex 0.6.2 → 0.7.0-alpha.2

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 CHANGED
@@ -18,7 +18,7 @@ via `prepublishOnly` — Node refuses `--experimental-strip-types` for files und
18
18
 
19
19
  Tool logic is host-agnostic and lives in `src/tools/ops.ts` (plain functions + shared zod arg shapes + `TOOL_DESCRIPTIONS`). `src/tools/memory.ts` (opencode) and `src/mcp.ts` are thin adapters — when adding or changing a tool, change `ops.ts` once and both hosts pick it up.
20
20
 
21
- **Plugin code must not runtime-import `@opencode-ai/plugin`** (D58): it is a devDependency, and opencode does not resolve it for `file://` plugins loaded from a global npm install — the import fails and opencode silently skips the plugin (no error, no tools). Type-only imports are fine. `src/tools/memory.ts` carries a local stand-in for the SDK's `tool()` (a runtime identity) for exactly this reason.
21
+ **Plugin code must not runtime-import a host SDK package** (D58, extended by D60): `@opencode-ai/plugin` (v1) and `@opencode/plugin` (v2) are devDependencies, and neither host resolves them for plugins loaded from a global npm install — the import fails and the host silently skips the plugin (no error, no tools). Type-only imports are fine. `src/tools/memory.ts` carries a local stand-in for the v1 SDK's `tool()` (a runtime identity), and `src/opencode-v2.ts` duck-types the v2 context, for exactly this reason. The plugin entry is a dual export (`{ id, setup, server }`, D60): v1 ≥1.18.29 calls `server`, opencode 2 calls `setup`; both adapt `src/plugin-core.ts`.
22
22
 
23
23
  Consequences:
24
24
  - Imports **must** use explicit `.ts` extensions (`allowImportingTsExtensions: true`, `moduleResolution: "Bundler"`).
@@ -65,7 +65,8 @@ at init time — npx fallback when no durable bin is on PATH, D17),
65
65
  `open-memex config` prints the effective config, `open-memex capture --dry-run "text"`
66
66
  previews keyword capture without writing, `open-memex doctor` runs health checks
67
67
  (node version, config, scope resolution, storage writability, SQLite locking,
68
- opencode plugin entry (target exists + no host-SDK runtime imports, D59),
68
+ opencode plugin entries for both host generations (target exists + no
69
+ host-SDK runtime imports, D59/D60),
69
70
  VS Code MCP enablement, MCP handshake).
70
71
  `open-memex init` with no --client auto-detects and wires every installed editor
71
72
  (`--yes` skips, scripts never prompt); `open-memex config set <key> <value>` edits settings after install.
@@ -136,6 +137,15 @@ Every write path (tool, keyword hook, CLI `add`) must:
136
137
  3. Check `findDuplicates` (design §3.4): identical content is idempotent (return existing id); near-duplicates (similarity ≥ 0.8) warn but save — suggest `supersede` when the new content replaces the old.
137
138
  4. `writeMemoryFile` first, then `readMemoryFile` + `upsertFromFile` to keep FTS in sync.
138
139
 
140
+ Capture-time aliases (D61): `memory_add`/`supersede` may carry up to 4
141
+ `aliases` (alternate phrasings) into frontmatter; they index in their own FTS
142
+ column. Only when `cfg.captureAliases` is on (init asks once, default on) —
143
+ when off, passed aliases are dropped. Always pass them through
144
+ `normalizeAliases`; the keyword-capture path (no agent in the loop) never
145
+ invents aliases. Checkpoint-proposal guidance (propose, never auto-draft) is
146
+ copy, not mechanism: keep the rubric wording in sync across the skill, the
147
+ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
148
+
139
149
  ## Lifecycle invariants (design §3.3)
140
150
 
141
151
  - Statuses: `active → superseded | deprecated | retracted | archived`. Retrieval excludes `retracted`/`archived`, ranks `active` above `deprecated`.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,28 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Proactive capture: checkpoint proposals and aliases (D61)
13
+
14
+ - At task checkpoints the agent now proposes instead of waiting to be asked: when a task wraps up, it offers 1–3 short candidate memories distilled from the session (decisions and their reasons, conventions, gotchas, approaches tried and abandoned) and saves only the ones you approve — approved inferences are marked `source: "inference"`. One memorability rubric and one search discipline (break the question into concepts, try several phrasings, check both scopes) are written into every guidance surface — the bundled skill, the AGENTS.md memory-hygiene footer, the MCP handshake, and the tool descriptions — so behavior is consistent across editors. Nothing is auto-drafted in the background; that posture waits on memory visibility.
15
+ - Memories can carry up to 4 **aliases** — alternate phrasings, synonyms, other-language equivalents — stored in the memory file and indexed with it, so a differently-worded question still matches ("vacation days" finds the holiday policy; "节假日" too). `init` asks once (default on); `open-memex config set captureAliases false` turns it off. Existing memory files are untouched — the index rebuilds itself on first use.
16
+
17
+ ## One plugin, both opencode generations (D60)
18
+
19
+ - The native plugin now loads on **opencode 2** as well as opencode 1. `open-memex init --client opencode --global` writes both config spellings — the v1 `"plugin"` file entry and the v2 `"plugins"` directory entry — and each host reads its own. Previously an opencode 2 host silently skipped the plugin (a file entry is not a directory it can load), leaving no tools and no explanation.
20
+ - Tools register with code mode off (`options.codemode: false`): on opencode 2 an unflagged plugin tool is only reachable through a JS `execute` meta-tool, and models that call it by name get "Unknown tool" — with the opt-out, the five tools are ordinary function calls on both generations.
21
+ - On the opencode 1 side this raises the minimum host version to **1.18.29**: the dual entrypoint is a plain exported object, and older v1 hosts only invoke function exports.
22
+ - `open-memex doctor` extends the plugin patrol to both config keys: v2 entries are checked for a resolvable entry file and the same no-host-SDK-runtime-import rule, and when the installed opencode major version disagrees with what the config wires, doctor fails and names the fix.
23
+
24
+ ## [0.6.2] - 2026-10-03
25
+
26
+ ## Doctor patrols the plugin entry (D59)
27
+
28
+ - `open-memex doctor` gains an `opencode plugin` check: it reads the user-level opencode configs, attributes the open-memex plugin entry, and fails when the target file no longer exists (naming the `open-memex init --client opencode --global --force` re-point command) or when the plugin's import closure runtime-imports the host SDK. That second failure is exactly what silently killed the plugin in D58 — it now shows up in a check instead of surfacing as missing tools.
29
+
30
+ ## Notes
31
+
32
+ - 0.6.1 contained D56–D58 but was published before this check landed; 0.6.2 is the recommended upgrade for everyone on the 0.6.x line.
33
+
12
34
  ## [0.6.1] - 2026-10-03
13
35
 
14
36
  ## Question-shaped search, host parity said out loud (D56)
@@ -130,7 +152,8 @@ Install: `npm install -g open-memex`
130
152
 
131
153
  - First public release under the open-memex name (renamed from my-o-memory): local-first memory for AI coding agents — Markdown files as the source of truth, SQLite FTS5 index, opencode plugin + MCP server + CLI.
132
154
 
133
- [Unreleased]: https://github.com/stoneskin/open-memex/compare/v0.6.1...HEAD
155
+ [Unreleased]: https://github.com/stoneskin/open-memex/compare/v0.6.2...HEAD
156
+ [0.6.2]: https://github.com/stoneskin/open-memex/compare/v0.6.1...v0.6.2
134
157
  [0.6.1]: https://github.com/stoneskin/open-memex/compare/v0.6.0...v0.6.1
135
158
  [0.6.0]: https://github.com/stoneskin/open-memex/compare/v0.5.1...v0.6.0
136
159
  [0.5.1]: https://github.com/stoneskin/open-memex/compare/v0.5.0...v0.5.1
package/README.md CHANGED
@@ -247,6 +247,12 @@ project picks it up, no per-project init. You get the 5 core tools, keyword
247
247
  auto-capture, and first-turn context injection. (A config file with comments is
248
248
  left untouched — `init` prints the line to add by hand.)
249
249
 
250
+ Both opencode generations are supported from the same install: `init` writes
251
+ the opencode 1 spelling (`"plugin"`) and the opencode 2 spelling (`"plugins"`)
252
+ — each host reads its own key. Opencode 1 needs version **1.18.29 or newer**
253
+ for this. `open-memex doctor` tells you if the wiring and the installed host
254
+ version don't match.
255
+
250
256
  **opencode** (as a plain MCP consumer):
251
257
 
252
258
  ```sh
@@ -333,7 +339,8 @@ Everything `init` writes, in one place:
333
339
  macOS/Linux — your memory files and the search index. Nothing here is ever
334
340
  modified by `uninstall`.
335
341
  - **Editor wiring** (removed by `open-memex uninstall`):
336
- - opencode: a `"plugin"` entry merged into
342
+ - opencode: a `"plugin"` entry (opencode 1) and a `"plugins"` entry
343
+ (opencode 2) merged into
337
344
  `~/.config/opencode/opencode.json` (or `.jsonc`). Stale `my-o-memory`
338
345
  entries from before the rename are removed at the same time.
339
346
  - VS Code / Cursor: an `open-memex` server entry in the user-level or
@@ -366,7 +373,19 @@ Three ways memories get in:
366
373
  (or it saves on its own when you state a fact worth keeping) — it calls
367
374
  `memory_add`. The routing above is a heuristic; you can always say "save this
368
375
  to my personal memory" or use the CLI with `--scope` to be explicit.
369
- - **The CLI**: `open-memex add "…"` with optional `--scope` / `--tags` / `--type`.
376
+ - **Checkpoint proposals**: when a task wraps up, the agent proposes 1–3 short
377
+ candidate memories distilled from the session — decisions and their reasons,
378
+ conventions, gotchas, approaches tried and abandoned — and saves only the
379
+ ones you approve. Nothing is written silently: no draft is created behind
380
+ your back.
381
+ - **The CLI**: `open-memex add "…"` with optional `--scope` / `--tags` / `--type`
382
+ / `--aliases`.
383
+
384
+ **Aliases.** Each saved memory can carry up to 4 alternate phrasings
385
+ (synonyms, another language's equivalent) that are indexed with it, so a
386
+ question worded differently still finds the memory — "vacation days" finds the
387
+ holiday policy. `init` asks once whether to enable this (default on); turn it
388
+ off any time with `open-memex config set captureAliases false`.
370
389
 
371
390
  **Redaction.** Wrap anything sensitive in `<private>…</private>` and it is
372
391
  stripped before saving. Recognized secrets (API keys, tokens, high-entropy
package/README.zh-CN.md CHANGED
@@ -180,6 +180,8 @@ open-memex init --client opencode --global --yes
180
180
 
181
181
  把原生插件并入你的用户级 `~/.config/opencode/opencode.json`(如果你已有的是 `opencode.jsonc`,就并进那个)——一次设置,每个项目都生效,不用逐项目 init。你会得到 5 个核心工具、关键词自动捕获和第一轮上下文注入。(配置文件里有注释时它不会动,`init` 会打印要手动加的那一行。)
182
182
 
183
+ 同一套安装同时支持两个 opencode 世代:`init` 会同时写入 opencode 1 的写法(`"plugin"`)和 opencode 2 的写法(`"plugins"`),每个宿主只读自己的那把。opencode 1 要求 **1.18.29 或更新版本**。接线和已装宿主版本对不上,`open-memex doctor` 会报出来。
184
+
183
185
  **opencode**(作为普通 MCP 消费者):
184
186
 
185
187
  ```sh
@@ -238,7 +240,7 @@ open-memex uninstall --yes
238
240
 
239
241
  - **记忆数据**(首次使用时创建,不是 `init` 本身写的):Windows 上是 `%APPDATA%\open-memex\`,macOS/Linux 上是 `~/.local/share/open-memex/`——你的记忆文件和搜索索引。`uninstall` 永不修改这里的任何东西。
240
242
  - **编辑器接线**(由 `open-memex uninstall` 移除):
241
- - opencode:一条 `"plugin"` 条目,并入 `~/.config/opencode/opencode.json`(或 `.jsonc`)。改名之前遗留的 `my-o-memory` 旧条目会同时被清掉。
243
+ - opencode:一条 `"plugin"` 条目(opencode 1)和一条 `"plugins"` 条目(opencode 2),并入 `~/.config/opencode/opencode.json`(或 `.jsonc`)。改名之前遗留的 `my-o-memory` 旧条目会同时被清掉。
242
244
  - VS Code / Cursor:用户级或项目级 MCP 配置里的一条 `open-memex` 服务器条目,外加 Copilot 指令文件里的一段 open-memex 内容(默认是用户级 `~/.copilot/copilot-instructions.md`;`--instructions project` 改为写进仓库的 `.github/copilot-instructions.md`,适合团队全员都用 open-memex 的情况)。
243
245
  - Visual Studio:solution 旁边的 `.mcp.json`。
244
246
  - Agent Skill:VS Code(`~/.copilot/skills/`)、Cursor(`~/.cursor/skills/`)或 per-project MCP 模式下的 opencode(`~/.config/opencode/skills/`)各自一个 `skills/open-memex/` 文件夹。
@@ -252,7 +254,10 @@ open-memex uninstall --yes
252
254
 
253
255
  - **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget …`、`TIL …`、`save this …`——中文 `记住…` / `记一下` / `记录一下` / `别忘了…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`记住我…`、`替我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。
254
256
  - **助手主动保存**:在任何编辑器里,你让助手记住某件事(或者你陈述了一个值得记的事实、它自己判断该保存),它会调用 `memory_add`。上面的路由是启发式规则;你可以明说"存到我的个人记忆",或用 CLI 加 `--scope` 明确指定。
255
- - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tags` / `--type`。
257
+ - **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
258
+ - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tags` / `--type` / `--aliases`。
259
+
260
+ **别名。** 每条记忆可以带最多 4 个换一种说法(同义词、另一种语言的对应词),和记忆一起进索引,这样问法不同也能找到——比如搜 "vacation days" 能找到假期政策那条。`init` 时会问一次是否开启(默认开);随时可用 `open-memex config set captureAliases false` 关掉。
256
261
 
257
262
  **脱敏。** 把敏感内容包在 `<private>…</private>` 里,它在保存前会被整段剥离。识别出的密钥(API key、token、高熵凭证)会被就地掩码——保留前 4 个字符(让你能认出是哪个 key),其余替换——记忆仍会保存。随时安全地预览一句话会被捕获成什么样:
258
263
 
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatR
10
10
  import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
11
11
  import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
12
12
  import { search, list, hitStateLabel } from "./retrieve/search.js";
13
- import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
13
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, normalizeAliases, } from "./store/markdown.js";
14
14
  import { loadConfig } from "./config.js";
15
15
  import { paths, projectRoot } from "./paths.js";
16
16
  import { redact } from "./redact.js";
@@ -54,18 +54,25 @@ Example:
54
54
  open-memex search "deploy checklist" --scope both`,
55
55
  add: `Save a fact, preference, decision, or note to local memory.
56
56
 
57
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
57
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
58
58
 
59
59
  Flags:
60
- --scope project (default) or personal (personal never leaves this machine)
61
- --type memory type (default: fact)
62
- --tag comma-separated tags
60
+ --scope project (default) or personal (personal never leaves this machine)
61
+ --type memory type (default: fact)
62
+ --tag comma-separated tags
63
+ --aliases alternate phrasings of this fact (up to 4), indexed with the
64
+ memory so differently-worded searches still match; separate
65
+ with ; (commas also accepted — use ; when an alias itself
66
+ contains one). Stored only when capture aliases are enabled
67
+ (init default)
63
68
 
64
69
  Example:
65
70
  open-memex add "We deploy on Fridays" --scope project --tag process`,
66
71
  supersede: `Replace a memory with a newer version. The old one is kept as history.
67
72
 
68
- Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
73
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
74
+
75
+ Without --aliases, the old memory's aliases carry over.
69
76
 
70
77
  Example:
71
78
  open-memex supersede 01ABC "We deploy on Thursdays now"`,
@@ -849,6 +856,18 @@ async function main() {
849
856
  .map((t) => t.trim())
850
857
  .filter(Boolean)
851
858
  : [],
859
+ // D61: --aliases "phrase one; phrase two" (comma also accepted; use
860
+ // ; when an alias itself contains a comma). A bare --aliases with no
861
+ // value makes parseFlags yield "true" — treat that as absent. Stored
862
+ // only when the install enables capture aliases, redacted like content.
863
+ ...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
864
+ ? {
865
+ aliases: normalizeAliases(flags.aliases
866
+ .split(/[;,]/)
867
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
868
+ .filter(Boolean)),
869
+ }
870
+ : {}),
852
871
  source: "cli",
853
872
  created_at: rfc,
854
873
  updated_at: rfc,
@@ -888,6 +907,12 @@ async function main() {
888
907
  tags: flags.tag
889
908
  ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
890
909
  : undefined,
910
+ aliases: typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
911
+ ? normalizeAliases(flags.aliases
912
+ .split(/[;,]/)
913
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
914
+ .filter(Boolean))
915
+ : undefined,
891
916
  source: "cli",
892
917
  });
893
918
  upsertFromFile(oldMf);
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ export const DEFAULT_CONFIG = {
6
6
  maxProfileItems: 5,
7
7
  injectOnFirstTurn: true,
8
8
  keywordCaptureEnabled: true,
9
+ captureAliases: true,
9
10
  keywordPatterns: [
10
11
  "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
11
12
  "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
@@ -79,6 +80,7 @@ export const SETTABLE_KEYS = {
79
80
  maxProfileItems: toNonNegInt,
80
81
  injectOnFirstTurn: toBool,
81
82
  keywordCaptureEnabled: toBool,
83
+ captureAliases: toBool,
82
84
  memoryDir: toRelativeDir,
83
85
  "sync.autoPull": toBool,
84
86
  logLevel: (v) => {
@@ -57,7 +57,7 @@ export function distillAgentsMarkdown(opts) {
57
57
  // (D53: the checkpoint mechanism is gone).
58
58
  lines.push(`### Memory hygiene (open-memex)`);
59
59
  lines.push(``);
60
- lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it.`);
60
+ lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, ` Worth keeping: decisions and their reasons, preferences, conventions,`, ` gotchas, approaches tried and abandoned. Not worth keeping: one-off task`, ` details or anything re-derivable from the code.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it.`, `- When saving via \`memory_add\`, attach 2–4 aliases (alternate phrasings,`, ` synonyms, equivalents in the user's other language) when the install has`, ` capture aliases enabled — they make reworded questions find the memory.`);
61
61
  lines.push(``);
62
62
  return lines.join("\n");
63
63
  }
package/dist/doctor.js CHANGED
@@ -82,14 +82,17 @@ function lockingCheck() {
82
82
  }
83
83
  }
84
84
  /**
85
- * D59: patrol the opencode native-plugin entry. The D58 field failure was
86
- * silent — the entry was configured, the host never loaded it, and nothing
87
- * anywhere said why. Two death modes to catch:
88
- * 1. the entry's target file does not exist (package moved, nvm rolled
89
- * to a new version dir, checkout deleted);
90
- * 2. the plugin's import closure runtime-imports @opencode-ai/plugin
91
- * (or another host-only module the package does not depend on),
92
- * which is unresolvable from a global install (D58 regression).
85
+ * D59/D60: patrol the opencode native-plugin entries. The D58 field failure
86
+ * was silent — the entry was configured, the host never loaded it, and
87
+ * nothing anywhere said why. Death modes to catch:
88
+ * 1. the entry's target does not exist (package moved, nvm rolled to a
89
+ * new version dir, checkout deleted);
90
+ * 2. the plugin's import closure runtime-imports a host SDK package
91
+ * (@opencode-ai/plugin on v1, @opencode/plugin on v2) — unresolvable
92
+ * from a global install, the host silently skips the plugin (D58);
93
+ * 3. (D60) the config wires only the other host generation: an
94
+ * opencode 2 host never reads the v1 `plugin` file entry (it demands
95
+ * a directory under `plugins`), and opencode 1 cannot read `plugins`.
93
96
  * Read-only; only inspects the user-level opencode configs.
94
97
  */
95
98
  function opencodePluginCheck() {
@@ -97,8 +100,18 @@ function opencodePluginCheck() {
97
100
  try {
98
101
  const jsonPath = opencodeGlobalConfigPath();
99
102
  const candidates = [jsonPath, jsonPath.replace(/\.json$/, ".jsonc")];
100
- const entries = [];
103
+ const v1Entries = [];
104
+ const v2Entries = [];
101
105
  let sawConfig = false;
106
+ const v2EntryPackage = (entry) => {
107
+ if (typeof entry === "string")
108
+ return entry;
109
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
110
+ const p = entry.package;
111
+ return typeof p === "string" ? p : null;
112
+ }
113
+ return null;
114
+ };
102
115
  for (const f of candidates) {
103
116
  if (!fs.existsSync(f))
104
117
  continue;
@@ -106,17 +119,24 @@ function opencodePluginCheck() {
106
119
  const text = fs.readFileSync(f, "utf8");
107
120
  try {
108
121
  const doc = JSON.parse(text);
109
- const list = Array.isArray(doc.plugin) ? doc.plugin : [];
110
- for (const e of list) {
111
- if (typeof e === "string")
112
- entries.push(e);
122
+ if (Array.isArray(doc.plugin)) {
123
+ for (const e of doc.plugin)
124
+ if (typeof e === "string")
125
+ v1Entries.push(e);
126
+ }
127
+ if (Array.isArray(doc.plugins)) {
128
+ for (const e of doc.plugins) {
129
+ const p = v2EntryPackage(e);
130
+ if (p !== null)
131
+ v2Entries.push(p);
132
+ }
113
133
  }
114
134
  }
115
135
  catch {
116
136
  // JSONC (comments/trailing commas): fall back to scanning quoted
117
137
  // strings for plugin entries.
118
138
  for (const m of text.matchAll(/"(file:[^"]+|[^"]+\.(?:ts|js|mts|mjs))"/g)) {
119
- entries.push(m[1]);
139
+ v1Entries.push(m[1]);
120
140
  }
121
141
  }
122
142
  }
@@ -124,24 +144,71 @@ function opencodePluginCheck() {
124
144
  return { name, ok: true, detail: "no global opencode config found (skipped)" };
125
145
  }
126
146
  const problems = [];
127
- const RUNTIME_SDK_IMPORT = /^\s*import\s+(?!type\b)[^;]*?["']@opencode-ai\/plugin(?:\/[^"']*)?["']/m;
128
- const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode-ai\/plugin(?:\/[^"']*)?["']\s*\)/;
147
+ // Host SDK packages per generation; a runtime import of either from a
148
+ // globally installed plugin cannot resolve (D58).
149
+ const RUNTIME_SDK_IMPORT = /^\s*import\s+(?!type\b)[^;]*?["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']/m;
150
+ const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']\s*\)/;
151
+ const toPath = (entry) => {
152
+ try {
153
+ return entry.startsWith("file:") ? fileURLToPath(entry) : entry;
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ };
129
159
  // An entry is ours when its path names the package, or when the target
130
160
  // file carries the plugin's "[open-memex]" log marker (source checkouts
131
161
  // can live at any path).
132
- const isOurEntry = (entry) => {
133
- if (entry.includes("open-memex"))
134
- return true;
162
+ const fileHasMarker = (target) => {
135
163
  try {
136
- const target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
137
- if (!fs.existsSync(target))
138
- return false;
139
164
  return fs.readFileSync(target, "utf8").slice(0, 65536).includes("[open-memex]");
140
165
  }
141
166
  catch {
142
167
  return false;
143
168
  }
144
169
  };
170
+ /** Resolve an OpenCode 2 directory entry to its entry file, if any. */
171
+ const resolveV2EntryFile = (target) => {
172
+ try {
173
+ if (!fs.existsSync(target))
174
+ return null;
175
+ if (fs.statSync(target).isFile())
176
+ return target;
177
+ for (const idx of ["index.ts", "index.js", "index.mts", "index.mjs"]) {
178
+ const p = path.join(target, idx);
179
+ if (fs.existsSync(p))
180
+ return p;
181
+ }
182
+ const pkgJson = path.join(target, "package.json");
183
+ if (fs.existsSync(pkgJson)) {
184
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
185
+ if (typeof pkg.main === "string") {
186
+ const main = path.join(target, pkg.main);
187
+ if (fs.existsSync(main))
188
+ return main;
189
+ }
190
+ }
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ return null;
196
+ };
197
+ const isOursV1 = (entry) => {
198
+ if (entry.includes("open-memex"))
199
+ return true;
200
+ const target = toPath(entry);
201
+ return target !== null && fs.existsSync(target) && fileHasMarker(target);
202
+ };
203
+ const isOursV2 = (entry) => {
204
+ if (entry.includes("open-memex"))
205
+ return true;
206
+ const target = toPath(entry);
207
+ if (target === null)
208
+ return false;
209
+ const file = resolveV2EntryFile(target);
210
+ return file !== null && fileHasMarker(file);
211
+ };
145
212
  const scanSdkImports = (entryFile) => {
146
213
  // Walk the entry's relative-import closure looking for host-SDK
147
214
  // runtime imports (type-only imports are erased and fine).
@@ -176,16 +243,14 @@ function opencodePluginCheck() {
176
243
  }
177
244
  return offenders;
178
245
  };
179
- const ours = entries.filter(isOurEntry);
180
- if (ours.length === 0) {
246
+ const oursV1 = v1Entries.filter(isOursV1);
247
+ const oursV2 = v2Entries.filter(isOursV2);
248
+ if (oursV1.length === 0 && oursV2.length === 0) {
181
249
  return { name, ok: true, detail: "no global plugin entry (per-project or MCP wiring is not checked)" };
182
250
  }
183
- for (const entry of ours) {
184
- let target;
185
- try {
186
- target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
187
- }
188
- catch {
251
+ for (const entry of oursV1) {
252
+ const target = toPath(entry);
253
+ if (target === null) {
189
254
  problems.push(`entry "${entry}" is not a parseable file URL`);
190
255
  continue;
191
256
  }
@@ -194,16 +259,62 @@ function opencodePluginCheck() {
194
259
  continue;
195
260
  }
196
261
  for (const f of scanSdkImports(target)) {
197
- problems.push(`${f} runtime-imports @opencode-ai/plugin — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
262
+ problems.push(`${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
263
+ }
264
+ }
265
+ for (const entry of oursV2) {
266
+ // A bare package name ("open-memex" via `opencode plugin add`) is
267
+ // resolved by the host's plugin manager; there is no local path to
268
+ // verify.
269
+ const isPackageName = !entry.startsWith("file:") &&
270
+ !entry.startsWith(".") &&
271
+ !path.isAbsolute(entry) &&
272
+ !/^[a-zA-Z]:[\\/]/.test(entry);
273
+ if (isPackageName)
274
+ continue;
275
+ const target = toPath(entry);
276
+ if (target === null || !fs.existsSync(target)) {
277
+ problems.push(`opencode 2 entry "${entry}" does not resolve to an existing path — run \`open-memex init --client opencode --global --force\` to re-point it`);
278
+ continue;
279
+ }
280
+ const entryFile = resolveV2EntryFile(target);
281
+ if (entryFile === null) {
282
+ problems.push(`opencode 2 entry "${entry}" has no loadable plugin file (looked for index.ts/index.js and package.json main)`);
283
+ continue;
284
+ }
285
+ for (const f of scanSdkImports(entryFile)) {
286
+ problems.push(`${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
198
287
  }
199
288
  }
289
+ // D60: generation mismatch — the config wires only the generation the
290
+ // installed host cannot read. Best-effort: no binary, no opinion.
291
+ let hostMajor = null;
292
+ try {
293
+ const out = execFileSync("opencode", ["--version"], {
294
+ timeout: 3000,
295
+ encoding: "utf8",
296
+ stdio: ["ignore", "pipe", "ignore"],
297
+ });
298
+ const m = out.match(/(\d+)\.(\d+)\.(\d+)/);
299
+ if (m)
300
+ hostMajor = Number(m[1]);
301
+ }
302
+ catch {
303
+ hostMajor = null;
304
+ }
305
+ if (hostMajor !== null && hostMajor >= 2 && oursV1.length > 0 && oursV2.length === 0) {
306
+ problems.push(`the installed opencode is v${hostMajor} but the config only has the opencode 1 "plugin" entry — v2 loads directories from "plugins" and will silently skip this plugin; run \`open-memex init --client opencode --global --force\` to add the v2 entry`);
307
+ }
308
+ else if (hostMajor === 1 && oursV2.length > 0 && oursV1.length === 0) {
309
+ problems.push(`the installed opencode is v1 but the config only has the opencode 2 "plugins" entry — v1 reads the "plugin" key and will not load this plugin; run \`open-memex init --client opencode --global --force\` to add the v1 entry`);
310
+ }
200
311
  if (problems.length > 0) {
201
312
  return { name, ok: false, detail: problems.join("; ") };
202
313
  }
203
314
  return {
204
315
  name,
205
316
  ok: true,
206
- detail: `${ours.length} plugin ${ours.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
317
+ detail: `${oursV1.length + oursV2.length} plugin ${oursV1.length + oursV2.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
207
318
  };
208
319
  }
209
320
  catch (err) {
package/dist/index.js CHANGED
@@ -1,127 +1,35 @@
1
- import { loadConfig } from "./config.js";
2
- import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "./scope.js";
3
- import { db } from "./store/db.js";
4
- import { syncScope, upsertFromFile } from "./store/sync.js";
5
- import { scopeHasFiles } from "./store/migrate.js";
6
- import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
7
- import { buildContextBlock } from "./retrieve/inject.js";
8
- import { detectKeywords } from "./capture/keywords.js";
9
- import { findDuplicates } from "./store/lifecycle.js";
10
- import { redact } from "./redact.js";
1
+ import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
11
2
  import { makeTools } from "./tools/memory.js";
12
- const plugin = async ({ worktree, directory }) => {
13
- const cfg = loadConfig();
3
+ import { v2Plugin } from "./opencode-v2.js";
4
+ /**
5
+ * One entrypoint, both opencode generations (D60):
6
+ * - OpenCode 2 structurally decodes the default export as `{ id, setup }`
7
+ * and ignores `server` (see src/opencode-v2.ts).
8
+ * - OpenCode 1 (≥1.18.29) reads `server` off the same default export and
9
+ * ignores `setup` — the function below is the v1 plugin exactly as it
10
+ * behaved before dual support.
11
+ * Neither host runtime-imports an SDK package from this file: v1's SDK is
12
+ * type-only (D58), and v2's decode is structural.
13
+ */
14
+ const server = async ({ worktree, directory }) => {
14
15
  const roots = worktree || directory || process.cwd();
15
- const scope = resolveProjectScope(roots);
16
- // Init DB and one-shot sync of markdown -> index on plugin load.
17
- try {
18
- db();
19
- syncScope(scope.key);
20
- syncScope(PERSONAL_SCOPE.key);
21
- if (cfg.logLevel === "debug") {
22
- console.log(`[open-memex] loaded. scope=${scope.key}`);
23
- }
24
- // If a git remote was added *after* memories were first captured, the
25
- // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
26
- // disk and tell the user how to migrate. We do NOT auto-migrate: two
27
- // different repos at the same cwd would collide.
28
- const cwdScope = resolveCwdScope(roots);
29
- if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
30
- console.warn(`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
31
- `Current project scope is "${scope.key}". ` +
32
- `To move them: npm run cli -- migrate --from ${cwdScope.key}`);
33
- }
34
- }
35
- catch (err) {
36
- console.error("[open-memex] init failed:", err);
37
- }
38
- const tools = makeTools(() => scope, cfg);
39
- const injectedSessions = new Set();
16
+ const state = bootstrapPlugin(roots);
17
+ const tools = makeTools(() => state.scope, state.cfg);
40
18
  return {
41
19
  tool: tools,
42
20
  async "chat.message"(_input, output) {
43
- if (!cfg.keywordCaptureEnabled)
44
- return;
45
21
  const parts = (output.parts ?? []);
46
22
  const text = parts
47
23
  .map((p) => (p?.type === "text" ? p.text ?? "" : ""))
48
24
  .filter(Boolean)
49
25
  .join("\n");
50
- if (!text)
51
- return;
52
- const hits = detectKeywords(text, cfg);
53
- for (const h of hits) {
54
- const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
55
- // Secrets are masked (first 4 chars kept) and the capture proceeds;
56
- // skip only when nothing usable remains.
57
- if (content.length === 0)
58
- continue;
59
- if (hadSecret && cfg.logLevel === "debug") {
60
- console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
61
- }
62
- // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
63
- const target = h.personal ? PERSONAL_SCOPE : scope;
64
- // Dedup (§3.4): skip exact duplicates captured before.
65
- if (findDuplicates(target.key, content).exact)
66
- continue;
67
- const now = Date.now();
68
- const rfc = msToRfc3339(now);
69
- const fm = {
70
- id: ulid(),
71
- schema_version: 2,
72
- scope_key: target.key,
73
- scope: target.kind === "project" ? "project" : "personal",
74
- visibility: target.kind === "project" ? "internal" : "private",
75
- project_name: target.projectName,
76
- type: "fact",
77
- role: "knowledge",
78
- importance: "normal",
79
- status: "active",
80
- tags: ["keyword"],
81
- source: "keyword",
82
- created_at: rfc,
83
- updated_at: rfc,
84
- supersedes: null,
85
- superseded_by: null,
86
- review_state: "draft",
87
- proposed_by: null,
88
- approved_by: null,
89
- derived_from: null,
90
- review_note: null,
91
- review_history: [],
92
- };
93
- try {
94
- const { filePath } = writeMemoryFile(fm, content);
95
- const mf = readMemoryFile(filePath);
96
- if (mf)
97
- upsertFromFile(mf);
98
- // Capture feedback: always visible (not debug-only) — the user said
99
- // "记住…", they should see that it landed. The opencode plugin API
100
- // offers no toast channel, so the plugin log is the feedback surface.
101
- const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
102
- console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
103
- }
104
- catch (err) {
105
- console.error("[open-memex] keyword capture failed:", err);
106
- }
107
- }
26
+ captureFromText(text, state);
108
27
  },
109
28
  async "experimental.chat.system.transform"(input, output) {
110
- if (!cfg.injectOnFirstTurn)
111
- return;
112
- const sid = input.sessionID ?? "";
113
- if (injectedSessions.has(sid))
114
- return;
115
- injectedSessions.add(sid);
116
- try {
117
- const block = buildContextBlock(scope, cfg);
118
- if (block)
119
- output.system.push(block);
120
- }
121
- catch (err) {
122
- console.error("[open-memex] context injection failed:", err);
123
- }
29
+ injectOnce(input.sessionID ?? "", state, (block) => {
30
+ output.system.push(block);
31
+ });
124
32
  },
125
33
  };
126
34
  };
127
- export default plugin;
35
+ export default { id: PLUGIN_ID, setup: v2Plugin.setup, server };