open-memex 0.7.0-alpha.9 → 0.7.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -14,7 +14,9 @@ via `prepublishOnly` — Node refuses `--experimental-strip-types` for files und
14
14
  - **CLI** (`src/cli.ts`) and smoke tests run under **Node 22+** with `--experimental-strip-types`. SQLite here is `better-sqlite3` (native module).
15
15
  - **MCP server** (`src/mcp.ts`, stdio) runs under **Node 22+** with `--experimental-strip-types`. It exposes the same eleven memory tools to any MCP client (VS Code Copilot, Cursor, Claude Code). **stdout is the protocol channel — never log to stdout in `mcp.ts`; diagnostics go to stderr.**
16
16
 
17
- `src/store/db.ts` picks the backend at runtime by sniffing `globalThis.Bun`. Both backends share the same surface (`new Database(path)`, `.exec`, `.prepare().run/all/get`, `.close`). Any DB code you write must stay on that common subset — do not import `better-sqlite3` or `bun:sqlite` directly outside `db.ts`.
17
+ `src/store/db.ts` picks the backend at runtime by sniffing `globalThis.Bun`. Both backends share the same surface (`new Database(path)`, `.exec`, `.prepare().run/all/get`, `.close`). Any DB code you write must stay on that common subset — do not import `better-sqlite3` or `bun:sqlite` directly outside `db.ts`. The Node driver is pinned to the `better-sqlite3` 13 N-API line (D72): the pre-13 `node::ObjectWrap` build aborts intermittently on Node 24.19+ when a prepared statement is finalized by GC.
18
+
19
+ D74 — the Node floor is **22.14**, not 22.6. `better-sqlite3` 13 is compiled against Node-API 10, which Node gained in 22.14.0; below that floor `require()` succeeds and `new Database()` segfaults the process with no diagnostic (measured: Node 22.12.0 dies with `0xC0000005` on win32-x64, Node 22.23.3 / 24.20.0 are fine; upstream WiseLibs/better-sqlite3#1514). The gate lives in `src/runtime-floor.ts` (pure, unit-tested) and is enforced in `loadDatabase` (`src/store/db.ts`) so every entry point fails with an actionable message instead of a silent crash; `doctor` reports the floor plus a live driver probe run in a child process (a segfault cannot be caught in-process, so the probe must be spawned, and it must resolve the driver from this package's directory, not the caller's cwd). `engines.node` is `>=22.14.0`. Never lower it back to the `--experimental-strip-types` floor of 22.6 — that number is a different constraint and predates the driver bump.
18
20
 
19
21
  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
22
 
@@ -33,7 +35,7 @@ npm run typecheck # tsc --noEmit — the only
33
35
  npm run cli -- where | list | search "q" | add ... | forget <id> | reindex
34
36
  npm run cli -- <command> --help # per-command help (AI assistants discover flags this way)
35
37
  npm run cli -- sync-status # last sync time/kind + outbox drafts + repo review states + uncommitted files
36
- npm run cli -- inventory [--format text|json] [--scope both] # what is remembered, in the open (D68); personal-bearing output refused inside a git worktree without --allow-personal
38
+ npm run cli -- inventory [--format text|json|html] [--scope both] # what is remembered, in the open (D68); personal-bearing output refused inside a git worktree without --allow-personal
37
39
  npm run cli -- audit [--scope project|personal|both] # memory health: near-dup pairs, stale actives, broken chains, personal-in-repo (read-only)
38
40
  npm run cli -- pull # fetch + fast-forward only (explicit; diverged = clean failure, never force-merge)
39
41
  npm run cli -- push # push current branch to remote (explicit only; open-memex never auto-pushes)
@@ -126,6 +128,7 @@ Layout: `memories/<scope_key>/<id>.md` (YAML frontmatter + body) + `index.db` (S
126
128
 
127
129
  - `personal` scope key is literal `"personal"` (v1 called this `user`; renamed in v2, design §19).
128
130
  - `project` scope key: `project__<sanitized-name>__<12-hex-sha256>`, seeded from normalized git origin URL, else lowercased cwd. See `src/scope.ts`. Same repo across machines → same key (intentional; enables future git-commit of memories).
131
+ - The root a host adapter seeds on matters: OpenCode v1 reports the **filesystem root** as `worktree` for non-git folders, so the v1 adapter goes through `pickScopeRoot` (src/scope.ts) — `worktree` only when it is a real directory below the filesystem root, else the opened `directory` (D71). Seeding on a filesystem root merges every non-git folder on a drive into one `project__workspace__…` bucket.
129
132
  - `org`, `team`, `public` are reserved — the schema rejects writes. See `docs/SCOPES.md`.
130
133
  - The scope key **changes** when a repo gains/loses a git origin. `src/index.ts` logs a one-shot warning on load if it finds files under the legacy cwd-only key (`resolveCwdScope`). Use `cli scopes` to enumerate all scope dirs and `cli migrate --from <old>` to reconcile — see `src/store/migrate.ts`. No auto-migration; two unrelated repos at the same cwd would silently merge.
131
134
  - Read-only callers must use `memoriesDirPath` (in `src/paths.ts`), never `memoriesDirFor`. The latter `mkdir -p`s the directory as a side effect and will pollute storage with empty scope dirs.
@@ -154,21 +157,24 @@ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
154
157
  - 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.
155
158
  - 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).
156
159
  - `setStatus` refuses `retracted → active` (D64): retracted content must not silently return to recall. Save a new memory instead.
160
+ - **Visibility surfaces share one contract (D70).** `memory_list`, `cli list` and `inventory` render the same `formatInventoryLine`; numbering is a running counter across every section of one listing (never per scope), and a cut list discloses how many entries it dropped. Any *correction* that touches an in-repo memory (`memory_forget`, `soft=true`, `forget --soft`) must say the change is local until committed and pushed — use `isInRepoMemoryFile` (`src/paths.ts`). Report output paths go through `inventoryWriteRefusal` (`src/cli.ts`), which fails closed on an unresolvable directory.
157
161
  - **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.
158
162
  - 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.
159
163
  - Frontmatter is `schema_version: 2`. The SQLite index schema is versioned separately and rebuilds automatically on version change — never hand-edit `index.db`.
160
164
 
161
165
  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/help me remember), 我们 → current scope (我们认为/我们决定/帮我们记住); personal patterns run first and claim their line so a generic trigger can't double-fire (a rejected personal match keeps the claim — see D67).
