open-memex 0.7.0-alpha.3 → 0.7.0-alpha.6

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
@@ -136,6 +136,7 @@ Every write path (tool, keyword hook, CLI `add`) must:
136
136
  2. If `hadSecret` → the matched secret is **masked in place** (first 4 characters kept, the rest replaced with `x`) and the write proceeds; never save the unmasked original. `<private>…</private>` spans are stripped to `[REDACTED]` instead (design D14).
137
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.
138
138
  4. `writeMemoryFile` first, then `readMemoryFile` + `upsertFromFile` to keep FTS in sync.
139
+ 5. Deletion reports honestly (D63): only ENOENT counts as "deleted"; any other unlink failure keeps the index row and returns failure — the file is the source of truth and would otherwise resurrect the memory on next sync.
139
140
 
140
141
  Capture-time aliases (D61): `memory_add`/`supersede` may carry up to 4
141
142
  `aliases` (alternate phrasings) into frontmatter; they index in their own FTS
@@ -150,16 +151,19 @@ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
150
151
 
151
152
  - Statuses: `active → superseded | deprecated | retracted | archived`. Retrieval excludes `retracted`/`archived`, ranks `active` above `deprecated`.
152
153
  - Never hand-write `status: superseded` or half a chain. Use the `supersede` code path (`src/store/lifecycle.ts`): the old record keeps its file, flips to `superseded`, and both sides get `supersedes`/`superseded_by`. Only `active` memories can be superseded.
154
+ - Superseding a memory that had entered review (proposed/approved/published/rejected) starts the replacement at `review_state: proposed` so `promote` can advance it (D64). Never reset it to `draft` — a draft in the repo dir is stranded (submit only moves outbox files).
155
+ - `setStatus` refuses `retracted → active` (D64): retracted content must not silently return to recall. Save a new memory instead.
156
+ - **Every memory-file write goes through `atomicWriteTextSync`** (markdown.ts; tmp + rename). The create path, supersede flips, review transitions, submit landings, migrate/v2migrate rewrites — no direct `fs.writeFileSync` on a memory file. A torn read mid-rewrite parses as a deletion and drops the memory from the index.
153
157
  - Chain integrity is self-healing: on read, a missing counterpart is auto-completed with a warning; a dangling pointer warns but is never fabricated. Don't "fix" chains by editing frontmatter directly — let the read path do it.
154
158
  - Frontmatter is `schema_version: 2`. The SQLite index schema is versioned separately and rebuilds automatically on version change — never hand-edit `index.db`.
155
159
 
156
- Keyword capture fires from `chat.message` on the user's message parts (`UserMessage`). Patterns live in `src/capture/keywords.ts` / config `keywordPatterns`; regex group 1 is the memory body. Scope routing: personal patterns (`cfg.keywordPersonalPatterns`) force the personal scope — the rule is 我 → personal (记住我/替我记/我觉得/我喜欢/remember for me), 我们 → current scope (我们认为/我们决定/帮我们记住); personal patterns run first and claim their line so a generic trigger can't double-fire.
160
+ Keyword capture fires from `chat.message` on the user's message parts (`UserMessage`). Patterns live in `src/capture/keywords.ts` / config `keywordPatterns`; regex group 1 is the memory body. Scope routing: personal patterns (`cfg.keywordPersonalPatterns`) force the personal scope — the rule is 我 → personal (记住我/替我记/帮我记/我觉得/我喜欢/remember for me), 我们 → current scope (我们认为/我们决定/帮我们记住); personal patterns run first and claim their line so a generic trigger can't double-fire.
157
161
 
158
162
  Context injection happens exactly once per session in `experimental.chat.system.transform`, guarded by an in-memory `Set<sessionID>` in `src/index.ts`. It is not persisted — restarting opencode re-injects on the next first turn.
159
163
 
160
164
  ## Design constraints — read the frozen design first
161
165
 
