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 +12 -2
- package/CHANGELOG.md +24 -1
- package/README.md +21 -2
- package/README.zh-CN.md +7 -2
- package/dist/cli.js +31 -6
- package/dist/config.js +2 -0
- package/dist/distill-agents.js +1 -1
- package/dist/doctor.js +143 -32
- package/dist/index.js +20 -112
- package/dist/init.js +85 -5
- package/dist/mcp.js +12 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/plugin-core.js +138 -0
- package/dist/retrieve/inject.js +1 -1
- package/dist/retrieve/search.js +11 -2
- package/dist/store/db.js +11 -9
- package/dist/store/lifecycle.js +2 -0
- package/dist/store/markdown.js +39 -1
- package/dist/store/sync.js +5 -3
- package/dist/tools/ops.js +22 -3
- package/docs/V2-DESIGN.md +94 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +47 -2
- package/scripts/test-full.ts +28 -0
- package/skills/open-memex/SKILL.md +30 -1
- package/src/cli.ts +36 -5
- package/src/config.ts +10 -0
- package/src/distill-agents.ts +6 -0
- package/src/doctor.ts +140 -29
- package/src/index.ts +25 -112
- package/src/init.ts +91 -5
- package/src/mcp.ts +12 -5
- package/src/opencode-v2.ts +119 -0
- package/src/plugin-core.ts +152 -0
- package/src/retrieve/inject.ts +1 -1
- package/src/retrieve/search.ts +14 -2
- package/src/store/db.ts +11 -9
- package/src/store/lifecycle.ts +3 -0
- package/src/store/markdown.ts +45 -1
- package/src/store/sync.ts +6 -3
- package/src/tools/ops.ts +31 -2
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
- **
|
|
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"`
|
|
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
|
-
-
|
|
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
|
|
61
|
-
--type
|
|
62
|
-
--tag
|
|
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) => {
|
package/dist/distill-agents.js
CHANGED
|
@@ -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
|
|
86
|
-
* silent — the entry was configured, the host never loaded it, and
|
|
87
|
-
* anywhere said why.
|
|
88
|
-
* 1. the entry's target
|
|
89
|
-
*
|
|
90
|
-
* 2. the plugin's import closure runtime-imports
|
|
91
|
-
* (
|
|
92
|
-
*
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
180
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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: `${
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
35
|
+
export default { id: PLUGIN_ID, setup: v2Plugin.setup, server };
|