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

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
@@ -33,6 +33,7 @@ npm run typecheck # tsc --noEmit — the only
33
33
  npm run cli -- where | list | search "q" | add ... | forget <id> | reindex
34
34
  npm run cli -- <command> --help # per-command help (AI assistants discover flags this way)
35
35
  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
36
37
  npm run cli -- audit [--scope project|personal|both] # memory health: near-dup pairs, stale actives, broken chains, personal-in-repo (read-only)
37
38
  npm run cli -- pull # fetch + fast-forward only (explicit; diverged = clean failure, never force-merge)
38
39
  npm run cli -- push # push current branch to remote (explicit only; open-memex never auto-pushes)
@@ -157,15 +158,17 @@ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
157
158
  - 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.
158
159
  - Frontmatter is `schema_version: 2`. The SQLite index schema is versioned separately and rebuilds automatically on version change — never hand-edit `index.db`.
159
160
 
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.
161
+ 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
+
163
+ 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.
161
164
 
162
165
  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.
163
166
 
164
167
  ## Design constraints — read the frozen design first
165
168
 
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:
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:
167
170
  AGENTS.md answers "how should AI work here"; the design doc answers "why is it
168
- built this way" (principles, iron rules, D1–D49 decision log). Before changing
171
+ built this way" (principles, iron rules, the append-only decision log, currently D1–D68). Before changing
169
172
  architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
170
173
  schema, MCP tools, CLI contract), read the relevant design section — the decision
171
174
  log records what was already considered and rejected.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,33 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Memory visibility: see it, hide it, delete it (D68)
