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 +4 -2
- package/CHANGELOG.md +18 -0
- package/README.md +11 -4
- package/README.zh-CN.md +2 -1
- package/dist/.build-stamp +1 -0
- package/dist/capture/keywords.js +41 -8
- package/dist/cli.js +36 -21
- package/dist/config.js +18 -5
- package/dist/submit.js +7 -4
- package/dist/tools/memory.js +8 -3
- package/docs/V2-DESIGN.md +64 -3
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +63 -0
- package/scripts/smoke-pure.ts +78 -1
- package/scripts/test-full.ts +47 -7
- package/src/capture/keywords.ts +56 -6
- package/src/cli.ts +41 -22
- package/src/config.ts +18 -5
- package/src/submit.ts +8 -4
- package/src/tools/memory.ts +8 -3
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,
|
|
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
|
-
`记住…` /
|
|
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 …`——中文 `记住…` /
|
|
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
|
package/dist/capture/keywords.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
728
|
+
const { hits, dropped } = scanKeywords(text, cfg);
|
|
726
729
|
if (hits.length === 0) {
|
|
727
|
-
console.log("no keyword triggers
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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+)?
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
30
|
-
"^\\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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|
package/dist/tools/memory.js
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
//
|
|
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–
|
|
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
|
|
573
|
-
|
|
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.
|
|
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
|
+
}
|
package/scripts/smoke-pure.ts
CHANGED
|
@@ -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");
|
package/scripts/test-full.ts
CHANGED
|
@@ -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
|
-
|
|
500
|
-
`import {
|
|
501
|
-
`import {
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/capture/keywords.ts
CHANGED
|
@@ -8,16 +8,42 @@ export interface KeywordHit {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
813
|
+
const { hits, dropped } = scanKeywords(text, cfg);
|
|
809
814
|
if (hits.length === 0) {
|
|
810
|
-
console.log("no keyword triggers
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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+)?
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
66
|
-
"^\\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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/tools/memory.ts
CHANGED
|
@@ -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
|
-
//
|
|
49
|
-
//
|
|
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
|
|