162
166
 
167
+ D73 — one user statement, one memory. The hook and the agent are two writers for one turn, and only the hook knows the sentence is stored. Both hooks therefore take the session id (`chat.message` on v1, `prompt` on v2), `captureFromText` records each capture in `PluginState.captures`, and the system/context hook pushes the hand-off note (`captureHandoff` → `formatHandoffBlock`, `src/capture/handoff.ts`) once per capture. `memory_add` additionally refuses a write overlapping a `source: keyword`/`user` memory created in the same scope within `TURN_ECHO_WINDOW_MS` (`findTurnEcho`, threshold `TURN_ECHO_THRESHOLD` = 0.28) and returns the stored id plus the supersede route. Keep the note, the refusal text, `TOOL_DESCRIPTIONS.memory_add`, the skill, and this paragraph saying the same thing — an agent that reads only one of them still has to behave. Never lower `TURN_ECHO_THRESHOLD` to catch more: the measured reason it is high is that an agent's gloss (0.211) and a genuinely new fact from the same sentence (0.214) are lexically indistinguishable.
168
+
163
169
  D67 rules for any new pattern: a trigger must be a **statement to the store**, not narration — 记得… / `remind me to…` are deliberately out, and the ambiguous forms require an explicit marker (`别忘了:…`, `don't forget: …`, `don't forget that …`). The 帮/替/我们 family takes the full verb phrase (`记` + optional `住|录`, never followed by a verb continuation) + optional `一下`; the separator boundary applies to the **bare** verb only, so `帮我记一下这个配置` captures while `帮我记得带伞` cannot produce mid-word garbage. Bodies under `MIN_CAPTURE_CHARS` (3) are rejected as fragments — never lower the floor silently; `scanKeywords` returns the drop so `capture --dry-run` and the debug log can report it.
164
170
 
165
171
  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.
166
172
 
167
173
  ## Design constraints — read the frozen design first
168
174
 
169
- `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D68; open questions tracked at the end of the doc). Per its §12:
175
+ `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D73; open questions tracked at the end of the doc). Per its §12:
170
176
  AGENTS.md answers "how should AI work here"; the design doc answers "why is it
171
- built this way" (principles, iron rules, the append-only decision log, currently D1–D68). Before changing
177
+ built this way" (principles, iron rules, the append-only decision log, currently D1–D73). Before changing
172
178
  architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
173
179
  schema, MCP tools, CLI contract), read the relevant design section — the decision
174
180
  log records what was already considered and rejected.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,46 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## The Node floor is 22.14, because the driver says so (D74)