162
- `docs/V2-DESIGN.md` is the frozen protocol v0.2 (zero open questions). Per its §12:
166
+ `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions D1–D64 settled; open questions tracked at the end of the doc). Per its §12:
163
167
  AGENTS.md answers "how should AI work here"; the design doc answers "why is it
164
168
  built this way" (principles, iron rules, D1–D49 decision log). Before changing
165
169
  architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
package/CHANGELOG.md CHANGED
@@ -9,6 +9,39 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Keyword capture gaps (D65)
13
+
14
+ The most natural phrasings were the ones that fell through:
15
+
16
+ - `帮我记住…` / `帮我记一下…` now capture (personal scope, same 我-rule as 替我记; `帮我们记住…` still routes project).
17
+ - A leading `请` no longer defeats capture: `请记住我…`, `请记住(个人)…`, and `请记住:…` all work.
18
+ - English `remember to …` no longer leaves a stray "to" at the front of the captured body.
19
+
20
+ Patterns are code defaults, so these apply automatically on upgrade — unless you hand-customized `keywordPatterns` / `keywordPersonalPatterns` in your config, in which case your lists win and you can port the additions by hand.
21
+
22
+ ## P1 fixes: stale answers and stranded files (D64)
23
+
24
+ Second batch from the whole-project review — the index, the files, and the guidance can no longer quietly disagree:
25
+
26
+ - The opencode plugin now re-syncs the index before every tool call (previously only at startup): memories added via the CLI or another client are visible right away instead of after a restart.
27
+ - Superseding a published memory re-enters review as `proposed` instead of a stranded `draft` no command could advance; `promote` can move it forward again.
28
+ - All memory-file rewrites are atomic (tmp + rename), not just creates.
29
+ - A retracted memory can't be flipped back to `active` via status changes — save a new memory if the content is valid again.
30
+ - Proposed copies of personal memories are `visibility: internal` (was: `private`, which export silently excluded from team bundles).
31
+ - `uninstall` finds the real global opencode config (`.jsonc` included), and `doctor` checks all eleven MCP tools plus stranded v1 (`user`-scope) memories.
32
+ - `memory_status` output is capped per section; `memory_list` accepts `scope: both`; `forget` notes when the deleted file was in the repo; `where` prints the config path.
33
+
34
+ ## Correctness fixes from the whole-project review (D63)
35
+
36
+ Six silent-wrong-answer bugs found by an external three-lens review, each fixed with a regression test:
37
+
38
+ - `init --client visualstudio` no longer wipes other servers from an existing `.mcp.json` (the parsed config was dropped instead of merged).
39
+ - `memory_forget` / `forget` tell the truth: when the memory file can't be deleted (locked, in use), they report failure and keep the index entry, instead of claiming success while the file — the source of truth — brings the memory back on next sync.
40
+ - Config parsing is string-aware: a redact pattern like `src/**/secrets` is no longer eaten as a block comment and silently disabled. `doctor` and the config loader now share one parser.
41
+ - `loadConfig` enforces the `memoryDir` invariant on hand-edited configs; an escaping value (`../../shared`) falls back to the default with a warning instead of writing project memories outside the repo.
42
+ - `migrate` re-scopes as well as re-keys: migrated files get the destination's `scope`/`visibility`, so personal→project migrations are submittable and project→personal memories stay private in exports. Conflict resolution compares parsed timestamps, not RFC-3339 strings.
43
+ - `import` rejects bundle entries whose paths escape the bundle (`../` or symlinks) instead of reading arbitrary local files into the store; rejected paths are printed.
44
+
12
45
  ## Environment variable names (D62)
13
46
 
14
47
  - The override variables now answer to `OPEN_MEMEX_HOME` (storage root) and `OPEN_MEMEX_CONFIG` (config path). The pre-rename `MY_O_MEMORY_HOME` / `MY_O_MEMORY_CONFIG` names keep working as fallbacks — nothing to migrate, the data root itself does not move. `doctor` notes when the storage root came from the legacy variable.
package/README.md CHANGED
@@ -222,9 +222,10 @@ open-memex init --client vscode
222
222
  ```
223
223
 
224
224
  Writes the MCP server entry; reload the window afterwards and confirm the
225
- `open-memex` server is started in Copilot Chat's MCP panel. By default the
226
- server entry goes to VS Code's *user-level* config (works in every project);
227
- `init` can also write a project-level `.vscode/mcp.json` if you prefer.
225
+ `open-memex` server is started in Copilot Chat's MCP panel. By default this
226
+ writes a project-level `.vscode/mcp.json` (an interactive run asks which
227
+ level you want); add `--global` for VS Code's *user-level* config instead —
228
+ one setup that works in every project.
228
229
 
229
230
  **Cursor:**
230
231
 
@@ -232,8 +233,8 @@ server entry goes to VS Code's *user-level* config (works in every project);
232
233
  open-memex init --client cursor