13
+
14
+ Group 3-C — the surface a user reads to learn what the store actually remembers:
15
+
16
+ - **`memory_list` is now a numbered inventory.** Lines are structured (`[type] id=… created=… source=…`), keep the raw provenance, and every listing states its true total — a truncated list says how many entries it is not showing instead of quietly looking complete. New input `include: "active" | "all"` (default `active`): **behavior change** — superseded versions, retracted and archived memories no longer appear unless you pass `include=all` (the audit view). The CLI gains the matching `list --include` flag.
17
+ - **`open-memex inventory`** renders the same data as readable text (default) or JSON (`--format json`, format `open-memex-inventory/1`) — personal plus the current project, outbox drafts in their own section, hidden history counted rather than silently dropped. A report containing personal memories refuses to be written inside a git working tree without `--allow-personal`.
18
+ - **Hide instead of delete:** `memory_forget` gains `soft=true` (CLI: `forget --soft`) — the memory is retracted: out of lists and search, file kept. Still one-way (D64): bringing the fact back means saving it again.
19
+ - **Guidance kept in one voice:** the tool descriptions, the MCP session-start instructions, SKILL.md and the `distill-agents` AGENTS.md snippet all teach the same flow — answer "what do you remember?" conversationally from the inventory, re-list before acting on a number, read an entry back before deleting it.
20
+
21
+ ## Keyword capture: what counts as a trigger (D67)
22
+
23
+ The D66 review left two questions open and introduced one regression. Ruled, not deferred:
24
+
25
+ - **Fixed a regression:** D66's separator boundary was one notch too strict — it also demanded a separator after a *complete* verb phrase, so `帮我记一下这个配置`, `帮我记住这个配置`, `替我记一下我住在杭州` and `帮我们记一下这个约定` stopped matching. The boundary now guards the bare verb only, so `帮我记得…` still can't produce mid-word garbage.
26
+ - **The ≥3-character body floor stays, but is no longer silent.** `记住:这个` is a fragment, not a memory, so it isn't captured — but `open-memex capture --dry-run` now says why, and `logLevel: debug` logs it. A rejected *personal* match also keeps its line, so `记住我:OK` can no longer fall through to the generic 记住 and be saved as `我:OK`.
27
+ - **Narration is not a trigger.** `记得…`, `remind me to…` and friends stay out on purpose (a false trigger writes unreviewed memory; a missed one costs a sentence). The ambiguous forms already in the list now need an explicit marker: `别忘了:…`, `don't forget: …` and `don't forget that …` capture; bare `别忘了带伞` and `don't forget the wifi password` do not. The `note` family is unchanged.
28
+ - **Test tooling:** the stale-`dist/` guard is now a content fingerprint (`scripts/build-stamp.mjs`) instead of mtimes, which a branch switch could defeat; the agent-facing `memory_status` cap (20 per section + a "full list" pointer) is pinned again after D66's test replaced that coverage.
29
+
30
+ ## Review follow-ups (D66)
31
+
32
+ Fixes from the follow-up review of the D63–D65 stack:
33
+
34
+ - Keyword patterns: `请帮我记住…` works (the D65 pattern had missed its `请` prefix), `帮我记录一下:…` / `替我记录一下:…` capture cleanly instead of saving mid-word garbage, `帮我记得…` no longer fires, and `help me remember: …` routes personal like `remember for me`.
35
+ - `open-memex sync-status` (the CLI) shows the full list again — the 20-per-section cap now applies only to agent tool results, whose "… and N more" line points at the CLI. `open-memex list --scope both` matches the `memory_list` tool.
36
+ - `open-memex status --help` documents that retraction is one-way.
37
+ - The full test suite is Windows-safe now (file:// probe imports, USERPROFILE-aware uninstall test) and refuses to run against a stale `dist/` build.
38
+
12
39
  ## Keyword capture gaps (D65)
13
40
 
14
41
  The most natural phrasings were the ones that fell through:
package/README.md CHANGED
@@ -364,12 +364,18 @@ snippet to paste instead.
364
364
  Three ways memories get in:
365
365
 
366
366
  - **Keyword triggers** (opencode native plugin only): say `remember …`,
367
- `note that …`, `don't forget …`, `TIL …`, `save this …` — or in Chinese
368
- `记住…` / `记一下` / `记录一下` / `别忘了…` — and the sentence is captured
367
+ `note that …`, `don't forget: …`, `TIL …`, `save this …` — or in Chinese
368
+ `记住…` / `记一下…` / `记录一下…` / `别忘了:…` — and the sentence is captured
369
369
  without any tool call. Captures land in the current **project** by default;
370
- phrases that signal "this is about me" — `remember for me …`, `记住我…`,
371
- `替我记…`, `帮我记…`, `我觉得…`, `我喜欢…` — go to **personal** instead, and
370
+ phrases that signal "this is about me" — `remember for me …`, `help me remember: …`,
371
+ `记住我…`, `替我记…`, `帮我记…`, `我觉得…`, `我喜欢…` — go to **personal** instead, and
372
372
  team-context phrases (`我们决定…`, `帮我们记住…`) stay in project.
373
+ Two rules keep the noise down (D67): a trigger must be a *statement to the
374
+ store*, so the narration forms `记得…` / `remind me to…` / `别忘了带伞` /
375
+ `don't forget the wifi password` never fire (add a separator — `别忘了:…`,
376
+ `don't forget: …` — or `that` to make it an instruction); and a captured body
377
+ under 3 characters is rejected as a fragment rather than saved — `capture
378
+ --dry-run` says so instead of dropping it silently.
373
379
  - **The agent saves it**: in any editor, ask your agent to remember something
374
380
  (or it saves on its own when you state a fact worth keeping) — it calls
375
381
  `memory_add`. The routing above is a heuristic; you can always say "save this
@@ -410,9 +416,9 @@ they only matter once you share memories through Git.
410
416
  |---|---|
411
417
  | ● `memory_add` | Save a fact, preference, decision, note |
412
418
  | ● `memory_search` | Keyword search (BM25) across project + personal memories |
413
- | ● `memory_list` | List memories in a scope (`project`, `personal`, or `both`), newest first |
419
+ | ● `memory_list` | List memories in a scope (`project`, `personal`, or `both`) as a numbered inventory; `include=all` is the audit view |
414
420
  | ● `memory_supersede` | Replace a memory with a newer version (keeps a supersede chain) |
415
- | ● `memory_forget` | Delete a memory by id |
421
+ | ● `memory_forget` | Delete a memory by id (`soft=true` hides it instead — retracted, one-way) |
416
422
  | `memory_status` | Show the sync queue: outbox drafts, repo review states, uncommitted files |
417
423
  | `memory_submit` | Move named drafts into the repo memory dir (local branch + commit) |
418
424
  | `memory_propose` | Copy personal memories into the project scope as review candidates |
@@ -636,9 +642,18 @@ open-memex add "This repo uses better-sqlite3" --type fact
636
642
  open-memex search "auth flow"
637
643
  open-memex search "auth flow" --explain # show FTS expression, scores, lifecycle-hidden counts
638
644
  open-memex list --scope project
645
+ open-memex list --scope both # each scope's newest under its own header
646
+ open-memex list --include all # audit view: superseded versions, retracted, archived
639
647
  open-memex supersede <id> "Updated content"
640
648
  open-memex status <id> deprecated
641
649
  open-memex forget <id>
650
+ open-memex forget <id> --soft # hide instead of delete (retracted; one-way)
651
+
652
+ open-memex inventory # everything remembered, as readable text
653
+ open-memex inventory --format json # the same data, for agents
654
+ # Personal + current project, outbox drafts in their own section, hidden
655
+ # history counted. Refuses to write a personal-bearing report inside a
656
+ # git working tree unless you pass --allow-personal.
642
657
  ```
643
658
 
644
659
  Team review workflow (two homes, one per stage):
package/README.zh-CN.md CHANGED
@@ -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 …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。
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` 会明确说明,不再悄悄吞掉。
256
256
  - **助手主动保存**:在任何编辑器里,你让助手记住某件事(或者你陈述了一个值得记的事实、它自己判断该保存),它会调用 `memory_add`。上面的路由是启发式规则;你可以明说"存到我的个人记忆",或用 CLI 加 `--scope` 明确指定。
257
257
  - **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
258
258
  - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tag` / `--type` / `--aliases`。
@@ -275,9 +275,9 @@ MCP 服务器暴露十一个工具;opencode 原生插件暴露其中五个核
275
275
  |---|---|
276
276
  | ● `memory_add` | 保存一条事实、偏好、决策、笔记 |
277
277
  | ● `memory_search` | 关键词搜索(BM25),横跨项目 + 个人记忆 |
278
- | ● `memory_list` | 列出某个作用域(`project`、`personal` 或 `both`)的记忆,最新在前 |
278
+ | ● `memory_list` | 以编号清单列出某个作用域(`project`、`personal` 或 `both`)的记忆;`include=all` 是审计视图 |
279
279
  | ● `memory_supersede` | 用新版本替换一条记忆(保留 supersede 链) |
280
- | ● `memory_forget` | 按 id 删除一条记忆 |
280
+ | ● `memory_forget` | 按 id 删除一条记忆(`soft=true` 改为隐藏——retracted,单向) |
281
281
  | `memory_status` | 显示同步队列:outbox 草稿、仓库评审状态、未提交文件 |
282
282
  | `memory_submit` | 把指定的草稿移进仓库记忆目录(本地分支 + 提交) |
283
283
  | `memory_propose` | 把个人记忆复制到项目作用域,作为评审候选 |
@@ -442,9 +442,17 @@ open-memex add "This repo uses better-sqlite3" --type fact
442
442
  open-memex search "auth flow"
443
443
  open-memex search "auth flow" --explain # 显示 FTS 表达式、分数、被生命周期藏掉的计数
444
444
  open-memex list --scope project
445
+ open-memex list --scope both # 两个作用域各自最新的,各带一个标题
446
+ open-memex list --include all # 审计视图:被替换的版本、已撤回、已归档
445
447
  open-memex supersede <id> "Updated content"
446
448
  open-memex status <id> deprecated
447
449
  open-memex forget <id>
450
+ open-memex forget <id> --soft # 隐藏而不是删除(retracted,单向)
451
+
452
+ open-memex inventory # 记住的一切,纯文本清单
453
+ open-memex inventory --format json # 同一份数据,JSON 格式给 agent 用
454
+ # 个人 + 当前项目,outbox 草稿单独成节,被藏起的历史只计数。含个人内容的
455
+ # 报告拒绝写进 git 工作树,除非显式 --allow-personal。
448
456
  ```
449
457
 
450
458
  团队评审工作流(两个家,一个阶段一个):
@@ -0,0 +1 @@
1
+ 3a590ae176c625e174a075a2d9068fe443d23c374f70fc7df76809ff10a63ad4
@@ -1,15 +1,27 @@
1
1
  /**
2
- * Scan user text for memory-capture keyword triggers.
2
+ * D67: capture-body length bounds. The lower bound rejects fragments — a
3
+ * trigger that yields "这个" or "OK" is a match on the trigger, not a memory,
4
+ * and a 2-char memory is noise in every future session's injection. The floor
5
+ * stays deliberately above 1: D67 considered lowering it and decided against
6
+ * it. What changed is that a rejection is no longer silent (see KeywordDrop).
7
+ */
8
+ export const MIN_CAPTURE_CHARS = 3;
9
+ export const MAX_CAPTURE_CHARS = 2000;
10
+ /**
11
+ * Scan user text for memory-capture keyword triggers, reporting both what
12
+ * would be captured and what matched but was rejected (D67) — a dropped match
13
+ * that says nothing reads as a broken feature.
3
14
  * Each pattern must have a single capture group whose value becomes the memory body.
4
15
  * Personal patterns (cfg.keywordPersonalPatterns) force the personal scope.
5
16
  *
6
17
  * Personal patterns run first and claim their line: an explicit scope signal
7
18
  * (e.g. "记住我对花粉过敏") must not also fire the generic 记住 into the project scope.
8
19
  */
9
- export function detectKeywords(userText, cfg) {
10
- if (!cfg.keywordCaptureEnabled)
11
- return [];
20
+ export function scanKeywords(userText, cfg) {
12
21
  const hits = [];
22
+ const dropped = [];
23
+ if (!cfg.keywordCaptureEnabled)
24
+ return { hits, dropped };
13
25
  const lines = userText.split(/\r?\n/);
14
26
  const claimed = new Set();
15
27
  const scan = (patterns, personal, skipClaimed) => {
@@ -28,16 +40,37 @@ export function detectKeywords(userText, cfg) {
28
40
  if (!m || !m[1])
29
41
  continue;
30
42
  const content = m[1].trim().replace(/[.!?]$/, "").trim();
31
- if (content.length >= 3 && content.length <= 2000) {
43
+ if (personal)
44
+ claimed.add(i);
45
+ if (content.length >= MIN_CAPTURE_CHARS && content.length <= MAX_CAPTURE_CHARS) {
32
46
  hits.push({ content, pattern: src, personal });
33
- if (personal)
34
- claimed.add(i);
35
47
  break; // one hit per pattern per message
36
48
  }
49
+ // Rejected: record it once per pattern. The line stays claimed for
50
+ // personal patterns even here - "记住我:OK" must not fall through to
51
+ // the generic 记住 and be saved as "我:OK" (D67).
52
+ if (!dropped.some((d) => d.pattern === src && d.content === content)) {
53
+ dropped.push({
54
+ content,
55
+ pattern: src,
56
+ personal,
57
+ reason: content.length < MIN_CAPTURE_CHARS ? "too-short" : "too-long",
58
+ });
59
+ }
60
+ break;
37
61
  }
38
62
  }
39
63
  };
40
64
  scan(cfg.keywordPersonalPatterns, true, false);
41
65
  scan(cfg.keywordPatterns, false, true);
42
- return hits;
66
+ if (cfg.logLevel === "debug" && dropped.length > 0) {
67
+ for (const d of dropped) {
68
+ console.error(`[open-memex] keyword match not captured (${d.reason === "too-short" ? `body ${d.content.length} chars < ${MIN_CAPTURE_CHARS}` : `body ${d.content.length} chars > ${MAX_CAPTURE_CHARS}`}): "${d.content}"`);
69
+ }
70
+ }
71
+ return { hits, dropped };
72
+ }
73
+ /** Capture-only view of {@link scanKeywords} (drops are logged, not returned). */
74
+ export function detectKeywords(userText, cfg) {
75
+ return scanKeywords(userText, cfg).hits;
43
76
  }
package/dist/cli.js CHANGED
@@ -17,7 +17,22 @@ import { redact } from "./redact.js";
17
17
  import { resolveMcpCommand } from "./init.js";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
+ import { execFileSync } from "node:child_process";
20
21
  import { fileURLToPath } from "node:url";
22
+ /** True when `target` sits inside a git working tree (D68 guard). */
23
+ function isInsideWorkTree(target) {
24
+ try {
25
+ const dir = path.dirname(path.resolve(target));
26
+ const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ encoding: "utf8",
29
+ });
30
+ return out.trim() === "true";
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
21
36
  /** Per-command help, printed by `open-memex <command> --help`.
22
37
  AI assistants discover the CLI through --help, so every command needs one. */
23
38
  const COMMAND_HELP = {
@@ -29,12 +44,14 @@ Example:
29
44
  open-memex where`,
30
45
  list: `List memories in a scope, newest first.
31
46
 
32
- Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
47
+ Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
33
48
 
34
49
  Flags:
35
- --scope project (default) or personal
36
- --type filter by memory type
37
- --limit max results
50
+ --scope project (default), personal, or both
51
+ --type filter by memory type
52
+ --limit max results
53
+ --include active (default: what is currently remembered) or all (audit
54
+ view: superseded versions, retracted and archived included)
38
55
 
39
56
  Examples:
40
57
  open-memex list
@@ -80,14 +97,20 @@ Example:
80
97
 
81
98
  Usage: open-memex status <id> active|deprecated|retracted|archived
82
99
 
100
+ Retraction is one-way: a retracted memory cannot be set back to active
101
+ (it was withdrawn on purpose). Save the content as a new memory instead.
102
+
83
103
  Example:
84
104
  open-memex status 01ABC deprecated`,
85
- forget: `Delete a memory by id.
105
+ forget: `Delete a memory by id. With --soft, hide it instead: the memory
106
+ 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.
86
108
 
87
- Usage: open-memex forget <id>
109
+ Usage: open-memex forget <id> [--soft]
88
110
 
89
111
  Example:
90
- open-memex forget 01ABC`,
112
+ open-memex forget 01ABC
113
+ open-memex forget 01ABC --soft`,
91
114
  propose: `Copy personal memories into the project outbox as review drafts.
92
115
  The personal originals stay put. Nothing enters git at this step.
93
116
 
@@ -134,6 +157,19 @@ Usage: open-memex sync-status
134
157
 
135
158
  Example:
136
159
  open-memex sync-status`,
160
+ inventory: `Show everything remembered — personal + this project — in the
161
+ open: what each memory says, where it came from, and whether it has reached
162
+ the repo yet. Read-only; deleting stays a confirmed conversation with your
163
+ agent. Formats: text (default, diffable) and json (for agents).
164
+
165
+ Usage: open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
166
+
167
+ Default scope: both. Without --out the report goes to stdout. A report that
168
+ includes personal memories is refused inside a git working tree unless
169
+ --allow-personal is passed — personal content must not ride a commit.
170
+
171
+ Example:
172
+ open-memex inventory --format json --out ~/inventory.json`,
137
173
  pull: `Pull shared project memories from the git remote: fetch + fast-forward
138
174
  only. Never auto-merges — a diverged branch fails with a clear message and is
139
175
  left for you to resolve by hand. On success the local index re-syncs.
@@ -331,16 +367,17 @@ function usage(exitCode = 1) {
331
367
 
332
368
  Usage:
333
369
  open-memex where
334
- open-memex list [--scope project|personal] [--type T] [--limit N]
370
+ open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
335
371
  open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
336
372
  open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
337
373
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
338
374
  open-memex status <id> active|deprecated|retracted|archived
339
- open-memex forget <id>
375
+ open-memex forget <id> [--soft]
340
376
  open-memex propose <id...> --to project [--local-approve]
341
377
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
342
378
  open-memex resolve [id-or-path]
343
379
  open-memex sync-status
380
+ open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
344
381
  open-memex pull
345
382
  open-memex push
346
383
  open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
@@ -720,12 +757,11 @@ async function main() {
720
757
  process.exit(1);
721
758
  }
722
759
  const cfg = loadConfig();
723
- const { detectKeywords } = await import("./capture/keywords.js");
760
+ const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import("./capture/keywords.js");
724
761
  const text = rest.slice(1).join(" ");
725
- const hits = detectKeywords(text, cfg);
762
+ const { hits, dropped } = scanKeywords(text, cfg);
726
763
  if (hits.length === 0) {
727
- console.log("no keyword triggers — nothing would be captured.");
728
- return;
764
+ console.log("no keyword triggers - nothing would be captured.");
729
765
  }
730
766
  for (const h of hits) {
731
767
  const r = redact(h.content, cfg.redactPatterns);
@@ -734,6 +770,13 @@ async function main() {
734
770
  console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
735
771
  console.log(` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`);
736
772
  }
773
+ // D67: a trigger that matched but produced a fragment is reported, not
774
+ // silently dropped - otherwise "记住:这个" just looks broken.
775
+ for (const d of dropped) {
776
+ console.log(d.reason === "too-short"
777
+ ? `! matched but NOT captured: body "${d.content}" is ${d.content.length} chars (floor: ${MIN_CAPTURE_CHARS}). A trigger alone isn't a memory - write the fact: "记住:<fact>".`
778
+ : `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`);
779
+ }
737
780
  return;
738
781
  }
739
782
  // `mcp` starts the stdio MCP server (11 tools; the opencode plugin exposes 5).
@@ -767,20 +810,27 @@ async function main() {
767
810
  }
768
811
  if (cmd === "list") {
769
812
  const flags = parseFlags(rest);
770
- const s = resolveCliScope(flags, project);
771
- syncScope(s.key, "cli");
772
- const hits = list(s.key, {
773
- type: flags.type,
774
- limit: flags.limit ? Number(flags.limit) : undefined,
775
- });
776
- if (hits.length === 0) {
777
- console.log(`(no memories in ${s.key})`);
778
- return;
779
- }
780
- for (const h of hits) {
781
- const dep = h.status === "deprecated" ? " [deprecated]" : "";
782
- const rev = hitStateLabel(h);
783
- console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
813
+ // D66: --scope both, on par with the memory_list tool — prints each
814
+ // scope's newest under its own header.
815
+ const scopes = flags.scope === "both" ? [project, PERSONAL_SCOPE] : [resolveCliScope(flags, project)];
816
+ for (const s of scopes) {
817
+ syncScope(s.key, "cli");
818
+ const hits = list(s.key, {
819
+ type: flags.type,
820
+ limit: flags.limit ? Number(flags.limit) : undefined,
821
+ include: flags.include === "all" ? "all" : "active",
822
+ });
823
+ if (scopes.length > 1)
824
+ console.log(`## ${s.kind} (${s.key})`);
825
+ if (hits.length === 0) {
826
+ console.log(`(no memories in ${s.key})`);
827
+ continue;
828
+ }
829
+ 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()}`);
833
+ }
784
834
  }
785
835
  return;
786
836
  }
@@ -949,6 +999,20 @@ async function main() {
949
999
  const id = rest[0];
950
1000
  if (!id)
951
1001
  usage();
1002
+ // D68: --soft hides (retracts) instead of deleting — same action as
1003
+ // the memory_forget tool's soft flag. One-way (D64).
1004
+ if (rest.includes("--soft")) {
1005
+ try {
1006
+ const mf = setStatus(id, "retracted");
1007
+ upsertFromFile(mf);
1008
+ console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
1009
+ }
1010
+ catch (e) {
1011
+ console.error(`hide failed: ${e.message}`);
1012
+ process.exit(1);
1013
+ }
1014
+ return;
1015
+ }
952
1016
  const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
953
1017
  if (!row) {
954
1018
  console.error("not found");
@@ -1086,11 +1150,57 @@ async function main() {
1086
1150
  }
1087
1151
  return;
1088
1152
  }
1153
+ if (cmd === "inventory") {
1154
+ // D68: refresh both scopes first so the report matches the markdown
1155
+ // on disk. Default scope: personal + this project.
1156
+ syncScope(project.key, "cli");
1157
+ syncScope(PERSONAL_SCOPE.key, "cli");
1158
+ const flags = parseFlags(rest);
1159
+ const format = flags.format ?? "text";
1160
+ if (format !== "text" && format !== "json") {
1161
+ console.error(`inventory: unknown --format '${format}' (text|json; html arrives separately)`);
1162
+ process.exit(2);
1163
+ }
1164
+ const scopeKeys = flags.scope === "personal"
1165
+ ? [PERSONAL_SCOPE.key]
1166
+ : flags.scope === "project"
1167
+ ? [project.key]
1168
+ : [PERSONAL_SCOPE.key, project.key];
1169
+ 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
+ }
1181
+ try {
1182
+ const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.js");
1183
+ const data = loadInventory(scopeKeys);
1184
+ const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
1185
+ if (outPath) {
1186
+ fs.writeFileSync(outPath, rendered, "utf8");
1187
+ console.log(`inventory: ${data.total} memories → ${outPath}`);
1188
+ }
1189
+ else {
1190
+ process.stdout.write(rendered);
1191
+ }
1192
+ }
1193
+ catch (e) {
1194
+ console.error(`inventory failed: ${e.message}`);
1195
+ process.exit(2);
1196
+ }
1197
+ return;
1198
+ }
1089
1199
  if (cmd === "sync-status") {
1090
1200
  syncScope(project.key, "cli");
1091
1201
  syncScope(PERSONAL_SCOPE.key, "cli");
1092
1202
  try {
1093
- console.log(formatSyncStatus(getSyncStatus()));
1203
+ console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
1094
1204
  }
1095
1205
  catch (e) {
1096
1206
  console.error(`sync-status failed: ${e.message}`);
package/dist/config.js CHANGED
@@ -9,25 +9,38 @@ export const DEFAULT_CONFIG = {
9
9
  captureAliases: true,
10
10
  keywordPatterns: [
11
11
  "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
12
- "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
12
+ "^\\s*(?:please\\s+)?note(?:\\s+that)?[:,]?\\s+(.+)$",
13
+ // D67: "don't forget the wifi password" is narration addressed to the
14
+ // agent, not a statement to the store. An explicit marker - "that" or a
15
+ // colon - makes it an instruction ("don't forget that ..." / "don't
16
+ // forget: ..."). Mirrors 别忘了 below; see D67.
17
+ "^\\s*(?:please\\s+)?don'?t\\s+forget(?:\\s+that\\s+|[:,]\\s+)(.+)$",
13
18
  "^\\s*TIL[:,]?\\s+(.+)$",
14
19
  "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
15
20
  // Chinese equivalents
16
21
  "^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
17
22
  "^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
18
- "^\\s*别忘了[::,,]?\\s*(.+)$",
23
+ // D67: separator required. "别忘了带伞" is narration ("don't forget your
24
+ // umbrella"); "别忘了:周五不发布" is an instruction to the store. Same
25
+ // rule as the English "don't forget" above.
26
+ "^\\s*别忘了[::,,]\\s*(.+)$",
19
27
  // First-person plural: team/project context, NOT personal
20
28
  "^\\s*我们认为[::,,]?\\s*(.+)$",
21
29
  "^\\s*我们决定[::,,]?\\s*(.+)$",
22
- "^\\s*帮我们记(?:住|一下)?[::,,]?\\s*(.+)$",
30
+ // D67: the boundary applies to the BARE verb only. 记 + (住|录) + 一下 is a
31
+ // complete verb phrase, so the noun may follow directly ("帮我记住这个配置",
32
+ // "记一下这个约定"); only a bare 记 needs a separator, otherwise 帮我记得…
33
+ // would capture mid-word garbage. See D67.
34
+ "^\\s*(?:请)?帮我们记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
23
35
  ],
24
36
  keywordPersonalPatterns: [
25
37
  "^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
26
38
  "^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
27
39
  // First-person singular: personal scope ("我" → 个人, "我们" → 项目)
28
40
  "^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
29
- "^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
30
- "^\\s*帮我记(?:住|一下)?[::,,]?\\s*(.+)$",
41
+ "^\\s*(?:请)?替我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
42
+ "^\\s*(?:请)?帮我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
43
+ "^\\s*help\\s+me\\s+remember(?:\\s+that)?[:,]?\\s+(.+)$",
31
44
  "^\\s*我觉得[::,,]?\\s*(.+)$",
32
45
  "^\\s*我喜欢[::,,]?\\s*(.+)$",
33
46
  ],
@@ -62,7 +62,10 @@ export function distillAgentsMarkdown(opts) {
62
62
  // inferred captures source: "inference"; this footer omitted it, so
63
63
  // opencode agents saved distilled conclusions as if the user had
64
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.`);
65
+ `- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish.`,
66
+ // D68: same voice for the visibility surface — opencode agents reading
67
+ // this footer never see the MCP handshake either.
68
+ `- When the user asks what you remember, list it conversationally`, ` (\`memory_list\` with scope both, or \`open-memex inventory\`) — plain`, ` words, not raw ids. Before deleting an entry they point at, read it`, ` back in full and confirm; deletion is permanent. Offer hiding`, ` (\`memory_forget\` soft / \`forget --soft\`) when they hesitate.`);
66
69
  lines.push(``);
67
70
  return lines.join("\n");
68
71
  }