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

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
@@ -157,7 +157,9 @@ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
157
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.
158
158
  - Frontmatter is `schema_version: 2`. The SQLite index schema is versioned separately and rebuilds automatically on version change — never hand-edit `index.db`.
159
159
 
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.
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/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).
161
+
162
+ 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
163
 
162
164
  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
165
 
@@ -165,7 +167,7 @@ Context injection happens exactly once per session in `experimental.chat.system.
165
167
 
166
168
  `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:
167
169
  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
170
+ built this way" (principles, iron rules, the append-only decision log, currently D1–D67). Before changing
169
171
  architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
170
172
  schema, MCP tools, CLI contract), read the relevant design section — the decision
171
173
  log records what was already considered and rejected.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,24 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Keyword capture: what counts as a trigger (D67)
13
+
14
+ The D66 review left two questions open and introduced one regression. Ruled, not deferred:
15
+
16
+ - **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.
17
+ - **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`.
18
+ - **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.
19
+ - **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.
20
+
21
+ ## Review follow-ups (D66)
22
+
23
+ Fixes from the follow-up review of the D63–D65 stack:
24
+
25
+ - 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`.
26
+ - `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.
27
+ - `open-memex status --help` documents that retraction is one-way.
28
+ - The full test suite is Windows-safe now (file:// probe imports, USERPROFILE-aware uninstall test) and refuses to run against a stale `dist/` build.
29
+
12
30
  ## Keyword capture gaps (D65)
13
31
 
14
32
  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
@@ -636,6 +642,7 @@ 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
639
646
  open-memex supersede <id> "Updated content"
640
647
  open-memex status <id> deprecated
641
648
  open-memex forget <id>
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`。
@@ -442,6 +442,7 @@ 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 # 两个作用域各自最新的,各带一个标题
445
446
  open-memex supersede <id> "Updated content"
446
447
  open-memex status <id> deprecated
447
448
  open-memex forget <id>
@@ -0,0 +1 @@
1
+ f4cf4e253bbf0f3254ec439b1344af50e88cc4fd068b04be73cdc20ee770e819
@@ -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
@@ -29,10 +29,10 @@ Example:
29
29
  open-memex where`,
30
30
  list: `List memories in a scope, newest first.
31
31
 
32
- Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
32
+ Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N]
33
33
 
34
34
  Flags:
35
- --scope project (default) or personal
35
+ --scope project (default), personal, or both
36
36
  --type filter by memory type
37
37
  --limit max results
38
38
 
@@ -80,6 +80,9 @@ Example:
80
80
 
81
81
  Usage: open-memex status <id> active|deprecated|retracted|archived
82
82
 
83
+ Retraction is one-way: a retracted memory cannot be set back to active
84
+ (it was withdrawn on purpose). Save the content as a new memory instead.
85
+
83
86
  Example:
84
87
  open-memex status 01ABC deprecated`,
85
88
  forget: `Delete a memory by id.
@@ -720,12 +723,11 @@ async function main() {
720
723
  process.exit(1);
721
724
  }
722
725
  const cfg = loadConfig();
723
- const { detectKeywords } = await import("./capture/keywords.js");
726
+ const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import("./capture/keywords.js");
724
727
  const text = rest.slice(1).join(" ");
725
- const hits = detectKeywords(text, cfg);
728
+ const { hits, dropped } = scanKeywords(text, cfg);
726
729
  if (hits.length === 0) {
727
- console.log("no keyword triggers — nothing would be captured.");
728
- return;
730
+ console.log("no keyword triggers - nothing would be captured.");
729
731
  }
730
732
  for (const h of hits) {
731
733
  const r = redact(h.content, cfg.redactPatterns);
@@ -734,6 +736,13 @@ async function main() {
734
736
  console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
735
737
  console.log(` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`);
736
738
  }
739
+ // D67: a trigger that matched but produced a fragment is reported, not
740
+ // silently dropped - otherwise "记住:这个" just looks broken.
741
+ for (const d of dropped) {
742
+ console.log(d.reason === "too-short"
743
+ ? `! 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>".`
744
+ : `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`);
745
+ }
737
746
  return;
738
747
  }
739
748
  // `mcp` starts the stdio MCP server (11 tools; the opencode plugin exposes 5).
@@ -767,20 +776,26 @@ async function main() {
767
776
  }
768
777
  if (cmd === "list") {
769
778
  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()}`);
779
+ // D66: --scope both, on par with the memory_list tool — prints each
780
+ // scope's newest under its own header.
781
+ const scopes = flags.scope === "both" ? [project, PERSONAL_SCOPE] : [resolveCliScope(flags, project)];
782
+ for (const s of scopes) {
783
+ syncScope(s.key, "cli");
784
+ const hits = list(s.key, {
785
+ type: flags.type,
786
+ limit: flags.limit ? Number(flags.limit) : undefined,
787
+ });
788
+ if (scopes.length > 1)
789
+ console.log(`## ${s.kind} (${s.key})`);
790
+ if (hits.length === 0) {
791
+ console.log(`(no memories in ${s.key})`);
792
+ continue;
793
+ }
794
+ for (const h of hits) {
795
+ const dep = h.status === "deprecated" ? " [deprecated]" : "";
796
+ const rev = hitStateLabel(h);
797
+ console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
798
+ }
784
799
  }
785
800
  return;
786
801
  }