233
234
  ```
234
235
 
235
- Same shape as VS Code: user-level MCP config by default, project-level
236
- `.cursor/mcp.json` on request, plus Copilot-style instructions.
236
+ Same shape as VS Code: project-level `.cursor/mcp.json` by default,
237
+ user-level MCP config with `--global`, plus Copilot-style instructions.
237
238
 
238
239
  **opencode** (native plugin — recommended):
239
240
 
@@ -367,7 +368,7 @@ Three ways memories get in:
367
368
  `记住…` / `记一下` / `记录一下` / `别忘了…` — and the sentence is captured
368
369
  without any tool call. Captures land in the current **project** by default;
369
370
  phrases that signal "this is about me" — `remember for me …`, `记住我…`,
370
- `替我记…`, `我觉得…`, `我喜欢…` — go to **personal** instead, and
371
+ `替我记…`, `帮我记…`, `我觉得…`, `我喜欢…` — go to **personal** instead, and
371
372
  team-context phrases (`我们决定…`, `帮我们记住…`) stay in project.
372
373
  - **The agent saves it**: in any editor, ask your agent to remember something
373
374
  (or it saves on its own when you state a fact worth keeping) — it calls
@@ -378,7 +379,7 @@ Three ways memories get in:
378
379
  conventions, gotchas, approaches tried and abandoned — and saves only the
379
380
  ones you approve. Nothing is written silently: no draft is created behind
380
381
  your back.
381
- - **The CLI**: `open-memex add "…"` with optional `--scope` / `--tags` / `--type`
382
+ - **The CLI**: `open-memex add "…"` with optional `--scope` / `--tag` / `--type`
382
383
  / `--aliases`.
383
384
 
384
385
  **Aliases.** Each saved memory can carry up to 4 alternate phrasings
@@ -409,7 +410,7 @@ they only matter once you share memories through Git.
409
410
  |---|---|
410
411
  | ● `memory_add` | Save a fact, preference, decision, note |
411
412
  | ● `memory_search` | Keyword search (BM25) across project + personal memories |
412
- | ● `memory_list` | List memories in a scope, newest first |
413
+ | ● `memory_list` | List memories in a scope (`project`, `personal`, or `both`), newest first |
413
414
  | ● `memory_supersede` | Replace a memory with a newer version (keeps a supersede chain) |
414
415
  | ● `memory_forget` | Delete a memory by id |
415
416
  | `memory_status` | Show the sync queue: outbox drafts, repo review states, uncommitted files |
@@ -605,7 +606,7 @@ open-memex config set sync.autoPull true # best-effort pull at MCP session sta
605
606
  ```
606
607
 
607
608
  Settable keys: `maxProjectMemories`, `maxProfileItems`, `injectOnFirstTurn`,