13
+
14
+ - **Fixed: every command could die with no output on Node older than 22.14.** `better-sqlite3` 13 (the D72 N-API line) is compiled against Node-API 10, which Node only gained in **22.14.0**. Below that, `require()` succeeds and the first `new Database()` segfaults the process - no message, no stack, exit 139 on macOS/Linux and `0xC0000005` on Windows. Reproduced on win32-x64 with Node 22.12.0; Node 22.23.3 and 24.20.0 are fine, and Node 20 can never work with v13 (upstream [WiseLibs/better-sqlite3#1514](https://github.com/WiseLibs/better-sqlite3/issues/1514), open). open-memex now refuses to load the driver below the floor and says exactly what to do (upgrade Node, or pin `better-sqlite3@^12.11.1`) instead of dying.
15
+ - **`engines.node` corrected to `>=22.14.0`.** It claimed `>=22.6`, which was the `--experimental-strip-types` floor - a different constraint that predates the driver bump, and wrong for the driver.
16
+ - **`open-memex doctor` gained a `sqlite driver` check** that loads the driver and opens an in-memory database in a **child process** (a segfault cannot be caught in-process) and reports the exit code or signal. It resolves the driver from this package's own directory, so it works when `doctor` runs with cwd set to a user project.
17
+ - Both READMEs gained a troubleshooting entry for the crash signature (`exit 139` / `0xC0000005` / "Segmentation fault"). No protocol, schema, or data change.
18
+
19
+ ## [0.7.0] - 2026-10-04
20
+
21
+ ## One thing you said is one memory (D73)
22
+
23
+ Saying "remember ." used to save the same statement up to three times: the keyword hook stored your sentence verbatim with no agent in the loop, then the agent - which could see the chat but not the store - saved its own paraphrase beside it, and `memory_add`'s near-duplicate check scored the two too far apart (0.21-0.38 against a 0.80 bar) to notice. `open-memex audit` reported zero near-duplicates while three copies sat in the folder, and search returned all of them.
24
+
25
+ - **The agent is told what was already stored.** When the keyword hook captures a sentence, the plugin records it against the session and the next system context carries a short note naming the stored memory. Do not save that text again in any rewording; save only what it does not contain, or `memory_supersede` it. Delivered once per capture, in memory only (nothing new is written to disk).
26
+ - **`memory_add` refuses the echo anyway.** A write that overlaps a memory captured from your own words in the same scope within the last 10 minutes is refused with the stored id, as a deterministic backstop for when the note is not enough. The bar is deliberately high (0.28 token overlap) and never applies to the agent's own inferences - a distinct fact the same sentence carried still saves normally. A refusal is recoverable: supersede the stored one, or re-save with only the new information.
27
+ - Reports nothing and changes no data: existing memories are not merged or rewritten, and the near-duplicate notice for ordinary agent saves is unchanged.
28
+
29
+ ## Node 24 CLI aborts: SQLite driver moves to the N-API line (D72)
30
+
31
+ - **Fixed: CLI processes intermittently aborted on Node 24.19+.** `better-sqlite3` 11 could finalize a prepared statement from a garbage-collection callback after Node's environment was gone (`RemoveEnvironmentCleanupHook ... Assertion failed`), killing the process with `SIGABRT` and losing its output even though the memory work had already landed. The driver is now `better-sqlite3` 13 (the N-API rewrite; Node �22 unchanged) - same API surface, no protocol or data change. If you build from source on Node 24.19+, reinstall dependencies so the new driver is picked up.
32
+
33
+ ## Non-git folders shared one scope bucket (D71)
34
+
35
+ - **Fixed: every non-git folder on a drive shared a single project scope.** OpenCode v1 reports the filesystem root (`C:\`, `/`) as the project `worktree` when the opened folder is not a git repo, and the plugin seeded its scope on that root — so unrelated folders like `C:\temp` and `C:\scratch` all wrote to one `project__workspace__…` bucket. The scope now seeds on the folder actually opened (`pickScopeRoot` in `src/scope.ts`); real repo worktrees are unchanged. Memories already saved under the shared bucket are not moved automatically: run `open-memex scopes` to spot it, then `open-memex migrate --from <old-key>`.
36
+
37
+ ## Visibility follow-ups: one number, one guard, one truth (D70)
38
+
39
+ Review of the D68/D69 inventory surfaces — each was honest on its own, and they disagreed with each other:
40
+
41
+ - `open-memex list` no longer truncates silently: it renders the same structured line the agent sees and says how many entries it left out (20 of 25 used to look like all 25).
42
+ - Numbers are unique within one listing. `memory_list(scope: both)` and the text report restarted the counter per scope, so a single listing held two `#1`s and "delete #3" was a guess.
43
+ - Hiding an in-repo memory now says what the delete path already said: the retraction is a local working-tree edit until you commit and push it — until then the team still sees the memory.
44
+ - The report's worktree guard fails closed (it used to skip the check when the output directory did not exist yet), explains a missing directory instead of dying with a raw `ENOENT`, and is shared by all three formats instead of existing twice.
45
+ - `inventory --format json` is now `open-memex-inventory/2`: the absolute `file` path is gone — that artifact is meant for an agent, and it carried your home directory with it.
46
+ - An empty scope is reported as `(0)` rather than disappearing; only the HTML audit view loads the hidden rows themselves; the page enforces its "no network requests" promise with a `default-src 'none'` CSP.
47
+
48
+ ## Inventory HTML report (D68 follow-up)
49
+
50
+ - **`open-memex inventory --format html`** renders the same data layer as the text/JSON formats into a single local page: current memories grouped by scope, outbox drafts in their own section, and the replaced/hidden history folded at the bottom with supersede-chain pointers. All content is escaped; the filter box is local show/hide only. Default output is `<data dir>/inventory.html`; the worktree refusal (`--allow-personal`) applies here too. Sections fold when the store grows past 50 current entries.
51
+
12
52
  ## Memory visibility: see it, hide it, delete it (D68)
13
53
 
14
54
  Group 3-C — the surface a user reads to learn what the store actually remembers:
package/README.md CHANGED
@@ -53,7 +53,7 @@ depth once you're up and running.
53
53
 
54
54
  ## Quick start
55
55
 
56
- You need **Node.js ≥ 22.6** (check with `node -v`). Then:
56
+ You need **Node.js ≥ 22.14** (check with `node -v`). Then:
57
57
 
58
58
  ```sh
59
59
  npm install -g open-memex
@@ -163,7 +163,9 @@ no one remembers to write down.
163
163
 
164
164
  ### Requirements
165
165
 
166
- - **Node.js ≥ 22.6** (`open-memex doctor` verifies this for you)
166
+ - **Node.js ≥ 22.14** (`open-memex doctor` verifies this for you). The floor is
167
+ the SQLite driver's: `better-sqlite3` 13 is built against Node-API 10, which
168
+ Node gained in 22.14.0 — older Node segfaults on the first database open.
167
169
 
168
170
  ### Install the CLI
169
171
 
@@ -375,7 +377,11 @@ Three ways memories get in:
375
377
  `don't forget the wifi password` never fire (add a separator — `别忘了:…`,
376
378
  `don't forget: …` — or `that` to make it an instruction); and a captured body
377
379
  under 3 characters is rejected as a fragment rather than saved — `capture
378
- --dry-run` says so instead of dropping it silently.
380
+ --dry-run` says so instead of dropping it silently. A trigger also owns its
381
+ own sentence: the plugin tells the agent what it just stored, and a
382
+ `memory_add` that re-saves the same words within the next few minutes is
383
+ refused with the stored id (D73) — one thing you said is one memory, not
384
+ three copies of it.
379
385
  - **The agent saves it**: in any editor, ask your agent to remember something
380
386
  (or it saves on its own when you state a fact worth keeping) — it calls
381
387
  `memory_add`. The routing above is a heuristic; you can always say "save this
@@ -651,9 +657,11 @@ open-memex forget <id> --soft # hide instead of delete (retracted; one-way)
651
657
 
652
658
  open-memex inventory # everything remembered, as readable text
653
659
  open-memex inventory --format json # the same data, for agents
660
+ open-memex inventory --format html # the same data as a local page (writes <data dir>/inventory.html)
654
661
  # Personal + current project, outbox drafts in their own section, hidden
655
662
  # history counted. Refuses to write a personal-bearing report inside a
656
663
  # git working tree unless you pass --allow-personal.
664
+ # `list` numbers every entry in one listing and says how many it left out.
657
665
  ```
658
666
 
659
667
  Team review workflow (two homes, one per stage):
@@ -799,6 +807,18 @@ walks through the mental model end to end.
799
807
 
800
808
  ## Troubleshooting
801
809
 
810
+ **Every command dies with no output, or the process crashes (`exit 139`,
811
+ `0xC0000005`, "Segmentation fault").**
812
+ Your Node is older than **22.14** and the bundled SQLite driver cannot load on
813
+ it. `better-sqlite3` 13 is compiled against Node-API 10, which Node only gained
814
+ in 22.14.0; on anything older `require()` succeeds and then the first database
815
+ open segfaults the process with no diagnostic. open-memex now refuses to load
816
+ the driver and says so, but anything already crashing was almost certainly this.
817
+ Check with `node -v`, then either upgrade Node (`nvm install 22.14 && nvm use
818
+ 22.14`, or any current 22.x/24.x) or pin the driver down with `npm install
819
+ better-sqlite3@^12.11.1`. `open-memex doctor` reports both the Node floor and a
820
+ live driver probe. Known upstream: WiseLibs/better-sqlite3#1514.
821
+
802
822
  **`open-memex` is not recognized / command not found.**
803
823
  A global `npm install -g` puts the `open-memex` launcher in npm's global bin
804
824
  folder. If your terminal can't find it, that folder isn't on your `PATH`:
package/README.zh-CN.md CHANGED
@@ -42,7 +42,7 @@
42
42
 
43
43
  ## 快速开始
44
44
 
45
- 需要 **Node.js ≥ 22.6**(用 `node -v` 检查)。然后:
45
+ 需要 **Node.js ≥ 22.14**(用 `node -v` 检查)。然后:
46
46
 
47
47
  ```sh
48
48
  npm install -g open-memex
@@ -118,7 +118,7 @@ open-memex 用两种方式接编辑器:原生的 **opencode 插件**,和标
118
118
 
119
119
  ### 环境要求
120
120
 
121
- - **Node.js ≥ 22.6**(`open-memex doctor` 会替你核查)
121
+ - **Node.js ≥ 22.14**(`open-memex doctor` 会替你核查)。这个下限来自 SQLite 驱动:`better-sqlite3` 13 是按 Node-API 10 编译的,而 Node 直到 22.14.0 才支持它——更老的 Node 在第一次打开数据库时会直接段错误崩溃。
122
122
 
123
123
  ### 安装 CLI
124
124
 
@@ -252,7 +252,7 @@ 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 …`、`help me remember: …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。两条降噪规则(D67):触发词必须是**对存储说的话**,所以叙述型的 `记得…` / `remind me to…` / `别忘了带伞` / `don't forget the wifi password` 一律不触发(加一个分隔符——`别忘了:…`、`don't forget: …`——或用 `that`,才算指令);捕获内容不足 3 个字符按碎片丢弃,不会存下来——`capture --dry-run` 会明确说明,不再悄悄吞掉。
255
+ - **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget: …`、`TIL …`、`save this …`——中文 `记住…` / `记一下…` / `记录一下…` / `别忘了:…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`help me remember: …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。两条降噪规则(D67):触发词必须是**对存储说的话**,所以叙述型的 `记得…` / `remind me to…` / `别忘了带伞` / `don't forget the wifi password` 一律不触发(加一个分隔符——`别忘了:…`、`don't forget: …`——或用 `that`,才算指令);捕获内容不足 3 个字符按碎片丢弃,不会存下来——`capture --dry-run` 会明确说明,不再悄悄吞掉。另外,一句触发捕获的话只归一条记忆:插件会把它刚存下的内容告诉助手,几分钟内再用 `memory_add` 重复存同样的字眼会被拒绝(并给出已存的 id,D73)——你说的一件事就是一条记忆,不是它的三个副本。
256
256
  - **助手主动保存**:在任何编辑器里,你让助手记住某件事(或者你陈述了一个值得记的事实、它自己判断该保存),它会调用 `memory_add`。上面的路由是启发式规则;你可以明说"存到我的个人记忆",或用 CLI 加 `--scope` 明确指定。
257
257
  - **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
258
258
  - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tag` / `--type` / `--aliases`。
@@ -451,8 +451,10 @@ open-memex forget <id> --soft # 隐藏而不是删除(retracted,单向
451
451
 
452
452
  open-memex inventory # 记住的一切,纯文本清单
453
453
  open-memex inventory --format json # 同一份数据,JSON 格式给 agent 用
454
+ open-memex inventory --format html # 同一份数据的本地网页(写到 <数据目录>/inventory.html)
454
455
  # 个人 + 当前项目,outbox 草稿单独成节,被藏起的历史只计数。含个人内容的
455
456
  # 报告拒绝写进 git 工作树,除非显式 --allow-personal。
457
+ # `list` 在同一份清单里连续编号,并说明自己少显示了哪些条目。
456
458
  ```
457
459
 
458
460
  团队评审工作流(两个家,一个阶段一个):
@@ -559,6 +561,9 @@ npx -y open-memex mcp # 无需安装
559
561
 
560
562
  ## 故障排查
561
563
 
564
+ **每条命令都毫无输出地死掉,或进程直接崩溃(`exit 139`、`0xC0000005`、"Segmentation fault")。**
565
+ 你的 Node 比 **22.14** 老,内置的 SQLite 驱动在它上面加载不了。`better-sqlite3` 13 是按 Node-API 10 编译的,而 Node 直到 22.14.0 才支持;在更老的版本上 `require()` 会成功,然后第一次打开数据库就把进程段错误掉,没有任何诊断信息。open-memex 现在会拒绝加载驱动并明确告诉你,但如果之前就是毫无输出一路崩溃,基本可以确定是这个原因。用 `node -v` 确认后,二选一:升级 Node(`nvm install 22.14 && nvm use 22.14`,或任意当前的 22.x/24.x),或者把驱动降回去:`npm install better-sqlite3@^12.11.1`。`open-memex doctor` 会同时报告 Node 下限和一次真实的驱动探测。上游已知问题:WiseLibs/better-sqlite3#1514。
566
+
562
567
  **`open-memex` 无法识别 / 命令找不到。**
563
568
  全局 `npm install -g` 把 `open-memex` 启动器放在 npm 的全局 bin 文件夹里。终端找不到命令,就是这个文件夹不在 PATH 上:
564
569
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 3a590ae176c625e174a075a2d9068fe443d23c374f70fc7df76809ff10a63ad4
1
+ e9cc36b1db649d899fdd7298be01622709e4f6d8d4e583764c294bd009cfd0e6
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Turn hand-off between keyword capture and the agent (D73).
3
+ *
4
+ * The keyword hook and the agent are two writers for one user turn. The hook
5
+ * stores the user's sentence verbatim, with no agent in the loop; the agent
6
+ * then sees only the chat transcript and cannot tell the sentence is already
7
+ * stored, so it saves its own paraphrase — the same statement lands two or
8
+ * three times (observed 2026-10-04: one `remember …` sentence → one
9
+ * `source: keyword` file plus two `source: tool` files).
10
+ *
11
+ * Two mechanisms, in this order:
12
+ * (a) the hand-off note — this module's `formatHandoffBlock`, pushed into the
13
+ * turn's system context so the agent knows what is already stored;
14
+ * (b) the turn-echo guard — `TURN_ECHO_THRESHOLD` below, enforced in
15
+ * `addMemory` (src/tools/ops.ts) over what the hook just wrote.
16
+ *
17
+ * Pure module: no db, no fs, no config, so the threshold behavior is testable
18
+ * in scripts/smoke-pure.ts.
19
+ */
20
+ /**
21
+ * Overlap bar for refusing a re-save of a just-captured user statement.
22
+ *
23
+ * Deliberately high, and only ever applied to memories the *hook* wrote
24
+ * (`source: keyword` / `user`) inside `TURN_ECHO_WINDOW_MS`. Measured on the
25
+ * reported case and its near neighbours (token Jaccard, see
26
+ * `similarity` in src/store/lifecycle.ts): a wholesale re-save of the captured
27
+ * sentence scores 0.29–0.36, while a genuinely distinct fact the same
28
+ * sentence also contained scores 0.21 and below. Lexical overlap cannot
29
+ * separate an agent's gloss from a new fact — those two measured 0.211 and
30
+ * 0.214 — so the guard sits at the high-confidence end and everything under
31
+ * the bar is left to (a) and the agent's judgment. A refusal is always
32
+ * reported with the stored id, so the caller can supersede or rephrase.
33
+ */
34
+ export const TURN_ECHO_THRESHOLD = 0.28;
35
+ /** How long a hook capture counts as "this turn" (10 minutes). */
36
+ export const TURN_ECHO_WINDOW_MS = 10 * 60 * 1000;
37
+ export function isTurnEcho(score) {
38
+ return score >= TURN_ECHO_THRESHOLD;
39
+ }
40
+ /** Records still inside the window. */
41
+ export function freshCaptures(records, now) {
42
+ return records.filter((r) => now - r.at <= TURN_ECHO_WINDOW_MS);
43
+ }
44
+ function preview(content) {
45
+ const one = content.replace(/\s+/g, " ").trim();
46
+ return one.length > 160 ? `${one.slice(0, 160)}…` : one;
47
+ }
48
+ /** The hand-off note (a). Empty string when there is nothing to hand off. */
49
+ export function formatHandoffBlock(records) {
50
+ if (records.length === 0)
51
+ return "";
52
+ const lines = records.map((r) => `- id=${r.id} (${r.scopeKey}) — "${preview(r.content)}"`);
53
+ return [
54
+ "[open-memex] The user's own wording in this turn was ALREADY stored verbatim, before you saw it. It is not unsaved.",
55
+ ...lines,
56
+ "Do not call memory_add to save that text again in any rewording — one statement is already one memory, and a rewording only adds a second copy that retrieval will return alongside the first.",
57
+ "Save a memory only for information those lines do NOT contain, as its own self-contained statement. If your version is meant to replace one of them, call memory_supersede with that id instead of memory_add.",
58
+ ].join("\n");
59
+ }
package/dist/cli.js CHANGED
@@ -9,20 +9,34 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.js";
9
9
  import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.js";
10
10
  import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
11
11
  import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
12
- import { search, list, hitStateLabel } from "./retrieve/search.js";
12
+ import { search, list, countList, hitStateLabel } from "./retrieve/search.js";
13
+ import { formatInventoryLine, truncationNote } from "./retrieve/display.js";
13
14
  import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, normalizeAliases, } from "./store/markdown.js";
14
15
  import { loadConfig, configSource } from "./config.js";
15
- import { paths, projectRoot } from "./paths.js";
16
+ import { paths, projectRoot, isInRepoMemoryFile } from "./paths.js";
16
17
  import { redact } from "./redact.js";
17
18
  import { resolveMcpCommand } from "./init.js";
18
19
  import fs from "node:fs";
19
20
  import path from "node:path";
20
21
  import { execFileSync } from "node:child_process";
21
22
  import { fileURLToPath } from "node:url";
22
- /** True when `target` sits inside a git working tree (D68 guard). */
23
+ /**
24
+ * True when `target` sits inside a git working tree (D68 guard).
25
+ *
26
+ * D70: resolve the nearest EXISTING ancestor first. `git -C` on a
27
+ * directory that does not exist throws, and the old code turned that
28
+ * into "not in a worktree" — a fail-open guard on the one path where
29
+ * personal memories would ride a commit.
30
+ */
23
31
  function isInsideWorkTree(target) {
24
32
  try {
25
- const dir = path.dirname(path.resolve(target));
33
+ let dir = path.dirname(path.resolve(target));
34
+ while (!fs.existsSync(dir)) {
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir)
37
+ break;
38
+ dir = parent;
39
+ }
26
40
  const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
27
41
  stdio: ["ignore", "pipe", "ignore"],
28
42
  encoding: "utf8",
@@ -33,6 +47,24 @@ function isInsideWorkTree(target) {
33
47
  return false;
34
48
  }
35
49
  }
50
+ /**
51
+ * D70: the one place that decides where a report may be written, for
52
+ * every `inventory` format. Returns an error message to print, or null
53
+ * when the path is safe. Kept single so the text/json/html paths cannot
54
+ * drift apart (they already had two copies, D68).
55
+ */
56
+ function inventoryWriteRefusal(target, includesPersonal, allowPersonal) {
57
+ // Personal content must not ride a commit (the design's hardest rule).
58
+ if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
59
+ return (`inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
60
+ `Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`);
61
+ }
62
+ const parent = path.dirname(path.resolve(target));
63
+ if (!fs.existsSync(parent)) {
64
+ return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
65
+ }
66
+ return null;
67
+ }
36
68
  /** Per-command help, printed by `open-memex <command> --help`.
37
69
  AI assistants discover the CLI through --help, so every command needs one. */
38
70
  const COMMAND_HELP = {
@@ -42,17 +74,21 @@ Usage: open-memex where
42
74
 
43
75
  Example:
44
76
  open-memex where`,
45
- list: `List memories in a scope, newest first.
77
+ list: `List memories in a scope, newest first, as a numbered inventory.
46
78
 
47
79
  Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
48
80
 
49
81
  Flags:
50
82
  --scope project (default), personal, or both
51
83
  --type filter by memory type
52
- --limit max results
84
+ --limit max results (default 20, max 100)
53
85
  --include active (default: what is currently remembered) or all (audit
54
86
  view: superseded versions, retracted and archived included)
55
87
 
88
+ A cut list says how many entries it left out. Numbers are unique within one
89
+ listing, so "the third one" names exactly one memory - but they are only
90
+ valid for the listing you just ran.
91
+
56
92
  Examples:
57
93
  open-memex list
58
94
  open-memex list --scope personal --limit 20`,
@@ -104,7 +140,8 @@ Example:
104
140
  open-memex status 01ABC deprecated`,
105
141
  forget: `Delete a memory by id. With --soft, hide it instead: the memory
106
142
  is retracted (out of lists and search, file kept). Retraction is one-way —
107
- save the content as a new memory to bring it back.
143
+ save the content as a new memory to bring it back. If the memory lives in
144
+ the repo, either action is only local until you commit and push it.
108
145
 
109
146
  Usage: open-memex forget <id> [--soft]
110
147
 
@@ -160,16 +197,18 @@ Example:
160
197
  inventory: `Show everything remembered — personal + this project — in the
161
198
  open: what each memory says, where it came from, and whether it has reached
162
199
  the repo yet. Read-only; deleting stays a confirmed conversation with your
163
- agent. Formats: text (default, diffable) and json (for agents).
200
+ agent. Formats: text (default, diffable), json (for agents), html (a local
201
+ page for your browser; default writes <data dir>/inventory.html).
164
202
 
165
- Usage: open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
203
+ Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
166
204
 
167
- Default scope: both. Without --out the report goes to stdout. A report that
205
+ Default scope: both. Without --out, text/json go to stdout. A report that
168
206
  includes personal memories is refused inside a git working tree unless
169
207
  --allow-personal is passed — personal content must not ride a commit.
170
208
 
171
209
  Example:
172
- open-memex inventory --format json --out ~/inventory.json`,
210
+ open-memex inventory --format json --out ~/inventory.json
211
+ open-memex inventory --format html`,
173
212
  pull: `Pull shared project memories from the git remote: fetch + fast-forward
174
213
  only. Never auto-merges — a diverged branch fails with a clear message and is
175
214
  left for you to resolve by hand. On success the local index re-syncs.
@@ -377,7 +416,7 @@ Usage:
377
416
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
378
417
  open-memex resolve [id-or-path]
379
418
  open-memex sync-status
380
- open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
419
+ open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
381
420
  open-memex pull
382
421
  open-memex push
383
422
  open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
@@ -812,25 +851,38 @@ async function main() {
812
851
  const flags = parseFlags(rest);
813
852
  // D66: --scope both, on par with the memory_list tool — prints each
814
853
  // scope's newest under its own header.
815
- const scopes = flags.scope === "both" ? [project, PERSONAL_SCOPE] : [resolveCliScope(flags, project)];
854
+ const scopes = flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
855
+ const include = flags.include === "all" ? "all" : "active";
856
+ // D70: the CLI renders the SAME structured inventory the agent sees,
857
+ // numbering runs across the whole listing, and a cut list says how
858
+ // many entries it dropped — `open-memex list` is the surface a
859
+ // sceptical user runs first, so it may not look complete when it isn't.
860
+ let seq = 0;
816
861
  for (const s of scopes) {
817
862
  syncScope(s.key, "cli");
818
863
  const hits = list(s.key, {
819
864
  type: flags.type,
820
865
  limit: flags.limit ? Number(flags.limit) : undefined,
821
- include: flags.include === "all" ? "all" : "active",
866
+ include,
822
867
  });
823
- if (scopes.length > 1)
824
- console.log(`## ${s.kind} (${s.key})`);
868
+ const total = countList(s.key, { type: flags.type, include });
869
+ if (scopes.length > 1) {
870
+ console.log(s.key === PERSONAL_SCOPE.key
871
+ ? "## About you (personal — stays on this machine, applies everywhere)"
872
+ : `## This project (${s.projectName || s.key})`);
873
+ }
825
874
  if (hits.length === 0) {
826
- console.log(`(no memories in ${s.key})`);
875
+ console.log(scopes.length > 1
876
+ ? "(nothing remembered in this scope yet)"
877
+ : `(no memories in ${s.key})`);
827
878
  continue;
828
879
  }
829
880
  for (const h of hits) {
830
- const dep = h.status !== "active" ? ` [${h.status}]` : "";
831
- const rev = hitStateLabel(h);
832
- console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
881
+ console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
833
882
  }
883
+ const note = truncationNote(hits.length, total);
884
+ if (note)
885
+ console.log(note);
834
886
  }
835
887
  return;
836
888
  }
@@ -1002,10 +1054,16 @@ async function main() {
1002
1054
  // D68: --soft hides (retracts) instead of deleting — same action as
1003
1055
  // the memory_forget tool's soft flag. One-way (D64).
1004
1056
  if (rest.includes("--soft")) {
1057
+ const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
1005
1058
  try {
1006
1059
  const mf = setStatus(id, "retracted");
1007
1060
  upsertFromFile(mf);
1008
1061
  console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
1062
+ // D70: same honesty as the hard delete — a retraction of an in-repo
1063
+ // memory is a local working-tree edit until it is committed and pushed.
1064
+ if (target && isInRepoMemoryFile(target.file_path)) {
1065
+ console.log(`note: that memory lives in the repo (.ai/open-memex/) — the retraction is only local until you commit and push it; until then the team still sees it.`);
1066
+ }
1009
1067
  }
1010
1068
  catch (e) {
1011
1069
  console.error(`hide failed: ${e.message}`);
@@ -1157,8 +1215,8 @@ async function main() {
1157
1215
  syncScope(PERSONAL_SCOPE.key, "cli");
1158
1216
  const flags = parseFlags(rest);
1159
1217
  const format = flags.format ?? "text";
1160
- if (format !== "text" && format !== "json") {
1161
- console.error(`inventory: unknown --format '${format}' (text|json; html arrives separately)`);
1218
+ if (format !== "text" && format !== "json" && format !== "html") {
1219
+ console.error(`inventory: unknown --format '${format}' (text|json|html)`);
1162
1220
  process.exit(2);
1163
1221
  }
1164
1222
  const scopeKeys = flags.scope === "personal"
@@ -1167,22 +1225,38 @@ async function main() {
1167
1225
  ? [project.key]
1168
1226
  : [PERSONAL_SCOPE.key, project.key];
1169
1227
  const outPath = flags["out"] ?? flags["output"];
1170
- // D68/3-C v2: the report contains personal content. Writing it into
1171
- // a git working tree would ride the next commit off this machine —
1172
- // refuse unless the user explicitly accepts that.
1173
- if (outPath &&
1174
- scopeKeys.includes(PERSONAL_SCOPE.key) &&
1175
- flags["allow-personal"] !== "true" &&
1176
- isInsideWorkTree(outPath)) {
1177
- console.error(`inventory: ${outPath} is inside a git working tree and this report includes personal memories. ` +
1178
- `Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`);
1179
- process.exit(2);
1180
- }
1228
+ const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
1229
+ const allowPersonal = flags["allow-personal"] === "true";
1181
1230
  try {
1182
1231
  const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.js");
1232
+ if (format === "html") {
1233
+ const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.js");
1234
+ const target = outPath ?? defaultHtmlOutPath(paths().root);
1235
+ // D70: one guard for every format (this path had its own copy).
1236
+ const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
1237
+ if (refusal) {
1238
+ console.error(refusal);
1239
+ process.exit(2);
1240
+ }
1241
+ // D70: only the audit surface needs the hidden rows themselves;
1242
+ // text/json report counts, so they must not pay to load them.
1243
+ const data = loadInventory(scopeKeys, { hidden: "entries" });
1244
+ fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
1245
+ console.log(`inventory: ${data.total} memories → ${target}`);
1246
+ console.log(`open it in any browser; the report is read-only.`);
1247
+ return;
1248
+ }
1183
1249
  const data = loadInventory(scopeKeys);
1184
1250
  const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
1185
1251
  if (outPath) {
1252
+ // D68/3-C v2: the report contains personal content. Writing it into
1253
+ // a git working tree would ride the next commit off this machine —
1254
+ // refuse unless the user explicitly accepts that.
1255
+ const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
1256
+ if (refusal) {
1257
+ console.error(refusal);
1258
+ process.exit(2);
1259
+ }
1186
1260
  fs.writeFileSync(outPath, rendered, "utf8");
1187
1261
  console.log(`inventory: ${data.total} memories → ${outPath}`);
1188
1262
  }