@@ -1090,7 +1105,7 @@ async function main() {
1090
1105
  syncScope(project.key, "cli");
1091
1106
  syncScope(PERSONAL_SCOPE.key, "cli");
1092
1107
  try {
1093
- console.log(formatSyncStatus(getSyncStatus()));
1108
+ console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
1094
1109
  }
1095
1110
  catch (e) {
1096
1111
  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
  ],
package/dist/submit.js CHANGED
@@ -127,7 +127,7 @@ export function outboxDraftCount(scopeKey) {
127
127
  .get(scopeKey, outboxDir + path.sep);
128
128
  return row?.n ?? 0;
129
129
  }
130
- export function formatSyncStatus(st) {
130
+ export function formatSyncStatus(st, opts = {}) {
131
131
  const lines = [];
132
132
  lines.push(`project: ${st.projectName} (${st.scopeKey})`);
133
133
  // D31: when the index was last synced and what triggered it.
@@ -138,13 +138,16 @@ export function formatSyncStatus(st) {
138
138
  else {
139
139
  lines.push(`last sync: never`);
140
140
  }
141
- const CAP = 20;
141
+ // Agent-facing renders cap each section so a big store can't flood a
142
+ // tool result; the cap line points at the CLI, which shows everything.
143
+ const CAP = opts.cap ?? 20;
144
+ const more = (n) => ` … and ${n} more (full list: open-memex sync-status)`;
142
145
  lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
143
146
  for (const e of st.outbox.slice(0, CAP)) {
144
147
  lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
145
148
  }
146
149
  if (st.outbox.length > CAP)
147
- lines.push(` … and ${st.outbox.length - CAP} more`);
150
+ lines.push(more(st.outbox.length - CAP));
148
151
  const byState = new Map();
149
152
  for (const e of st.inRepo)
150
153
  byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
@@ -155,7 +158,7 @@ export function formatSyncStatus(st) {
155
158
  lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
156
159
  }
157
160
  if (st.inRepo.length > CAP)
158
- lines.push(` … and ${st.inRepo.length - CAP} more`);
161
+ lines.push(more(st.inRepo.length - CAP));
159
162
  if (st.uncommitted.length > 0) {
160
163
  lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
161
164
  }
@@ -22,9 +22,14 @@ export function makeTools(getScope, cfg) {
22
22
  syncScope(getScope().key, "request");
23
23
  syncScope(PERSONAL_SCOPE.key, "request");
24
24
  }
25
- catch {
26
- // A failed sync must not take the tools down with it; the index
27
- // may be stale for this call but the tool still answers.
25
+ catch (err) {
26
+ // Deliberate asymmetry with the MCP adapter (which lets sync errors
27
+ // propagate as tool errors): a failed sync must not take the plugin
28
+ // tools down — the index may be stale for this call but the tool
29
+ // still answers. Loud at debug, silent otherwise.
30
+ if (cfg.logLevel === "debug") {
31
+ console.error("[open-memex] pre-tool sync failed:", err);
32
+ }
28
33
  }
29
34
  };
30
35
  const memory_add = tool({
package/docs/V2-DESIGN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenMemex — Design Document (protocol v0.2)
2
2
 
3
- **Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D64 are settled; open questions are tracked at the end of this document.
3
+ **Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D67 are settled; open questions are tracked at the end of this document.
4
4
  **Author:** Stone, with 小沐
5
5
  **Changelog vs v1:** incorporates round-3 review from Perplexity, Grok, Gemini, ChatGPT, DeepSeek.
6
6
  Key changes: Design Principles section; `role` separated from `type`; two iron rules;
@@ -569,8 +569,10 @@ requirement: personal data never touches third-party services). Benchmarks to tr
569
569
  - Times: epoch ms → RFC 3339. `priority: N` → `importance: low|normal|high` (1–3 low, 4–7 normal, 8–10 high).
570
570
  - `type: instruction` (if any v1 memory used it) → `type: <content-kind>` + `role: instruction`.
571
571
  - Index is discarded and rebuilt from markdown files. `open-memex migrate --dry-run` previews everything.
572
- - Legacy paths: v1 `.my-o-memory/` repo dirs and `~/.my-o-memory/` config are moved to `.open-memex/` /
573
- `~/.open-memex/` during migration (originals kept as backup until the user confirms).
572
+ - Legacy paths: v1 `.my-o-memory/` repo dirs and `~/.my-o-memory/` config are moved to the
573
+ v2 locations (in-repo `.ai/open-memex/`; data root per §4/paths — `OPEN_MEMEX_HOME`, else
574
+ `%APPDATA%\open-memex` or `$XDG_DATA_HOME/open-memex`) by `open-memex migrate --to-v2`,
575
+ with the pre-migration tree kept as a dated backup.
574
576
 
575
577
  ---
576
578
 
@@ -1383,6 +1385,65 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1383
1385
  and the secret-masking windows stay as they are — narrowing them is a
1384
1386
  capture-recall tradeoff that needs an explicit decision, not a drive-by.
1385
1387
 
1388
+ - **D66 — follow-up review of the D63–D65 stack.** (1) The 帮/替
1389
+ keyword family is rebuilt around full verb phrases: an optional `请`,
1390
+ `记` + optional `住|录` + optional `一下`, then a separator boundary —
1391
+ `请帮我记住:…` captures (D65's new pattern lacked its own `请`),
1392
+ `帮我记录一下:…` saves `周五不发布` instead of the mid-word garbage
1393
+ `录一下:…`, and `帮我记得…` does not fire. `help me remember: …`
1394
+ joins the personal patterns. Still deliberately unchanged: shortening
1395
+ the ≥3-char content floor (it eats `记一下这个`-type captures, but
1396
+ lowering it invites junk) and adding narrative triggers like `记得…` /
1397
+ `remind me to…` (imperative-vs-narration ambiguity) — both need an
1398
+ explicit decision. (2) `sync-status` CLI shows the full list; the
1399
+ 20-line cap binds only agent renders, whose "… and N more" line names
1400
+ the CLI. `list --scope both` reaches CLI parity with the tool, and
1401
+ `status --help` documents the retracted one-way door. (3) The test
1402
+ suite is Windows-safe (probe imports are `file://` URLs, the uninstall
1403
+ test sets `USERPROFILE` since `os.homedir()` ignores `HOME` there)
1404
+ and refuses to run against a stale `dist/`. (4) Sync-error posture is
1405
+ now a stated asymmetry: the MCP adapter lets pre-call sync errors
1406
+ propagate as tool errors; the plugin adapter logs them at debug and
1407
+ answers from a possibly-stale index rather than taking the tools
1408
+ down. (5) §19's legacy-paths text now names the shipped locations;
1409
+ historical decision entries keep their as-decided wording.
1410
+
1411
+ - **D67 — keyword capture: a trigger must be a statement to the store.**
1412
+ Three rulings, all from the D66 review's open items plus one bug D66
1413
+ introduced. (1) **The boundary guard guards the bare verb only.** D66 put
1414
+ a separator requirement *after* the whole verb phrase, which killed the
1415
+ commonest Chinese form — `帮我记一下这个配置`, `帮我记住这个配置`,
1416
+ `替我记一下我住在杭州`, `帮我们记一下这个约定` all stopped matching, because
1417
+ the noun follows 记一下/记住 directly. The guard now applies only to a
1418
+ bare 记: `记` + (`住`|`录`, never followed by a verb continuation like
1419
+ 得/着) + optional `一下`, or a separator. So mid-word garbage (`帮我记得带伞`
1420
+ → `带伞`) is still impossible while the natural form captures. (2) **The
1421
+ ≥3-char body floor stays, but stops being silent.** A trigger that yields
1422
+ `这个` or `OK` is a match on the trigger, not a memory, and a 2-char memory
1423
+ is noise in every later injection — so the floor is deliberately *not*
1424
+ lowered (a script-aware floor is the escape hatch if short Latin bodies
1425
+ ever matter). What changed is honesty: `capture --dry-run` prints the
1426
+ rejection with the reason, and `logLevel: debug` logs it, so "nothing
1427
+ happened" is never a mystery. A rejected **personal** match also keeps
1428
+ its line claimed — otherwise `记住我:OK` fell through to the generic
1429
+ 记住 and was saved as the nonsense project memory `我:OK`. (3) **Narrative
1430
+ forms are not triggers.** `记得…`, `remind me to…`, `I always forget to…`
1431
+ stay out: they are imperatives aimed at the agent, they are the most
1432
+ frequent sentences in any conversation that mentions memory, and a false
1433
+ trigger writes permanent state nobody reviewed — while a missed trigger
1434
+ costs one sentence. The asymmetry is deliberate: precision over recall.
1435
+ For the two ambiguous forms already in the list, an explicit marker now
1436
+ separates narration from instruction: `别忘了:…` / `don't forget: …` /
1437
+ `don't forget that …` capture; bare `别忘了带伞` / `don't forget the wifi
1438
+ password` do not. The `note` family is untouched — `note the API is v2`
1439
+ was never ambiguous. (4) Test/tooling honesty: `test-full.ts` guards the
1440
+ built `dist/` with a content fingerprint (`scripts/build-stamp.mjs`)
1441
+ rather than mtimes — `git checkout` rewrites mtimes, so a branch switch
1442
+ could leave an older-branch dist looking fresh — and the agent-facing
1443
+ `memory_status` cap is pinned again (20 entries per section plus a
1444
+ "full list: open-memex sync-status" pointer) after D66's CLI-parity test
1445
+ had dropped that coverage.
1446
+
1386
1447
  ## Open Questions
1387
1448
 
1388
1449
  _Some early questions are resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull). The entries below are open unless marked decided._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.0-alpha.6",
3
+ "version": "0.7.0-alpha.8",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -17,7 +17,7 @@
17
17
  "typecheck": "tsc --noEmit",
18
18
  "cli": "node --experimental-strip-types src/cli.ts",
19
19
  "mcp": "node --experimental-strip-types src/mcp.ts",
20
- "build": "tsc -p tsconfig.build.json",
20
+ "build": "tsc -p tsconfig.build.json && node scripts/build-stamp.mjs",
21
21
  "prepublishOnly": "npm run build",
22
22
  "postinstall": "node scripts/postinstall.js"
23
23
  },
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dist freshness stamp (D67).
4
+ *
5
+ * scripts/test-full.ts runs the *built* dist/cli.js, so a stale dist turns src
6
+ * fixes into phantom product failures. An mtime comparison is not enough:
7
+ * `git checkout` rewrites src mtimes, so switching branches can leave an
8
+ * older-branch dist looking newer than the branch's own src. So the build
9
+ * records a content fingerprint of every .ts file under src/ and the test
10
+ * compares that.
11
+ *
12
+ * node scripts/build-stamp.mjs # write dist/.build-stamp
13
+ * node scripts/build-stamp.mjs --check # verify, exit 1 when stale
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
21
+ const SRC = path.join(REPO, "src");
22
+ const STAMP = path.join(REPO, "dist", ".build-stamp");
23
+
24
+ function tsFiles(dir) {
25
+ const out = [];
26
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
27
+ const p = path.join(dir, entry.name);
28
+ if (entry.isDirectory()) out.push(...tsFiles(p));
29
+ else if (entry.name.endsWith(".ts")) out.push(p);
30
+ }
31
+ return out.sort();
32
+ }
33
+
34
+ /** Hash of every .ts path + content under src/: stable across checkouts, exact. */
35
+ export function srcFingerprint() {
36
+ const h = createHash("sha256");
37
+ for (const file of tsFiles(SRC)) {
38
+ h.update(path.relative(REPO, file).replace(/\\/g, "/"));
39
+ h.update(readFileSync(file));
40
+ }
41
+ return h.digest("hex");
42
+ }
43
+
44
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
45
+ const want = srcFingerprint();
46
+ if (process.argv.includes("--check")) {
47
+ const have = existsSync(STAMP) ? readFileSync(STAMP, "utf8").trim() : "";
48
+ if (have !== want) {
49
+ console.error(
50
+ `dist/ is stale (build stamp ${have ? have.slice(0, 12) : "missing"} != src ${want.slice(0, 12)}) - run \`npm run build\` before scripts/test-full.ts`,
51
+ );
52
+ process.exit(1);
53
+ }
54
+ console.log(`dist/ build stamp ok (${want.slice(0, 12)})`);
55
+ } else {
56
+ if (!existsSync(path.join(REPO, "dist"))) {
57
+ console.error("dist/ not found - run `npm run build` first");
58
+ process.exit(1);
59
+ }
60
+ writeFileSync(STAMP, want + "\n", "utf8");
61
+ console.log(`wrote dist/.build-stamp (${want.slice(0, 12)})`);
62
+ }
63
+ }
@@ -3,7 +3,7 @@
3
3
  import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, normalizeAliases, type Frontmatter } from "../src/store/markdown.ts";
4
4
  import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
- import { detectKeywords } from "../src/capture/keywords.ts";
6
+ import { detectKeywords, scanKeywords } from "../src/capture/keywords.ts";
7
7
  import { DEFAULT_CONFIG } from "../src/config.ts";
8
8
  import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
@@ -281,6 +281,83 @@ ok("remember Toronto… body intact", g5.length === 1 && g5[0]?.content === "Tor
281
281
  const g6 = detectKeywords("帮我们记住:周五不发布", DEFAULT_CONFIG);
282
282
  ok("帮我们记住 → project", g6.length === 1 && g6[0]?.personal === false, JSON.stringify(g6));
283
283
 
284
+ console.log("== keywords: review follow-ups (D66) ==");
285
+ // The new 帮我记 pattern initially shipped without the (?:请)? its siblings
286
+ // got in D65 — 请帮我记住 fell through again.
287
+ const f1 = detectKeywords("请帮我记住:这个项目用 pnpm", DEFAULT_CONFIG);
288
+ ok("请帮我记住 → personal", f1.length === 1 && f1[0]?.personal === true && f1[0]?.content === "这个项目用 pnpm", JSON.stringify(f1));
289
+ // 帮我记录一下 used to capture mid-word garbage ("录一下:…"). The verb
290
+ // phrase now includes 记录/记录一下 and must end at a separator boundary.
291
+ const f2 = detectKeywords("帮我记录一下:周五不发布", DEFAULT_CONFIG);
292
+ ok("帮我记录一下 body clean", f2.length === 1 && f2[0]?.content === "周五不发布", JSON.stringify(f2));
293
+ const f3 = detectKeywords("替我记录一下:护照在抽屉里", DEFAULT_CONFIG);
294
+ ok("替我记录一下 body clean", f3.length === 1 && f3[0]?.content === "护照在抽屉里", JSON.stringify(f3));
295
+ // The boundary guard: 帮我记得… is not a capture form and must not fire.
296
+ const f4 = detectKeywords("帮我记得带伞", DEFAULT_CONFIG);
297
+ ok("帮我记得 → no capture", f4.length === 0, JSON.stringify(f4));
298
+ // English personal phrasing on par with "remember for me".
299
+ const f5 = detectKeywords("Help me remember: the wifi is on the fridge note", DEFAULT_CONFIG);
300
+ ok("help me remember → personal", f5.length === 1 && f5[0]?.personal === true, JSON.stringify(f5));
301
+
302
+ console.log("== keywords: separator-less verb phrases (D67) ==");
303
+ // D66's boundary guard was one notch too strict: it demanded a separator even
304
+ // after a COMPLETE verb phrase, so the most natural form - "帮我记一下这个配置",
305
+ // with the noun straight after 记一下 - stopped matching. The boundary now
306
+ // guards the bare verb only (记 must be followed by 住/录/一下 or a separator),
307
+ // so 记得/记着 still cannot produce mid-word garbage.
308
+ const v1 = detectKeywords("帮我记一下这个配置", DEFAULT_CONFIG);
309
+ ok("帮我记一下这个配置 (no separator)", v1.length === 1 && v1[0]?.personal === true && v1[0]?.content === "这个配置", JSON.stringify(g1));
310
+ const v2 = detectKeywords("帮我记住这个配置", DEFAULT_CONFIG);
311
+ ok("帮我记住这个配置 (no separator)", v2.length === 1 && v2[0]?.content === "这个配置", JSON.stringify(g2));
312
+ const v3 = detectKeywords("替我记一下我住在杭州", DEFAULT_CONFIG);
313
+ ok("替我记一下我住在杭州 (no separator)", v3.length === 1 && v3[0]?.personal === true && v3[0]?.content === "我住在杭州", JSON.stringify(g3));
314
+ const v4 = detectKeywords("帮我们记一下这个约定", DEFAULT_CONFIG);
315
+ ok("帮我们记一下这个约定 → project", v4.length === 1 && v4[0]?.personal === false && v4[0]?.content === "这个约定", JSON.stringify(g4));
316
+ const v5 = detectKeywords("替我记着这个流程", DEFAULT_CONFIG);
317
+ ok("替我记着… → no capture (verb continuation)", v5.length === 0, JSON.stringify(v5));
318
+ const v6 = detectKeywords("帮我们记得每周同步", DEFAULT_CONFIG);
319
+ ok("帮我们记得… → no capture (verb continuation)", v6.length === 0, JSON.stringify(v6));
320
+ const v7 = detectKeywords("帮我记:这个项目用 pnpm", DEFAULT_CONFIG);
321
+ ok("帮我记:… (bare verb + separator)", v7.length === 1 && v7[0]?.content === "这个项目用 pnpm", JSON.stringify(v7));
322
+
323
+ console.log("== keywords: narration is not a trigger (D67) ==");
324
+ // 记得… / remind me… stay out on purpose (D67): they are imperatives aimed at
325
+ // the agent, and a false trigger writes memory nobody reviewed. The two forms
326
+ // already in the list follow the same rule - an explicit separator (or "that")
327
+ // marks a statement to the store.
328
+ const n1 = detectKeywords("别忘了带伞", DEFAULT_CONFIG);
329
+ ok("别忘了带伞 (narration) → no capture", n1.length === 0, JSON.stringify(n1));
330
+ const n2 = detectKeywords("别忘了:周五不发布", DEFAULT_CONFIG);
331
+ ok("别忘了:… (instruction) → project", n2.length === 1 && n2[0]?.personal === false && n2[0]?.content === "周五不发布", JSON.stringify(n2));
332
+ const n3 = detectKeywords("别忘了,每次发布前更新 changelog", DEFAULT_CONFIG);
333
+ ok("别忘了,… (ascii comma) → project", n3.length === 1 && n3[0]?.content === "每次发布前更新 changelog", JSON.stringify(n3));
334
+ const n4 = detectKeywords("Don't forget the wifi password", DEFAULT_CONFIG);
335
+ ok("don't forget <bare> (narration) → no capture", n4.length === 0, JSON.stringify(n4));
336
+ const n5 = detectKeywords("Don't forget that the wifi password is on the fridge", DEFAULT_CONFIG);
337
+ ok("don't forget that … → project", n5.length === 1 && n5[0]?.content === "the wifi password is on the fridge", JSON.stringify(n5));
338
+ const n6 = detectKeywords("don't forget: the wifi password", DEFAULT_CONFIG);
339
+ ok("don't forget: … → project", n6.length === 1 && n6[0]?.content === "the wifi password", JSON.stringify(n6));
340
+ // The 'note' family is unaffected - it was never the ambiguous form.
341
+ const n7 = detectKeywords("note the API is v2", DEFAULT_CONFIG);
342
+ ok("note <bare> still captures", n7.length === 1 && n7[0]?.content === "the API is v2", JSON.stringify(n7));
343
+ const n8 = detectKeywords("记得每次都要跑迁移脚本", DEFAULT_CONFIG);
344
+ ok("记得… → no capture", n8.length === 0, JSON.stringify(n8));
345
+ const n9 = detectKeywords("remind me to lock the door", DEFAULT_CONFIG);
346
+ ok("remind me to… → no capture", n9.length === 0, JSON.stringify(n9));
347
+
348
+ console.log("== keywords: the length floor reports instead of swallowing (D67) ==");
349
+ // D67 keeps the >=3-char floor (a trigger that yields 这个 is a match on the
350
+ // trigger, not a memory) but a rejection is no longer silent.
351
+ const d1 = scanKeywords("记住:这个", DEFAULT_CONFIG);
352
+ ok("短 body 不捕获", d1.hits.length === 0, JSON.stringify(d1));
353
+ ok("短 body 有说明 (too-short)", d1.dropped.length === 1 && d1.dropped[0]?.reason === "too-short" && d1.dropped[0]?.content === "这个", JSON.stringify(d1.dropped));
354
+ const d2 = scanKeywords("记住:这个配置", DEFAULT_CONFIG);
355
+ ok(">=3 字的照常捕获", d2.hits.length === 1 && d2.dropped.length === 0, JSON.stringify(d2));
356
+ // A rejected personal match must not leak into the project scope: "记住我:OK"
357
+ // must not be re-captured by the generic 记住 as "我:OK".
358
+ const d3 = scanKeywords("记住我:OK", DEFAULT_CONFIG);
359
+ ok("短 personal 不落到 project", d3.hits.length === 0 && d3.dropped.some((x) => x.reason === "too-short" && x.content === "OK"), JSON.stringify(d3));
360
+
284
361
  console.log("== scope ==");
285
362
  const s = resolveProjectScope(process.cwd());
286
363
  ok("kind=project", s.kind === "project");
@@ -11,12 +11,29 @@ import { execFileSync, spawn } from "node:child_process";
11
11
  import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
- import { fileURLToPath } from "node:url";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
15
 
16
16
  const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
17
17
  const CLI = path.join(REPO, "dist", "cli.js");
18
18
  const MCP_TS = path.join(REPO, "src", "mcp.ts");
19
19
 
20
+ // Freshness guard: the CLI under test is the built dist/cli.js. A stale build
21
+ // turns src fixes into phantom product failures, so refuse to run against one.
22
+ // The check is a content fingerprint written by the build (scripts/build-stamp.mjs),
23
+ // not an mtime comparison: `git checkout` rewrites src mtimes, so switching
24
+ // branches can leave an older-branch dist looking newer than the branch's src.
25
+ {
26
+ try {
27
+ execFileSync("node", [path.join(REPO, "scripts", "build-stamp.mjs"), "--check"], {
28
+ cwd: REPO,
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ });
31
+ } catch (e: any) {
32
+ console.error((e.stderr ?? "").toString().trim() || "dist/ is stale — run `npm run build` first");
33
+ process.exit(1);
34
+ }
35
+ }
36
+
20
37
  const T = fs.mkdtempSync(path.join(os.tmpdir(), "om-full-"));
21
38
  const TESTENV = {
22
39
  ...process.env,
@@ -496,9 +513,10 @@ console.log("== D64: p1 fixes (propose visibility / supersede published / status
496
513
  fs.rmSync(path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db-shm"), { force: true });
497
514
  const probe = path.join(T, "p1-plugin-probe.ts");
498
515
  fs.writeFileSync(probe, [
499
- `import { makeTools } from ${JSON.stringify(path.join(REPO, "src", "tools", "memory.ts"))};`,
500
- `import { PERSONAL_SCOPE } from ${JSON.stringify(path.join(REPO, "src", "scope.ts"))};`,
501
- `import { loadConfig } from ${JSON.stringify(path.join(REPO, "src", "config.ts"))};`,
516
+ // file:// URLs, not raw paths — Windows rejects drive-letter specifiers.
517
+ `import { makeTools } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "tools", "memory.ts")).href)};`,
518
+ `import { PERSONAL_SCOPE } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "scope.ts")).href)};`,
519
+ `import { loadConfig } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "config.ts")).href)};`,
502
520
  `const tools = makeTools(() => PERSONAL_SCOPE, loadConfig());`,
503
521
  `const r = await tools.memory_search.execute({ query: ${JSON.stringify(marker)} });`,
504
522
  `console.log(r.output);`,
@@ -518,8 +536,29 @@ console.log("== D64: p1 fixes (propose visibility / supersede published / status
518
536
  }
519
537
  cli(["reindex"], PROJ);
520
538
  r = cli(["sync-status"], PROJ);
521
- const statusLines = r.out.split("\n").length;
522
- ok("sync-status caps long sections", /… and \d+ more/.test(r.out) && statusLines < 50, `lines=${statusLines} ${r.out.slice(0, 120)}`);
539
+ // CLI shows the full list (the 20-line cap applies to agent tool
540
+ // results, whose "… and N more" line points back here).
541
+ const fillerShown = (r.out.match(/zzq cap filler/g) ?? []).length;
542
+ ok("sync-status shows the full list", fillerShown === 21 && !/… and \d+ more/.test(r.out), `shown=${fillerShown}`);
543
+ // ...and the agent-facing render (what memory_status returns) caps per section
544
+ // and names the CLI, so a hidden entry is still reachable.
545
+ const capProbe = path.join(T, "p1-cap-probe.ts");
546
+ fs.writeFileSync(capProbe, [
547
+ `import { getSyncStatus, formatSyncStatus } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "submit.ts")).href)};`,
548
+ `console.log(formatSyncStatus(getSyncStatus()));`,
549
+ ].join("\n"));
550
+ let capOut = "";
551
+ try {
552
+ capOut = execFileSync("node", ["--experimental-strip-types", capProbe], { cwd: PROJ, env: TESTENV, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
553
+ } catch (e: any) { capOut = (e.stdout ?? "") + (e.stderr ?? ""); }
554
+ const outboxSection = (capOut.split("outbox (appdata")[1] ?? "").split("repo .ai/open-memex")[0];
555
+ const capEntries = (outboxSection.match(/\[(draft|proposed|approved|published|rejected)\]/g) ?? []).length;
556
+ const capFiller = (outboxSection.match(/zzq cap filler/g) ?? []).length;
557
+ ok(
558
+ "agent-facing sync-status caps and points at the CLI",
559
+ capEntries === 20 && capFiller < 21 && /… and \d+ more \(full list: open-memex sync-status\)/.test(outboxSection),
560
+ `entries=${capEntries} fillers=${capFiller} ${outboxSection.slice(0, 200)}`,
561
+ );
523
562
  for (let i = 0; i < 21; i++) fs.rmSync(path.join(outboxDir, `01P1CAP${String(i).padStart(19, "0")}.md`), { force: true });
524
563
 
525
564
  // doctor: v1 memories left under memories/user/ must be flagged.
@@ -537,7 +576,8 @@ console.log("== D64: p1 fixes (propose visibility / supersede published / status
537
576
  fs.mkdirSync(ocDir, { recursive: true });
538
577
  const jsoncFile = path.join(ocDir, "opencode.jsonc");
539
578
  fs.writeFileSync(jsoncFile, JSON.stringify({ plugin: ["file:///plugins/open-memex/src/index.ts"] }, null, 2));
540
- r = cliRaw(["uninstall", "--client", "opencode", "--global", "--yes"], PROJ, { ...TESTENV, HOME: home2 });
579
+ // USERPROFILE too: os.homedir() ignores HOME on Windows.
580
+ r = cliRaw(["uninstall", "--client", "opencode", "--global", "--yes"], PROJ, { ...TESTENV, HOME: home2, USERPROFILE: home2 });
541
581
  const afterText = fs.readFileSync(jsoncFile, "utf8");
542
582
  ok("uninstall finds and cleans .jsonc global config", r.code === 0 && !/open-memex/.test(afterText), (r.out + r.err).slice(0, 160) + " || " + afterText.slice(0, 120));
543
583
  }
@@ -8,16 +8,42 @@ export interface KeywordHit {
8
8
  }
9
9
 
10
10
  /**
11
- * Scan user text for memory-capture keyword triggers.
11
+ * D67: capture-body length bounds. The lower bound rejects fragments — a
12
+ * trigger that yields "这个" or "OK" is a match on the trigger, not a memory,
13
+ * and a 2-char memory is noise in every future session's injection. The floor
14
+ * stays deliberately above 1: D67 considered lowering it and decided against
15
+ * it. What changed is that a rejection is no longer silent (see KeywordDrop).
16
+ */
17
+ export const MIN_CAPTURE_CHARS = 3;
18
+ export const MAX_CAPTURE_CHARS = 2000;
19
+
20
+ /** A pattern that matched but whose body was rejected before capture. */
21
+ export interface KeywordDrop {
22
+ content: string;
23
+ pattern: string;
24
+ personal: boolean;
25
+ reason: "too-short" | "too-long";
26
+ }
27
+
28
+ export interface KeywordScan {
29
+ hits: KeywordHit[];
30
+ dropped: KeywordDrop[];
31
+ }
32
+
33
+ /**
34
+ * Scan user text for memory-capture keyword triggers, reporting both what
35
+ * would be captured and what matched but was rejected (D67) — a dropped match
36
+ * that says nothing reads as a broken feature.
12
37
  * Each pattern must have a single capture group whose value becomes the memory body.
13
38
  * Personal patterns (cfg.keywordPersonalPatterns) force the personal scope.
14
39
  *
15
40
  * Personal patterns run first and claim their line: an explicit scope signal
16
41
  * (e.g. "记住我对花粉过敏") must not also fire the generic 记住 into the project scope.
17
42
  */
18
- export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] {
19
- if (!cfg.keywordCaptureEnabled) return [];
43
+ export function scanKeywords(userText: string, cfg: MyOMemoryConfig): KeywordScan {
20
44
  const hits: KeywordHit[] = [];
45
+ const dropped: KeywordDrop[] = [];
46
+ if (!cfg.keywordCaptureEnabled) return { hits, dropped };
21
47
  const lines = userText.split(/\r?\n/);
22
48
  const claimed = new Set<number>();
23
49
  const scan = (patterns: string[], personal: boolean, skipClaimed: boolean) => {
@@ -33,15 +59,39 @@ export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordH
33
59
  const m = lines[i].match(re);
34
60
  if (!m || !m[1]) continue;
35
61
  const content = m[1].trim().replace(/[.!?]$/, "").trim();
36
- if (content.length >= 3 && content.length <= 2000) {
62
+ if (personal) claimed.add(i);
63
+ if (content.length >= MIN_CAPTURE_CHARS && content.length <= MAX_CAPTURE_CHARS) {
37
64
  hits.push({ content, pattern: src, personal });
38
- if (personal) claimed.add(i);
39
65
  break; // one hit per pattern per message
40
66
  }
67
+ // Rejected: record it once per pattern. The line stays claimed for
68
+ // personal patterns even here - "记住我:OK" must not fall through to
69
+ // the generic 记住 and be saved as "我:OK" (D67).
70
+ if (!dropped.some((d) => d.pattern === src && d.content === content)) {
71
+ dropped.push({
72
+ content,
73
+ pattern: src,
74
+ personal,
75
+ reason: content.length < MIN_CAPTURE_CHARS ? "too-short" : "too-long",
76
+ });
77
+ }
78
+ break;
41
79
  }
42
80
  }
43
81
  };
44
82
  scan(cfg.keywordPersonalPatterns, true, false);
45
83
  scan(cfg.keywordPatterns, false, true);
46
- return hits;
84
+ if (cfg.logLevel === "debug" && dropped.length > 0) {
85
+ for (const d of dropped) {
86
+ console.error(
87
+ `[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}"`,
88
+ );
89
+ }
90
+ }
91
+ return { hits, dropped };
92
+ }
93
+
94
+ /** Capture-only view of {@link scanKeywords} (drops are logged, not returned). */
95
+ export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] {
96
+ return scanKeywords(userText, cfg).hits;
47
97
  }
package/src/cli.ts CHANGED
@@ -38,10 +38,10 @@ Example:
38
38
 
39
39
  list: `List memories in a scope, newest first.
40
40
 
41
- Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
41
+ Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N]
42
42
 
43
43
  Flags:
44
- --scope project (default) or personal
44
+ --scope project (default), personal, or both
45
45
  --type filter by memory type
46
46
  --limit max results
47
47
 
@@ -93,6 +93,9 @@ Example:
93
93
 
94
94
  Usage: open-memex status <id> active|deprecated|retracted|archived
95
95
 
96
+ Retraction is one-way: a retracted memory cannot be set back to active
97
+ (it was withdrawn on purpose). Save the content as a new memory instead.
98
+
96
99
  Example:
97
100
  open-memex status 01ABC deprecated`,
98
101
 
@@ -803,12 +806,13 @@ async function main() {
803
806
  process.exit(1);
804
807
  }
805
808
  const cfg = loadConfig();
806
- const { detectKeywords } = await import("./capture/keywords.ts");
809
+ const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import(
810
+ "./capture/keywords.ts"
811
+ );
807
812
  const text = rest.slice(1).join(" ");
808
- const hits = detectKeywords(text, cfg);
813
+ const { hits, dropped } = scanKeywords(text, cfg);
809
814
  if (hits.length === 0) {
810
- console.log("no keyword triggers — nothing would be captured.");
811
- return;
815
+ console.log("no keyword triggers - nothing would be captured.");
812
816
  }
813
817
  for (const h of hits) {
814
818
  const r = redact(h.content, cfg.redactPatterns);
@@ -819,8 +823,17 @@ async function main() {
819
823
  ` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`,
820
824
  );
821
825
  }
826
+ // D67: a trigger that matched but produced a fragment is reported, not
827
+ // silently dropped - otherwise "记住:这个" just looks broken.
828
+ for (const d of dropped) {
829
+ console.log(
830
+ d.reason === "too-short"
831
+ ? `! 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>".`
832
+ : `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`,
833
+ );
834
+ }
822
835
  return;
823
- }
836
+ }
824
837
 
825
838
  // `mcp` starts the stdio MCP server (11 tools; the opencode plugin exposes 5).
826
839
  // Branched before db() — runMcpServer() does its own init, and stdout must
@@ -859,20 +872,26 @@ async function main() {
859
872
 
860
873
  if (cmd === "list") {
861
874
  const flags = parseFlags(rest);
862
- const s = resolveCliScope(flags, project);
863
- syncScope(s.key, "cli");
864
- const hits = list(s.key, {
865
- type: flags.type,
866
- limit: flags.limit ? Number(flags.limit) : undefined,
867
- });
868
- if (hits.length === 0) {
869
- console.log(`(no memories in ${s.key})`);
870
- return;
871
- }
872
- for (const h of hits) {
873
- const dep = h.status === "deprecated" ? " [deprecated]" : "";
874
- const rev = hitStateLabel(h);
875
- console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
875
+ // D66: --scope both, on par with the memory_list tool — prints each
876
+ // scope's newest under its own header.
877
+ const scopes =
878
+ flags.scope === "both" ? [project, PERSONAL_SCOPE] : [resolveCliScope(flags, project)];
879
+ for (const s of scopes) {
880
+ syncScope(s.key, "cli");
881
+ const hits = list(s.key, {
882
+ type: flags.type,
883
+ limit: flags.limit ? Number(flags.limit) : undefined,
884
+ });
885
+ if (scopes.length > 1) console.log(`## ${s.kind} (${s.key})`);
886
+ if (hits.length === 0) {
887
+ console.log(`(no memories in ${s.key})`);
888
+ continue;
889
+ }
890
+ for (const h of hits) {
891
+ const dep = h.status === "deprecated" ? " [deprecated]" : "";
892
+ const rev = hitStateLabel(h);
893
+ console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
894
+ }
876
895
  }
877
896
  return;
878
897
  }
@@ -1189,7 +1208,7 @@ function positionalArgs(argv: string[]): string[] {
1189
1208
  syncScope(project.key, "cli");
1190
1209
  syncScope(PERSONAL_SCOPE.key, "cli");
1191
1210
  try {
1192
- console.log(formatSyncStatus(getSyncStatus()));
1211
+ console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
1193
1212
  } catch (e) {
1194
1213
  console.error(`sync-status failed: ${(e as Error).message}`);
1195
1214
  process.exit(2);
package/src/config.ts CHANGED
@@ -45,25 +45,38 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
45
45
  captureAliases: true,
46
46
  keywordPatterns: [
47
47
  "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
48
- "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
48
+ "^\\s*(?:please\\s+)?note(?:\\s+that)?[:,]?\\s+(.+)$",
49
+ // D67: "don't forget the wifi password" is narration addressed to the
50
+ // agent, not a statement to the store. An explicit marker - "that" or a
51
+ // colon - makes it an instruction ("don't forget that ..." / "don't
52
+ // forget: ..."). Mirrors 别忘了 below; see D67.
53
+ "^\\s*(?:please\\s+)?don'?t\\s+forget(?:\\s+that\\s+|[:,]\\s+)(.+)$",
49
54
  "^\\s*TIL[:,]?\\s+(.+)$",
50
55
  "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
51
56
  // Chinese equivalents
52
57
  "^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
53
58
  "^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
54
- "^\\s*别忘了[::,,]?\\s*(.+)$",
59
+ // D67: separator required. "别忘了带伞" is narration ("don't forget your
60
+ // umbrella"); "别忘了:周五不发布" is an instruction to the store. Same
61
+ // rule as the English "don't forget" above.
62
+ "^\\s*别忘了[::,,]\\s*(.+)$",
55
63
  // First-person plural: team/project context, NOT personal
56
64
  "^\\s*我们认为[::,,]?\\s*(.+)$",
57
65
  "^\\s*我们决定[::,,]?\\s*(.+)$",
58
- "^\\s*帮我们记(?:住|一下)?[::,,]?\\s*(.+)$",
66
+ // D67: the boundary applies to the BARE verb only. 记 + (住|录) + 一下 is a
67
+ // complete verb phrase, so the noun may follow directly ("帮我记住这个配置",
68
+ // "记一下这个约定"); only a bare 记 needs a separator, otherwise 帮我记得…
69
+ // would capture mid-word garbage. See D67.
70
+ "^\\s*(?:请)?帮我们记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
59
71
  ],
60
72
  keywordPersonalPatterns: [
61
73
  "^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
62
74
  "^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
63
75
  // First-person singular: personal scope ("我" → 个人, "我们" → 项目)
64
76
  "^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
65
- "^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
66
- "^\\s*帮我记(?:住|一下)?[::,,]?\\s*(.+)$",
77
+ "^\\s*(?:请)?替我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
78
+ "^\\s*(?:请)?帮我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
79
+ "^\\s*help\\s+me\\s+remember(?:\\s+that)?[:,]?\\s+(.+)$",
67
80
  "^\\s*我觉得[::,,]?\\s*(.+)$",
68
81
  "^\\s*我喜欢[::,,]?\\s*(.+)$",
69
82
  ],
package/src/submit.ts CHANGED
@@ -170,7 +170,7 @@ export function outboxDraftCount(scopeKey: string): number {
170
170
  return row?.n ?? 0;
171
171
  }
172
172
 
173
- export function formatSyncStatus(st: SyncStatus): string {
173
+ export function formatSyncStatus(st: SyncStatus, opts: { cap?: number } = {}): string {
174
174
  const lines: string[] = [];
175
175
  lines.push(`project: ${st.projectName} (${st.scopeKey})`);
176
176
  // D31: when the index was last synced and what triggered it.
@@ -182,12 +182,16 @@ export function formatSyncStatus(st: SyncStatus): string {
182
182
  } else {
183
183
  lines.push(`last sync: never`);
184
184
  }
185
- const CAP = 20;
185
+ // Agent-facing renders cap each section so a big store can't flood a
186
+ // tool result; the cap line points at the CLI, which shows everything.
187
+ const CAP = opts.cap ?? 20;
188
+ const more = (n: number) =>
189
+ ` … and ${n} more (full list: open-memex sync-status)`;
186
190
  lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
187
191
  for (const e of st.outbox.slice(0, CAP)) {
188
192
  lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
189
193
  }
190
- if (st.outbox.length > CAP) lines.push(` … and ${st.outbox.length - CAP} more`);
194
+ if (st.outbox.length > CAP) lines.push(more(st.outbox.length - CAP));
191
195
  const byState = new Map<string, number>();
192
196
  for (const e of st.inRepo) byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
193
197
  const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
@@ -196,7 +200,7 @@ export function formatSyncStatus(st: SyncStatus): string {
196
200
  const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
197
201
  lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
198
202
  }
199
- if (st.inRepo.length > CAP) lines.push(` … and ${st.inRepo.length - CAP} more`);
203
+ if (st.inRepo.length > CAP) lines.push(more(st.inRepo.length - CAP));
200
204
  if (st.uncommitted.length > 0) {
201
205
  lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
202
206
  }
@@ -44,9 +44,14 @@ export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
44
44
  try {
45
45
  syncScope(getScope().key, "request");
46
46
  syncScope(PERSONAL_SCOPE.key, "request");
47
- } catch {
48
- // A failed sync must not take the tools down with it; the index
49
- // may be stale for this call but the tool still answers.
47
+ } catch (err) {
48
+ // Deliberate asymmetry with the MCP adapter (which lets sync errors
49
+ // propagate as tool errors): a failed sync must not take the plugin
50
+ // tools down — the index may be stale for this call but the tool
51
+ // still answers. Loud at debug, silent otherwise.
52
+ if (cfg.logLevel === "debug") {
53
+ console.error("[open-memex] pre-tool sync failed:", err);
54
+ }
50
55
  }
51
56
  };
52
57