608
- `keywordCaptureEnabled`, `logLevel`, `memoryDir`, and `sync.autoPull` (a dotted
609
+ `keywordCaptureEnabled`, `captureAliases`, `logLevel`, `memoryDir`, and `sync.autoPull` (a dotted
609
610
  key that writes into the nested `sync` object). Full design:
610
611
  [docs/V2-DESIGN.md](./docs/V2-DESIGN.md).
611
612
 
package/README.zh-CN.md CHANGED
@@ -162,7 +162,7 @@ npx -y open-memex init --yes
162
162
  open-memex init --client vscode
163
163
  ```
164
164
 
165
- 写入 MCP 服务器配置并可干净重载;重载窗口,确认 Copilot Chat 的 MCP 面板里 `open-memex` 服务器已启动。默认把配置写到 VS Code 的*用户级*配置(每个项目都生效);也可以写到项目级 `.vscode/mcp.json`。
165
+ 写入 MCP 服务器配置;重载窗口,确认 Copilot Chat 的 MCP 面板里 `open-memex` 服务器已启动。默认写到项目级 `.vscode/mcp.json`(交互式运行会问你选哪一级);加 `--global` 改写 VS Code 的*用户级*配置——一次设置,每个项目都生效。
166
166
 
167
167
  **Cursor**:
168
168
 
@@ -170,7 +170,7 @@ open-memex init --client vscode
170
170
  open-memex init --client cursor
171
171
  ```
172
172
 
173
- 形态和 VS Code 一样:默认用户级 MCP 配置,可选项目级 `.cursor/mcp.json`,外加 Copilot 风格的指令文件。
173
+ 形态和 VS Code 一样:默认项目级 `.cursor/mcp.json`,`--global` 改为用户级 MCP 配置,外加 Copilot 风格的指令文件。
174
174
 
175
175
  **opencode**(原生插件——推荐):
176
176
 
@@ -252,10 +252,10 @@ open-memex uninstall --yes
252
252
 
253
253
  三种存入方式:
254
254
 
255
- - **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget …`、`TIL …`、`save this …`——中文 `记住…` / `记一下` / `记录一下` / `别忘了…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`记住我…`、`替我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。
255
+ - **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget …`、`TIL …`、`save this …`——中文 `记住…` / `记一下` / `记录一下` / `别忘了…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。
256
256
  - **助手主动保存**:在任何编辑器里,你让助手记住某件事(或者你陈述了一个值得记的事实、它自己判断该保存),它会调用 `memory_add`。上面的路由是启发式规则;你可以明说"存到我的个人记忆",或用 CLI 加 `--scope` 明确指定。
257
257
  - **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
258
- - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tags` / `--type` / `--aliases`。
258
+ - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tag` / `--type` / `--aliases`。
259
259
 
260
260
  **别名。** 每条记忆可以带最多 4 个换一种说法(同义词、另一种语言的对应词),和记忆一起进索引,这样问法不同也能找到——比如搜 "vacation days" 能找到假期政策那条。`init` 时会问一次是否开启(默认开);随时可用 `open-memex config set captureAliases false` 关掉。
261
261
 
@@ -275,7 +275,7 @@ MCP 服务器暴露十一个工具;opencode 原生插件暴露其中五个核
275
275
  |---|---|
276
276
  | ● `memory_add` | 保存一条事实、偏好、决策、笔记 |
277
277
  | ● `memory_search` | 关键词搜索(BM25),横跨项目 + 个人记忆 |
278
- | ● `memory_list` | 列出某个作用域的记忆,最新在前 |
278
+ | ● `memory_list` | 列出某个作用域(`project`、`personal` 或 `both`)的记忆,最新在前 |
279
279
  | ● `memory_supersede` | 用新版本替换一条记忆(保留 supersede 链) |
280
280
  | ● `memory_forget` | 按 id 删除一条记忆 |
281
281
  | `memory_status` | 显示同步队列:outbox 草稿、仓库评审状态、未提交文件 |
@@ -323,7 +323,7 @@ MCP 服务器暴露十一个工具;opencode 原生插件暴露其中五个核
323
323
  4. **PR 评审**——记忆是纯 Markdown;评审者走正常分支/PR 流程批准、要求修改或拒绝。
324
324
  5. **回想**——已发布的记忆在会话开始时注入,平时也能随时搜,人和助手都一样。
325
325
 
326
- 看护共享记忆的人遵循 [维护者约定](./docs/CURATOR.md):什么该批准、什么该打回、哪些卫生规则让共享记忆不腐烂。
326
+ 看护共享记忆的人遵循 [维护者约定](./docs/CURATOR.md)(英文):什么该批准、什么该打回、哪些卫生规则让共享记忆不腐烂。
327
327
 
328
328
  ## 回想:记忆是怎么回来的
329
329
 
@@ -414,7 +414,7 @@ open-memex config set maxProjectMemories 12
414
414
  open-memex config set sync.autoPull true # MCP 会话开始时尽力而为地 pull
415
415
  ```
416
416
 
417
- 可设置的键:`maxProjectMemories`、`maxProfileItems`、`injectOnFirstTurn`、`keywordCaptureEnabled`、`logLevel`、`memoryDir`,以及 `sync.autoPull`(带点的键,写入嵌套的 `sync` 对象)。完整设计:[docs/V2-DESIGN.md](./docs/V2-DESIGN.md)。
417
+ 可设置的键:`maxProjectMemories`、`maxProfileItems`、`injectOnFirstTurn`、`keywordCaptureEnabled`、`captureAliases`、`logLevel`、`memoryDir`,以及 `sync.autoPull`(带点的键,写入嵌套的 `sync` 对象)。完整设计:[docs/V2-DESIGN.md](./docs/V2-DESIGN.md)(英文)。
418
418
 
419
419
  ## CLI 参考
420
420
 
@@ -462,7 +462,7 @@ open-memex submit <id...> [--branch <name>] [--base <branch>]
462
462
  # 把你点名的草稿移进 .ai/open-memex/,标为 "proposed":
463
463
  # 复制、翻转 review_state、在当前分支本地 git 提交。
464
464
  # 永不自己建分支——建分支是你(或拿到完整链路明确许可的助手)的事。
465
- # 全有全無;冲突(同 id 不同内容)干净地中止。
465
+ # 全有全无;冲突(同 id 不同内容)干净地中止。
466
466
  # 打印 push + gh pr 命令;持有你 Yes 的助手可以一并执行 push/PR。
467
467
  # --branch <name> 先建分支(助手全链路路径)。PR base 默认是当前分支
468
468
  # (记忆 PR 叠在你的工作分支上);--base 可改指到 main 或你的评审分支。
@@ -473,7 +473,7 @@ open-memex pr-status [--apply]
473
473
  # 要求修改 → 只给建议。默认只报告;--apply 在本地执行映射的迁移(不推送)。
474
474
 
475
475
  open-memex pull
476
- # 从 git 远程拉共享记忆:fetch + 純 fast-forward。
476
+ # 从 git 远程拉共享记忆:fetch + 纯 fast-forward。
477
477
  # 分支分叉则明确报错失败——open-memex 永不强制合并;手工解决后再 pull。
478
478
  # 成功后本地索引重新同步。pull 默认显式触发;设置
479
479
  # `open-memex config set sync.autoPull true` 可在 MCP 会话开始时尽力拉一次
@@ -502,7 +502,7 @@ open-memex distill-agents [--scope project|personal] [--type t1,t2] [--limit N]
502
502
 
503
503
  open-memex propose <id...> --to project [--local-approve]
504
504
  # 一次提议一条或多条个人记忆(一个分支、一个 PR);
505
- # 每条复制并生成新 id。全有全無:任何一条 id 出错,整批中止,绝不半途。
505
+ # 每条复制并生成新 id。全有全无:任何一条 id 出错,整批中止,绝不半途。
506
506
  # 把一条个人记忆复制到项目作用域作为评审候选
507
507
  # (是复制不是移动——个人原件保留)。结果落进 outbox;
508
508
  # 准备进仓库时再跑 sync-status / submit。
@@ -547,7 +547,7 @@ npx -y open-memex mcp # 无需安装
547
547
  - **project(项目)**——限定在当前仓库,键由 git origin URL 的 hash 派生(所以同一仓库的多个 clone 共享一个作用域),没有远程时退回到当前工作目录路径。新记忆的默认作用域。
548
548
  - **personal(个人)**——横跨你的所有项目,仅限本机,永不向外同步。放个人偏好用。(v1 里叫 `user`;`migrate --to-v2` 会改名。)
549
549
 
550
- 完整的 scope 模型(键派生、迁移、可见性、保留名)见 [docs/SCOPES.md](./docs/SCOPES.md)。[概念指南](./docs/CONCEPTS.zh-CN.md)把心智模型完整走了一遍。
550
+ 完整的 scope 模型(键派生、迁移、可见性、保留名)见 [docs/SCOPES.md](./docs/SCOPES.md)(英文)。[概念指南](./docs/CONCEPTS.zh-CN.md)把心智模型完整走了一遍。
551
551
 
552
552
  ## 故障排查
553
553
 
@@ -602,7 +602,7 @@ Windows 上已加载的 DLL 是锁定的:如果 open-memex 的 MCP 服务器
602
602
 
603
603
  ## 项目状态
604
604
 
605
- open-memex 是稳定且日常在用的;当前稳定线以 `latest` 发到 npm 上,`alpha` 构建给测试者。发版历史在 [GitHub Releases](https://github.com/stoneskin/open-memex/releases);设计决策记录在只追加的日志 [docs/V2-DESIGN.md](./docs/V2-DESIGN.md) 里。
605
+ open-memex 是稳定且日常在用的;当前稳定线以 `latest` 发到 npm 上,`alpha` 构建给测试者。发版历史在 [GitHub Releases](https://github.com/stoneskin/open-memex/releases);设计决策记录在只追加的日志 [docs/V2-DESIGN.md](./docs/V2-DESIGN.md) 里(英文)。
606
606
 
607
607
  在望的方向(不承诺版本号):更多编辑器的原生助手插件(作为在同一组 MCP 工具之上的增强路径);本地 embedding 作为可选择开启的实验(没经询问,永不下载任何模型);只有在真实多仓库共享、ACL 或合规需求出现时才做 org 层。
608
608
 
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ 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
13
  import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, normalizeAliases, } from "./store/markdown.js";
14
- import { loadConfig } from "./config.js";
14
+ import { loadConfig, configSource } from "./config.js";
15
15
  import { paths, projectRoot } from "./paths.js";
16
16
  import { redact } from "./redact.js";
17
17
  import { resolveMcpCommand } from "./init.js";
@@ -752,6 +752,7 @@ async function main() {
752
752
  if (cmd === "where") {
753
753
  const p = paths();
754
754
  console.log(`root: ${p.root}`);
755
+ console.log(`config: ${configSource() ?? "(built-in defaults)"}`);
755
756
  console.log(`memories: ${p.memories}`);
756
757
  console.log(`index: ${p.indexDb}`);
757
758
  console.log(`project: ${project.key}`);
@@ -954,12 +955,18 @@ async function main() {
954
955
  process.exit(1);
955
956
  }
956
957
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
958
+ // Only ENOENT = success; anything else means the file survives and
959
+ // would be re-indexed — fail loudly (P0 review, 2026-10-03).
957
960
  if (row.file_path) {
958
961
  try {
959
962
  fs.unlinkSync(row.file_path);
960
963
  }
961
- catch {
962
- /* already gone */
964
+ catch (e) {
965
+ const code = e.code;
966
+ if (code !== "ENOENT") {
967
+ console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
968
+ process.exit(1);
969
+ }
963
970
  }
964
971
  }
965
972
  deleteFromIndex(id);
@@ -1176,6 +1183,9 @@ async function main() {
1176
1183
  syncScope(PERSONAL_SCOPE.key, "cli");
1177
1184
  }
1178
1185
  console.log(`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`);
1186
+ for (const rp of r.rejectedPaths) {
1187
+ console.log(` rejected: ${rp} (path outside the bundle)`);
1188
+ }
1179
1189
  for (const c of r.skippedConflict) {
1180
1190
  console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
1181
1191
  }
package/dist/config.js CHANGED
@@ -8,12 +8,12 @@ export const DEFAULT_CONFIG = {
8
8
  keywordCaptureEnabled: true,
9
9
  captureAliases: true,
10
10
  keywordPatterns: [
11
- "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
11
+ "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
12
12
  "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
13
13
  "^\\s*TIL[:,]?\\s+(.+)$",
14
14
  "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
15
15
  // Chinese equivalents
16
- "^\\s*记住(?!(个人))[::,,]?\\s*(.+)$",
16
+ "^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
17
17
  "^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
18
18
  "^\\s*别忘了[::,,]?\\s*(.+)$",
19
19
  // First-person plural: team/project context, NOT personal
@@ -23,10 +23,11 @@ export const DEFAULT_CONFIG = {
23
23
  ],
24
24
  keywordPersonalPatterns: [
25
25
  "^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
26
- "^\\s*记住(个人)[::,,]?\\s*(.+)$",
26
+ "^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
27
27
  // First-person singular: personal scope ("我" → 个人, "我们" → 项目)
28
- "^\\s*记住我(?!们)[::,,]?\\s*(.+)$",
28
+ "^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
29
29
  "^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
30
+ "^\\s*帮我记(?:住|一下)?[::,,]?\\s*(.+)$",
30
31
  "^\\s*我觉得[::,,]?\\s*(.+)$",
31
32
  "^\\s*我喜欢[::,,]?\\s*(.+)$",
32
33
  ],
@@ -37,13 +38,64 @@ export const DEFAULT_CONFIG = {
37
38
  memoryDir: ".ai/open-memex",
38
39
  sync: { autoPull: false },
39
40
  };
40
- function stripJsonComments(raw) {
41
- // Line comments
42
- let out = raw.replace(/^\s*\/\/.*$/gm, "");
43
- // Block comments (non-greedy)
44
- out = out.replace(/\/\*[\s\S]*?\*\//g, "");
45
- // Trailing commas before closing brackets
46
- out = out.replace(/,(\s*[}\]])/g, "$1");
41
+ /**
42
+ * Strip JSONC comments and trailing commas with a string-aware scanner.
43
+ * The previous regex version was not string-aware: a config value like
44
+ * "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
45
+ * corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
46
+ * with doctor.ts — one parser, not two.
47
+ */
48
+ export function stripJsonComments(raw) {
49
+ let out = "";
50
+ let i = 0;
51
+ let inStr = false;
52
+ let esc = false;
53
+ while (i < raw.length) {
54
+ const c = raw[i];
55
+ const n = raw[i + 1];
56
+ if (inStr) {
57
+ out += c;
58
+ if (esc)
59
+ esc = false;
60
+ else if (c === "\\")
61
+ esc = true;
62
+ else if (c === '"')
63
+ inStr = false;
64
+ i++;
65
+ continue;
66
+ }
67
+ if (c === '"') {
68
+ inStr = true;
69
+ out += c;
70
+ i++;
71
+ continue;
72
+ }
73
+ if (c === "/" && n === "/") {
74
+ while (i < raw.length && raw[i] !== "\n")
75
+ i++;
76
+ continue;
77
+ }
78
+ if (c === "/" && n === "*") {
79
+ i += 2;
80
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
81
+ i++;
82
+ i += 2;
83
+ continue;
84
+ }
85
+ if (c === ",") {
86
+ // Trailing comma: drop it when the next significant char closes a
87
+ // container. Whitespace/comments between are tolerated.
88
+ let j = i + 1;
89
+ while (j < raw.length && /\s/.test(raw[j]))
90
+ j++;
91
+ if (raw[j] === "}" || raw[j] === "]") {
92
+ i++;
93
+ continue;
94
+ }
95
+ }
96
+ out += c;
97
+ i++;
98
+ }
47
99
  return out;
48
100
  }
49
101
  /**
@@ -132,7 +184,19 @@ export function loadConfig() {
132
184
  try {
133
185
  const raw = fs.readFileSync(p, "utf8");
134
186
  const parsed = JSON.parse(stripJsonComments(raw));
135
- return { ...DEFAULT_CONFIG, ...parsed };
187
+ const merged = { ...DEFAULT_CONFIG, ...parsed };
188
+ // The config file is hand-editable, so the load path must enforce
189
+ // the same invariants `config set` does (P0 review, 2026-10-03):
190
+ // an unvalidated memoryDir like "../../shared" would put
191
+ // committed project memories outside the repo.
192
+ try {
193
+ merged.memoryDir = toRelativeDir(merged.memoryDir);
194
+ }
195
+ catch {
196
+ console.error(`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`);
197
+ merged.memoryDir = DEFAULT_CONFIG.memoryDir;
198
+ }
199
+ return merged;
136
200
  }
137
201
  catch (err) {
138
202
  console.error(`[open-memex] failed to parse ${p}:`, err);
@@ -57,7 +57,12 @@ 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.`, ` 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.`);
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
+ // D64: the skills runbook and the MCP handshake both teach marking
62
+ // inferred captures source: "inference"; this footer omitted it, so
63
+ // opencode agents saved distilled conclusions as if the user had
64
+ // stated them ("tool"). Keep the rubric one voice across surfaces.
65
+ `- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish.`);
61
66
  lines.push(``);
62
67
  return lines.join("\n");
63
68
  }
package/dist/doctor.js CHANGED
@@ -7,17 +7,25 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.js";
10
+ import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.js";
11
11
  import { paths, homeOverride } from "./paths.js";
12
12
  import { resolveCwdScope } from "./scope.js";
13
13
  import { db, backendName } from "./store/db.js";
14
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.js";
15
+ // D64: the MCP server exposes eleven tools; checking only the five the
16
+ // opencode plugin carries let a half-registered server pass as healthy.
15
17
  const EXPECTED_TOOLS = [
16
18
  "memory_add",
17
19
  "memory_search",
18
20
  "memory_list",
19
21
  "memory_supersede",
20
22
  "memory_forget",
23
+ "memory_status",
24
+ "memory_submit",
25
+ "memory_propose",
26
+ "memory_promote",
27
+ "memory_resolve",
28
+ "memory_pr_status",
21
29
  ];
22
30
  function nodeCheck() {
23
31
  const [major, minor] = process.versions.node.split(".").map(Number);
@@ -407,7 +415,8 @@ function mcpCheck() {
407
415
  });
408
416
  }
409
417
  /** Parse JSON tolerating line/block comments plus trailing commas
410
- * (VS Code's settings.json is JSONC). Health-check grade, not a full parser. */
418
+ * (VS Code's settings.json is JSONC). Uses config.ts's string-aware
419
+ * stripper — one parser, not two (P0 review, 2026-10-03). */
411
420
  function parseLenientJson(raw) {
412
421
  try {
413
422
  return JSON.parse(raw);
@@ -415,47 +424,7 @@ function parseLenientJson(raw) {
415
424
  catch {
416
425
  /* fall through to comment stripping */
417
426
  }
418
- let out = "";
419
- let i = 0;
420
- let inStr = false;
421
- let esc = false;
422
- while (i < raw.length) {
423
- const c = raw[i];
424
- const n = raw[i + 1];
425
- if (inStr) {
426
- out += c;
427
- if (esc)
428
- esc = false;
429
- else if (c === "\\")
430
- esc = true;
431
- else if (c === '"')
432
- inStr = false;
433
- i++;
434
- continue;
435
- }
436
- if (c === '"') {
437
- inStr = true;
438
- out += c;
439
- i++;
440
- continue;
441
- }
442
- if (c === "/" && n === "/") {
443
- while (i < raw.length && raw[i] !== "\n")
444
- i++;
445
- continue;
446
- }
447
- if (c === "/" && n === "*") {
448
- i += 2;
449
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
450
- i++;
451
- i += 2;
452
- continue;
453
- }
454
- out += c;
455
- i++;
456
- }
457
- out = out.replace(/,\s*([}\]])/g, "$1");
458
- return JSON.parse(out);
427
+ return JSON.parse(stripJsonComments(raw));
459
428
  }
460
429
  function vscodeSettingsPath() {
461
430
  const home = os.homedir();
@@ -574,6 +543,19 @@ function legacyCheck() {
574
543
  const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
575
544
  if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory())
576
545
  found.push(`legacy data dir ${legacyDir} — merge it with \`open-memex migrate --to-v2\``);
546
+ // D64: v1 stored personal memories under the scope dir "user"; v2 reads
547
+ // only "personal". Files left there are invisible to every read path —
548
+ // an upgrader silently sees an empty personal scope. Surface them here;
549
+ // the migration itself stays explicit (no silent moves).
550
+ try {
551
+ const userDir = path.join(root, "memories", "user");
552
+ if (fs.existsSync(userDir) &&
553
+ fs.readdirSync(userDir).some((f) => f.endsWith(".md")))
554
+ found.push("v1 personal memories under " + userDir + " are invisible to v2 reads — convert them with `open-memex migrate --to-v2` (it moves them to the personal scope; preview with --dry-run)");
555
+ }
556
+ catch {
557
+ /* unreadable — not this check's problem */
558
+ }
577
559
  for (const f of [opencodeGlobalConfigPath(), userMcpConfigPath("vscode"), userMcpConfigPath("cursor")]) {
578
560
  try {
579
561
  if (fs.existsSync(f) && fs.readFileSync(f, "utf8").includes(LEGACY_PACKAGE_NAME))
package/dist/export.js CHANGED
@@ -117,10 +117,20 @@ export function importBundle(bundlePath, opts) {
117
117
  if (manifest.format !== EXPORT_FORMAT) {
118
118
  fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
119
119
  }
120
- const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
120
+ const result = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] };
121
121
  const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
122
+ // The manifest comes from whoever sent the bundle: entry.file must
123
+ // stay inside the staging dir (P0 review, 2026-10-03 — "../" entries
124
+ // otherwise read arbitrary .md files into the store). realpath also
125
+ // catches symlinks planted by the tarball.
126
+ const stageRoot = fs.realpathSync(stage);
122
127
  for (const entry of manifest.memories ?? []) {
123
- const src = path.join(stage, entry.file);
128
+ const joined = path.resolve(path.join(stage, entry.file));
129
+ const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined;
130
+ if (!src.startsWith(stageRoot + path.sep)) {
131
+ result.rejectedPaths.push(entry.file);
132
+ continue;
133
+ }
124
134
  const mf = readMemoryFile(src);
125
135
  if (!mf)
126
136
  continue;
package/dist/init.js CHANGED
@@ -56,11 +56,12 @@ You have a local memory MCP server (\`open-memex\`). Its tools are
56
56
  these full names.
57
57
 
58
58
  - The server tells you when project outbox drafts are waiting for review — in
59
- tool results. At session start, call \`memory_status\` once to check. When
60
- drafts are waiting, summarize them (one line each) and ask the user which
61
- ones to sync into the repo; sync NOTHING the user did not name. If you
62
- already asked about these drafts this session, don't ask again. When the
63
- server reports none waiting, do nothing.
59
+ tool results. When it does (or when the user asks), call \`memory_status\`
60
+ to see them; don't call it blindly at session start. When drafts are
61
+ waiting, summarize them (one line each) and ask the user which ones to
62
+ sync into the repo; sync NOTHING the user did not name. If you already
63
+ asked about these drafts this session, don't ask again. When the server
64
+ reports none waiting, do nothing.
64
65
  - BE PROACTIVE about facts the user states directly: when the user shares a
65
66
  decision, preference, project convention, or fix-and-cause worth remembering
66
67
  across sessions, call \`memory_add\` without being asked. Keep each memory to
@@ -286,7 +287,17 @@ function opencodePluginDir() {
286
287
  * loads open-memex in every project with no per-project init.
287
288
  */
288
289
  export function opencodeGlobalConfigPath(home = os.homedir()) {
289
- return path.join(opencodeConfigDir(home, process.env.XDG_CONFIG_HOME), "opencode.json");
290
+ const dir = opencodeConfigDir(home, process.env.XDG_CONFIG_HOME);
291
+ // D64: init writes into whichever of .json/.jsonc already exists
292
+ // (writeOpencodeGlobalPlugin), so readers must resolve the same file.
293
+ // Hardcoding .json made uninstall report "nothing to remove" on
294
+ // .jsonc-only setups and left the plugin entry behind.
295
+ for (const name of ["opencode.jsonc", "opencode.json"]) {
296
+ const candidate = path.join(dir, name);
297
+ if (fs.existsSync(candidate))
298
+ return candidate;
299
+ }
300
+ return path.join(dir, "opencode.json");
290
301
  }
291
302
  /**
292
303
  * Pure merge of the open-memex plugin URL into a parsed opencode config doc.
@@ -470,6 +481,7 @@ function writeVisualStudioMcpJson(root, force) {
470
481
  });
471
482
  return null;
472
483
  }
484
+ doc = parsed;
473
485
  }
474
486
  const section = (doc["servers"] ??= {});
475
487
  // F30: drop the pre-rename server entry — it points at the OLD data dir.
package/dist/review.js CHANGED
@@ -23,7 +23,7 @@ import * as path from "node:path";
23
23
  import { loadConfig } from "./config.js";
24
24
  import { inRepoMemoriesDirPath, projectRoot } from "./paths.js";
25
25
  import { resolveProjectScope } from "./scope.js";
26
- import { parse as parseMemory, serialize, ulid, writeMemoryFile, } from "./store/markdown.js";
26
+ import { parse as parseMemory, serialize, atomicWriteTextSync, ulid, writeMemoryFile, } from "./store/markdown.js";
27
27
  import { findMemoryFile } from "./store/lifecycle.js";
28
28
  import { upsertFromFile } from "./store/sync.js";
29
29
  /** Best-effort author identity: git user.name, else the OS user. */
@@ -79,6 +79,12 @@ function copyPersonalToProject(sourceId, srcFm, srcBody, opts = {}) {
79
79
  id: ulid(),
80
80
  scope: "project",
81
81
  scope_key: project.key,
82
+ // D64: the copy is headed for the team. Source visibility (usually
83
+ // "private") must not ride along: export excludes private by default,
84
+ // so a private copy would silently vanish from every bundle the team
85
+ // builds. Proposing IS the act of sharing — the copy is internal;
86
+ // the personal original keeps its own visibility, untouched.
87
+ visibility: "internal",
82
88
  project_name: project.projectName,
83
89
  created_at: now,
84
90
  updated_at: now,
@@ -194,7 +200,7 @@ export function promoteMemory(id, opts = {}) {
194
200
  },
195
201
  ],
196
202
  };
197
- fs.writeFileSync(mf.filePath, serialize(fm, mf.body), "utf8");
203
+ atomicWriteTextSync(mf.filePath, serialize(fm, mf.body));
198
204
  upsertFromFile({ fm, body: mf.body, filePath: mf.filePath, mtimeMs: Date.now() });
199
205
  return { id, from, to, history: fm.review_history };
200
206
  }
@@ -428,7 +434,7 @@ export function resolveConflict(target) {
428
434
  if (conflicts.length > 0)
429
435
  return { ok: false, filePath, conflicts };
430
436
  const fm = merged;
431
- fs.writeFileSync(filePath, serialize(fm, mergedBody), "utf8");
437
+ atomicWriteTextSync(filePath, serialize(fm, mergedBody));
432
438
  upsertFromFile({ fm, body: mergedBody, filePath, mtimeMs: Date.now() });
433
439
  return { ok: true, filePath, autoMerged: [...new Set(autoMerged)] };
434
440
  }