open-memex 0.7.0-alpha.13 → 0.7.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -14,7 +14,7 @@ via `prepublishOnly` — Node refuses `--experimental-strip-types` for files und
14
14
  - **CLI** (`src/cli.ts`) and smoke tests run under **Node 22+** with `--experimental-strip-types`. SQLite here is `better-sqlite3` (native module).
15
15
  - **MCP server** (`src/mcp.ts`, stdio) runs under **Node 22+** with `--experimental-strip-types`. It exposes the same eleven memory tools to any MCP client (VS Code Copilot, Cursor, Claude Code). **stdout is the protocol channel — never log to stdout in `mcp.ts`; diagnostics go to stderr.**
16
16
 
17
- `src/store/db.ts` picks the backend at runtime by sniffing `globalThis.Bun`. Both backends share the same surface (`new Database(path)`, `.exec`, `.prepare().run/all/get`, `.close`). Any DB code you write must stay on that common subset — do not import `better-sqlite3` or `bun:sqlite` directly outside `db.ts`.
17
+ `src/store/db.ts` picks the backend at runtime by sniffing `globalThis.Bun`. Both backends share the same surface (`new Database(path)`, `.exec`, `.prepare().run/all/get`, `.close`). Any DB code you write must stay on that common subset — do not import `better-sqlite3` or `bun:sqlite` directly outside `db.ts`. The Node driver is pinned to the `better-sqlite3` 13 N-API line (D72): the pre-13 `node::ObjectWrap` build aborts intermittently on Node 24.19+ when a prepared statement is finalized by GC.
18
18
 
19
19
  Tool logic is host-agnostic and lives in `src/tools/ops.ts` (plain functions + shared zod arg shapes + `TOOL_DESCRIPTIONS`). `src/tools/memory.ts` (opencode) and `src/mcp.ts` are thin adapters — when adding or changing a tool, change `ops.ts` once and both hosts pick it up.
20
20
 
@@ -162,15 +162,17 @@ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
162
162
 
163
163
  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).
164
164
 
165
+ D73 — one user statement, one memory. The hook and the agent are two writers for one turn, and only the hook knows the sentence is stored. Both hooks therefore take the session id (`chat.message` on v1, `prompt` on v2), `captureFromText` records each capture in `PluginState.captures`, and the system/context hook pushes the hand-off note (`captureHandoff` → `formatHandoffBlock`, `src/capture/handoff.ts`) once per capture. `memory_add` additionally refuses a write overlapping a `source: keyword`/`user` memory created in the same scope within `TURN_ECHO_WINDOW_MS` (`findTurnEcho`, threshold `TURN_ECHO_THRESHOLD` = 0.28) and returns the stored id plus the supersede route. Keep the note, the refusal text, `TOOL_DESCRIPTIONS.memory_add`, the skill, and this paragraph saying the same thing — an agent that reads only one of them still has to behave. Never lower `TURN_ECHO_THRESHOLD` to catch more: the measured reason it is high is that an agent's gloss (0.211) and a genuinely new fact from the same sentence (0.214) are lexically indistinguishable.
166
+
165
167
  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.
166
168
 
167
169
  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.
168
170
 
169
171
  ## Design constraints — read the frozen design first
170
172
 
171
- `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D71; open questions tracked at the end of the doc). Per its §12:
173
+ `docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through D73; open questions tracked at the end of the doc). Per its §12:
172
174
  AGENTS.md answers "how should AI work here"; the design doc answers "why is it
173
- built this way" (principles, iron rules, the append-only decision log, currently D1–D71). Before changing
175
+ built this way" (principles, iron rules, the append-only decision log, currently D1–D73). Before changing
174
176
  architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
175
177
  schema, MCP tools, CLI contract), read the relevant design section — the decision
176
178
  log records what was already considered and rejected.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,18 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## One thing you said is one memory (D73)
13
+
14
+ Saying "remember ." used to save the same statement up to three times: the keyword hook stored your sentence verbatim with no agent in the loop, then the agent - which could see the chat but not the store - saved its own paraphrase beside it, and `memory_add`'s near-duplicate check scored the two too far apart (0.21-0.38 against a 0.80 bar) to notice. `open-memex audit` reported zero near-duplicates while three copies sat in the folder, and search returned all of them.
15
+
16
+ - **The agent is told what was already stored.** When the keyword hook captures a sentence, the plugin records it against the session and the next system context carries a short note naming the stored memory. Do not save that text again in any rewording; save only what it does not contain, or `memory_supersede` it. Delivered once per capture, in memory only (nothing new is written to disk).
17
+ - **`memory_add` refuses the echo anyway.** A write that overlaps a memory captured from your own words in the same scope within the last 10 minutes is refused with the stored id, as a deterministic backstop for when the note is not enough. The bar is deliberately high (0.28 token overlap) and never applies to the agent's own inferences - a distinct fact the same sentence carried still saves normally. A refusal is recoverable: supersede the stored one, or re-save with only the new information.
18
+ - Reports nothing and changes no data: existing memories are not merged or rewritten, and the near-duplicate notice for ordinary agent saves is unchanged.
19
+
20
+ ## Node 24 CLI aborts: SQLite driver moves to the N-API line (D72)
21
+
22
+ - **Fixed: CLI processes intermittently aborted on Node 24.19+.** `better-sqlite3` 11 could finalize a prepared statement from a garbage-collection callback after Node's environment was gone (`RemoveEnvironmentCleanupHook ... Assertion failed`), killing the process with `SIGABRT` and losing its output even though the memory work had already landed. The driver is now `better-sqlite3` 13 (the N-API rewrite; Node �22 unchanged) - same API surface, no protocol or data change. If you build from source on Node 24.19+, reinstall dependencies so the new driver is picked up.
23
+
12
24
  ## Non-git folders shared one scope bucket (D71)
13
25
 
14
26
  - **Fixed: every non-git folder on a drive shared a single project scope.** OpenCode v1 reports the filesystem root (`C:\`, `/`) as the project `worktree` when the opened folder is not a git repo, and the plugin seeded its scope on that root — so unrelated folders like `C:\temp` and `C:\scratch` all wrote to one `project__workspace__…` bucket. The scope now seeds on the folder actually opened (`pickScopeRoot` in `src/scope.ts`); real repo worktrees are unchanged. Memories already saved under the shared bucket are not moved automatically: run `open-memex scopes` to spot it, then `open-memex migrate --from <old-key>`.
package/README.md CHANGED
@@ -375,7 +375,11 @@ Three ways memories get in:
375
375
  `don't forget the wifi password` never fire (add a separator — `别忘了:…`,
376
376
  `don't forget: …` — or `that` to make it an instruction); and a captured body
377
377
  under 3 characters is rejected as a fragment rather than saved — `capture
378
- --dry-run` says so instead of dropping it silently.
378
+ --dry-run` says so instead of dropping it silently. A trigger also owns its
379
+ own sentence: the plugin tells the agent what it just stored, and a
380
+ `memory_add` that re-saves the same words within the next few minutes is
381
+ refused with the stored id (D73) — one thing you said is one memory, not
382
+ three copies of it.
379
383
  - **The agent saves it**: in any editor, ask your agent to remember something
380
384
  (or it saves on its own when you state a fact worth keeping) — it calls
381
385
  `memory_add`. The routing above is a heuristic; you can always say "save this
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 …`、`help me remember: …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。两条降噪规则(D67):触发词必须是**对存储说的话**,所以叙述型的 `记得…` / `remind me to…` / `别忘了带伞` / `don't forget the wifi password` 一律不触发(加一个分隔符——`别忘了:…`、`don't forget: …`——或用 `that`,才算指令);捕获内容不足 3 个字符按碎片丢弃,不会存下来——`capture --dry-run` 会明确说明,不再悄悄吞掉。
255
+ - **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget: …`、`TIL …`、`save this …`——中文 `记住…` / `记一下…` / `记录一下…` / `别忘了:…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`help me remember: …`、`记住我…`、`替我记…`、`帮我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。两条降噪规则(D67):触发词必须是**对存储说的话**,所以叙述型的 `记得…` / `remind me to…` / `别忘了带伞` / `don't forget the wifi password` 一律不触发(加一个分隔符——`别忘了:…`、`don't forget: …`——或用 `that`,才算指令);捕获内容不足 3 个字符按碎片丢弃,不会存下来——`capture --dry-run` 会明确说明,不再悄悄吞掉。另外,一句触发捕获的话只归一条记忆:插件会把它刚存下的内容告诉助手,几分钟内再用 `memory_add` 重复存同样的字眼会被拒绝(并给出已存的 id,D73)——你说的一件事就是一条记忆,不是它的三个副本。
256
256
  - **助手主动保存**:在任何编辑器里,你让助手记住某件事(或者你陈述了一个值得记的事实、它自己判断该保存),它会调用 `memory_add`。上面的路由是启发式规则;你可以明说"存到我的个人记忆",或用 CLI 加 `--scope` 明确指定。
257
257
  - **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
258
258
  - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tag` / `--type` / `--aliases`。
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- f8a813363fbd1433ce149e78defa533d42be7ccbb7f37f1d53be64cd48b90c14
1
+ c84b05d4afadebb4346f31a95ca00c4075a700269fc5019453799d0eeeb8b09e
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Turn hand-off between keyword capture and the agent (D73).
3
+ *
4
+ * The keyword hook and the agent are two writers for one user turn. The hook
5
+ * stores the user's sentence verbatim, with no agent in the loop; the agent
6
+ * then sees only the chat transcript and cannot tell the sentence is already
7
+ * stored, so it saves its own paraphrase — the same statement lands two or
8
+ * three times (observed 2026-10-04: one `remember …` sentence → one
9
+ * `source: keyword` file plus two `source: tool` files).
10
+ *
11
+ * Two mechanisms, in this order:
12
+ * (a) the hand-off note — this module's `formatHandoffBlock`, pushed into the
13
+ * turn's system context so the agent knows what is already stored;
14
+ * (b) the turn-echo guard — `TURN_ECHO_THRESHOLD` below, enforced in
15
+ * `addMemory` (src/tools/ops.ts) over what the hook just wrote.
16
+ *
17
+ * Pure module: no db, no fs, no config, so the threshold behavior is testable
18
+ * in scripts/smoke-pure.ts.
19
+ */
20
+ /**
21
+ * Overlap bar for refusing a re-save of a just-captured user statement.
22
+ *
23
+ * Deliberately high, and only ever applied to memories the *hook* wrote
24
+ * (`source: keyword` / `user`) inside `TURN_ECHO_WINDOW_MS`. Measured on the
25
+ * reported case and its near neighbours (token Jaccard, see
26
+ * `similarity` in src/store/lifecycle.ts): a wholesale re-save of the captured
27
+ * sentence scores 0.29–0.36, while a genuinely distinct fact the same
28
+ * sentence also contained scores 0.21 and below. Lexical overlap cannot
29
+ * separate an agent's gloss from a new fact — those two measured 0.211 and
30
+ * 0.214 — so the guard sits at the high-confidence end and everything under
31
+ * the bar is left to (a) and the agent's judgment. A refusal is always
32
+ * reported with the stored id, so the caller can supersede or rephrase.
33
+ */
34
+ export const TURN_ECHO_THRESHOLD = 0.28;
35
+ /** How long a hook capture counts as "this turn" (10 minutes). */
36
+ export const TURN_ECHO_WINDOW_MS = 10 * 60 * 1000;
37
+ export function isTurnEcho(score) {
38
+ return score >= TURN_ECHO_THRESHOLD;
39
+ }
40
+ /** Records still inside the window. */
41
+ export function freshCaptures(records, now) {
42
+ return records.filter((r) => now - r.at <= TURN_ECHO_WINDOW_MS);
43
+ }
44
+ function preview(content) {
45
+ const one = content.replace(/\s+/g, " ").trim();
46
+ return one.length > 160 ? `${one.slice(0, 160)}…` : one;
47
+ }
48
+ /** The hand-off note (a). Empty string when there is nothing to hand off. */
49
+ export function formatHandoffBlock(records) {
50
+ if (records.length === 0)
51
+ return "";
52
+ const lines = records.map((r) => `- id=${r.id} (${r.scopeKey}) — "${preview(r.content)}"`);
53
+ return [
54
+ "[open-memex] The user's own wording in this turn was ALREADY stored verbatim, before you saw it. It is not unsaved.",
55
+ ...lines,
56
+ "Do not call memory_add to save that text again in any rewording — one statement is already one memory, and a rewording only adds a second copy that retrieval will return alongside the first.",
57
+ "Save a memory only for information those lines do NOT contain, as its own self-contained statement. If your version is meant to replace one of them, call memory_supersede with that id instead of memory_add.",
58
+ ].join("\n");
59
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
1
+ import { PLUGIN_ID, bootstrapPlugin, captureFromText, captureHandoff, injectOnce, } from "./plugin-core.js";
2
2
  import { pickScopeRoot } from "./scope.js";
3
3
  import { makeTools } from "./tools/memory.js";
4
4
  import { v2Plugin } from "./opencode-v2.js";
@@ -19,18 +19,24 @@ const server = async ({ worktree, directory }) => {
19
19
  const tools = makeTools(() => state.scope, state.cfg);
20
20
  return {
21
21
  tool: tools,
22
- async "chat.message"(_input, output) {
22
+ async "chat.message"(input, output) {
23
23
  const parts = (output.parts ?? []);
24
24
  const text = parts
25
25
  .map((p) => (p?.type === "text" ? p.text ?? "" : ""))
26
26
  .filter(Boolean)
27
27
  .join("\n");
28
- captureFromText(text, state);
28
+ captureFromText(text, state, input.sessionID ?? "");
29
29
  },
30
30
  async "experimental.chat.system.transform"(input, output) {
31
- injectOnce(input.sessionID ?? "", state, (block) => {
31
+ const sessionID = input.sessionID ?? "";
32
+ injectOnce(sessionID, state, (block) => {
32
33
  output.system.push(block);
33
34
  });
35
+ // D73: runs on every request, so the note lands on the very turn whose
36
+ // message the hook just captured. Delivered once per capture.
37
+ const handoff = captureHandoff(sessionID, state);
38
+ if (handoff)
39
+ output.system.push(handoff);
34
40
  },
35
41
  };
36
42
  };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
2
+ import { PLUGIN_ID, bootstrapPlugin, captureFromText, captureHandoff, injectOnce, } from "./plugin-core.js";
3
3
  import { makeTools } from "./tools/memory.js";
4
4
  /** Register the five memory tools on a v2 host. */
5
5
  async function registerTools(ctx, state) {
@@ -28,12 +28,17 @@ export const v2Plugin = {
28
28
  const registrations = [];
29
29
  await registerTools(ctx, state);
30
30
  registrations.push(await ctx.session.hook("prompt", (ev) => {
31
- captureFromText(ev.prompt?.text ?? "", state);
31
+ captureFromText(ev.prompt?.text ?? "", state, ev.sessionID ?? "");
32
32
  }));
33
33
  registrations.push(await ctx.session.hook("context", (ev) => {
34
- injectOnce(ev.sessionID ?? "", state, (block) => {
34
+ const sessionID = ev.sessionID ?? "";
35
+ injectOnce(sessionID, state, (block) => {
35
36
  ev.system.push({ type: "text", text: block });
36
37
  });
38
+ // D73: tell the agent what the keyword hook already stored this turn.
39
+ const handoff = captureHandoff(sessionID, state);
40
+ if (handoff)
41
+ ev.system.push({ type: "text", text: handoff });
37
42
  }));
38
43
  return () => {
39
44
  for (const r of registrations)
@@ -6,6 +6,7 @@ import { scopeHasFiles } from "./store/migrate.js";
6
6
  import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
7
7
  import { buildContextBlock } from "./retrieve/inject.js";
8
8
  import { detectKeywords } from "./capture/keywords.js";
9
+ import { formatHandoffBlock, freshCaptures, } from "./capture/handoff.js";
9
10
  import { findDuplicates } from "./store/lifecycle.js";
10
11
  import { redact } from "./redact.js";
11
12
  /**
@@ -44,14 +45,16 @@ export function bootstrapPlugin(root) {
44
45
  catch (err) {
45
46
  console.error("[open-memex] init failed:", err);
46
47
  }
47
- return { cfg, scope, injectedSessions: new Set() };
48
+ return { cfg, scope, injectedSessions: new Set(), captures: new Map() };
48
49
  }
49
50
  /**
50
51
  * Keyword capture (§ capture): scan one user message for "remember …"
51
52
  * triggers and save each hit. Shared by the v1 `chat.message` hook and
52
- * the v2 session `prompt` hook — both hand us the message text.
53
+ * the v2 session `prompt` hook — both hand us the message text and the
54
+ * session id. The session id is what makes the D73 hand-off possible: without
55
+ * it the agent never learns the sentence is already stored.
53
56
  */
54
- export function captureFromText(text, state) {
57
+ export function captureFromText(text, state, sessionID) {
55
58
  const { cfg, scope } = state;
56
59
  if (!cfg.keywordCaptureEnabled || !text)
57
60
  return;
@@ -106,12 +109,54 @@ export function captureFromText(text, state) {
106
109
  // offers no toast channel, so the plugin log is the feedback surface.
107
110
  const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
108
111
  console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
112
+ // D73 hand-off: the agent gets told about this capture in the turn's
113
+ // system context, so it does not save the same statement again.
114
+ recordCapture(state, sessionID, { id: fm.id, scopeKey: target.key, content, at: now });
109
115
  }
110
116
  catch (err) {
111
117
  console.error("[open-memex] keyword capture failed:", err);
112
118
  }
113
119
  }
114
120
  }
121
+ /** Note one hook capture against its session (D73). No-op without a session. */
122
+ function recordCapture(state, sessionID, record) {
123
+ if (!sessionID)
124
+ return;
125
+ const list = state.captures.get(sessionID) ?? [];
126
+ list.push(record);
127
+ state.captures.set(sessionID, list);
128
+ }
129
+ /**
130
+ * D73 hand-off note (a): what the keyword hook already stored for this
131
+ * session, delivered once per capture into the turn's system context. The
132
+ * agent cannot avoid re-saving a statement it cannot see is stored, so the
133
+ * store tells it. Returns "" when there is nothing new to hand off; stale
134
+ * records are pruned (in-memory only, like `injectedSessions`).
135
+ *
136
+ * Both host adapters push this from their system/context hook — the one that
137
+ * runs before the model request, i.e. after `chat.message` / `prompt`.
138
+ */
139
+ export function captureHandoff(sessionID, state) {
140
+ if (!sessionID)
141
+ return "";
142
+ const list = state.captures.get(sessionID);
143
+ if (!list || list.length === 0)
144
+ return "";
145
+ const now = Date.now();
146
+ const fresh = freshCaptures(list, now);
147
+ if (fresh.length !== list.length) {
148
+ if (fresh.length === 0)
149
+ state.captures.delete(sessionID);
150
+ else
151
+ state.captures.set(sessionID, fresh);
152
+ }
153
+ const pending = fresh.filter((r) => !r.delivered);
154
+ if (pending.length === 0)
155
+ return "";
156
+ for (const r of pending)
157
+ r.delivered = true;
158
+ return formatHandoffBlock(pending);
159
+ }
115
160
  /**
116
161
  * First-turn context injection: exactly once per session. The host adapter
117
162
  * supplies `push` (v1: output.system.push; v2: the session context hook's
@@ -5,6 +5,7 @@ import { db } from "./db.js";
5
5
  import { paths, projectRoot, inRepoMemoriesDirPath, } from "../paths.js";
6
6
  import { loadConfig } from "../config.js";
7
7
  import { cjkIndexText } from "../retrieve/cjk.js";
8
+ import { isTurnEcho } from "../capture/handoff.js";
8
9
  import { parse, serialize, atomicWriteTextSync, ulid, msToRfc3339, } from "./markdown.js";
9
10
  /**
10
11
  * Dedup + lifecycle (V2-DESIGN §3.3, §3.4).
@@ -40,6 +41,10 @@ export function similarity(a, b) {
40
41
  return union === 0 ? 0 : inter / union;
41
42
  }
42
43
  export const NEAR_DUP_THRESHOLD = 0.8;
44
+ /** `source` values the keyword hook (or an agent marking a user statement) writes.
45
+ * Only these are turn-echo candidates — a `tool` memory is the agent's own
46
+ * inference and keeps the 0.8 near-dup notice instead of a refusal. */
47
+ const ECHO_SOURCES = "('keyword','user')";
43
48
  /** Find exact + near duplicates of `body` among ACTIVE memories in scope. */
44
49
  export function findDuplicates(scopeKey, body, excludeId) {
45
50
  const hash = contentHash(body);
@@ -64,6 +69,43 @@ export function findDuplicates(scopeKey, body, excludeId) {
64
69
  near.sort((x, y) => y.score - x.score);
65
70
  return { exact, near: near.slice(0, 3) };
66
71
  }
72
+ /**
73
+ * D73: memories written from the user's own words moments ago — the keyword
74
+ * hook's captures, plus agent saves marked `source: user`. `memory_add` uses
75
+ * this to refuse a re-save of a statement the store already holds verbatim
76
+ * (see TURN_ECHO_THRESHOLD in src/capture/handoff.ts for why the bar is high).
77
+ * `created_at` is epoch ms in the index, so the window is a numeric compare.
78
+ */
79
+ export function findTurnEcho(scopeKey, body, windowMs) {
80
+ const cutoff = Date.now() - windowMs;
81
+ let rows;
82
+ try {
83
+ rows = db()
84
+ .prepare(`SELECT id, content, source, created_at FROM memories
85
+ WHERE scope_key = ? AND status = 'active'
86
+ AND source IN ${ECHO_SOURCES} AND created_at >= ?`)
87
+ .all(scopeKey, cutoff);
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ const out = [];
93
+ for (const r of rows) {
94
+ if (typeof r.created_at !== "number" || r.created_at < cutoff)
95
+ continue;
96
+ const score = similarity(body, r.content);
97
+ if (isTurnEcho(score)) {
98
+ out.push({
99
+ id: r.id,
100
+ score,
101
+ source: r.source,
102
+ snippet: r.content.replace(/\s+/g, " ").trim().slice(0, 120),
103
+ });
104
+ }
105
+ }
106
+ out.sort((x, y) => y.score - x.score);
107
+ return out.slice(0, 3);
108
+ }
67
109
  /** Locate a memory file by id: index file_path first, then dir-scan fallback. */
68
110
  export function findMemoryFile(id) {
69
111
  const { memories } = paths();
package/dist/tools/ops.js CHANGED
@@ -13,7 +13,8 @@ import { search, list, countList, hitStateLabel } from "../retrieve/search.js";
13
13
  import { formatInventoryLine, truncationNote } from "../retrieve/display.js";
14
14
  import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, normalizeAliases, } from "../store/markdown.js";
15
15
  import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
16
- import { findDuplicates, supersede, setStatus } from "../store/lifecycle.js";
16
+ import { findDuplicates, findTurnEcho, supersede, setStatus } from "../store/lifecycle.js";
17
+ import { TURN_ECHO_WINDOW_MS } from "../capture/handoff.js";
17
18
  import { db } from "../store/db.js";
18
19
  import { redact } from "../redact.js";
19
20
  import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.js";
@@ -37,7 +38,7 @@ export async function withOutboxNote(scopeKey, p, reviewHint) {
37
38
  }
38
39
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
39
40
  export const TOOL_DESCRIPTIONS = {
40
- memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
41
+ memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. If a note tells you the user's own wording was already stored verbatim this turn, that statement is already saved: do not retry it in a rewording — save only what that text does not contain, or use memory_supersede on the given id. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
41
42
  memory_search: "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Call before asking the user about past decisions, conventions, or preferences they may have told you before. Search well: break the question into its concepts and try 2–3 phrasings per concept — synonyms, the user's other language, shorter keyword forms — and check the other scope too, before concluding nothing is stored.",
42
43
  memory_list: "List memories in a scope, newest first, as a numbered inventory. Each line keeps the raw fields — [type] id=… created=… source=… — say them back to the user in plain words (source=user → 'you told me this'; inference → 'I inferred this, check me'; keyword → 'caught from your own wording'), never read the raw id aloud unless they ask. When the user asks 'what do you remember about me?', use scope=both and present the inventory conversationally. The user may point at an entry by its number ('delete #3'): numbers are only valid for the listing you just produced — re-run memory_list, read the candidate back in full, and get a confirmation before calling memory_forget (deletion is permanent). include=all is the audit view (superseded versions, retracted, archived also shown).",
43
44
  memory_supersede: "Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
@@ -197,6 +198,26 @@ export async function addMemory(getScope, cfg, args) {
197
198
  output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
198
199
  };
199
200
  }
201
+ // D73: one user statement, one memory. The keyword hook stores the user's
202
+ // sentence verbatim with no agent in the loop; a paraphrase of that same
203
+ // sentence is refused instead of written beside it. The stored id comes back
204
+ // so the caller can supersede it or save only the genuinely new part.
205
+ const echo = findTurnEcho(s.key, redacted, TURN_ECHO_WINDOW_MS);
206
+ if (echo.length > 0) {
207
+ const top = echo[0];
208
+ const others = echo
209
+ .slice(1)
210
+ .map((e) => `id=${e.id} (${e.source}, overlap ${e.score.toFixed(2)})`)
211
+ .join(", ");
212
+ return {
213
+ title: "memory: already captured verbatim",
214
+ output: `Not saved — the user's own words were already stored moments ago (source: ${top.source}, overlap ${top.score.toFixed(2)}): ` +
215
+ `id=${top.id} — ${top.snippet}` +
216
+ (others ? `\nAlso matching: ${others}` : "") +
217
+ `\nDo not retry with a rewording: that only adds a second copy of one statement. ` +
218
+ `Save a separate memory only for information that stored text does not contain, or call memory_supersede on that id if this replaces it.`,
219
+ };
220
+ }
200
221
  const fm = buildFrontmatter(s, {
201
222
  type: args.type ?? "fact",
202
223
  tags: args.tags ?? [],
package/docs/TEST-PLAN.md CHANGED
@@ -73,7 +73,7 @@
73
73
 
74
74
  ## H. 已知问题观察
75
75
 
76
- - [ ] better-sqlite3 在 Node 24 退出时偶发 crash(exit 134):注意是否丢数据(预期:不丢,只影响退出码)
76
+ - [x] better-sqlite3 在 Node 24 退出时偶发 crash(exit 134):D72 已把驱动升到 13(N-API 线);Linux Node 24.20 连续 3 次 `test-full` 114/114。Windows 真机仍需确认同一版本下无 `SIGABRT` / exit 134,且不丢数据。
77
77
  - [ ] `memory_list` 默认只列 project scope 是否符合预期(Stone 已定保持现状)
78
78
 
79
79
  ---
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–D71 are settled; open questions are tracked at the end of this document.
3
+ **Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D73 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;
@@ -1469,6 +1469,47 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1469
1469
  entry back before deleting. The HTML rendering of the same data layer
1470
1470
  follows as a separate change; auto-draft stays gated on the unlock
1471
1471
  conditions in the design doc.
1472
+ - **D73 — one user statement, one memory: the keyword hook hands off to the
1473
+ agent.** The keyword hook and the agent are two writers for one user turn,
1474
+ and only one of them knows the sentence is already stored. Observed
1475
+ 2026-10-04: a single `remember this folder is for my spark tank ideas …
1476
+ shared memory across agents and employees` produced **three** memories — one
1477
+ `source: keyword` verbatim capture plus two `source: tool` paraphrases the
1478
+ agent wrote because it could see the chat transcript but not the store. The
1479
+ existing §3.4 guard could not catch it: token Jaccard scored those
1480
+ paraphrases 0.21–0.38 against the capture, below the 0.8 near-dup bar, and
1481
+ `open-memex audit` reported zero near-dup pairs, so the store looked healthy
1482
+ while holding three copies of one statement (retrieval then returns all
1483
+ three). Two mechanisms, deliberately layered:
1484
+ **(a) hand-off note** — `captureFromText` records each capture against the
1485
+ host session id (`chat.message` / v2 `prompt`, both of which carry one), and
1486
+ the system-context hook (`experimental.chat.system.transform` / v2
1487
+ `context`) pushes a short block naming what was already stored, delivered
1488
+ once per capture and pruned after the turn window. The agent can now avoid
1489
+ the duplicate write it had no way to see. In-memory only, like
1490
+ `injectedSessions`: never persisted, gone on restart.
1491
+ **(b) turn-echo guard** — `memory_add` additionally refuses a write that
1492
+ overlaps a `source: keyword`/`user` memory created in the same scope within
1493
+ the last 10 minutes, returning the stored id and pointing at
1494
+ `memory_supersede`. This is the deterministic backstop for the case where
1495
+ (a) is ignored. It is deliberately scoped and high-barred, because lexical
1496
+ overlap **cannot** separate an agent's gloss from a genuinely new fact: in
1497
+ the same measured set, the agent's gloss scored 0.211 and a distinct fact
1498
+ the same sentence happened to contain scored 0.214. So the bar sits at
1499
+ 0.28 — wholesale re-saves of the captured sentence measure 0.29–0.36 and are
1500
+ refused; anything below is left to (a) and the agent's judgment, and a
1501
+ refusal always names the stored id so the caller can rephrase to the new
1502
+ part or supersede. Agent-authored `source: tool` saves keep the old 0.8
1503
+ near-dup *notice* and are never refused. The refused case is recoverable by
1504
+ construction (supersede, or a follow-up add carrying only the new
1505
+ information), and no D14-style masking, dedup, or lifecycle rule changes.
1506
+ What is deliberately NOT done: no fuzzy-matching upgrade (D73 measures why a
1507
+ better metric still cannot decide "new information"), no auto-merging of the
1508
+ agent's gloss into the capture, and no transcript store (the replay-distillation
1509
+ open question owns that).
1510
+
1511
+ - **D72 — the Node SQLite driver moves to the N-API line.** The D70 suite was stable on Windows and failed in shifting clusters on Linux under Node 24.20: individual CLI processes intermittently died with `SIGABRT` in `Statement::~Statement()` (`RemoveEnvironmentCleanupHook ... Assertion failed: (env) != nullptr`) and lost their stdout, so unrelated supersede / submit / inventory checks failed depending on which process the GC reaped. The work itself had always landed; only the exit path was broken. Root cause is upstream: Node 24.19 changed `node::ObjectWrap` cleanup-hook registration, and `better-sqlite3` <13 (raw `node::ObjectWrap`) can finalize a prepared statement from a GC callback with no Environment entered. `better-sqlite3` 13 is the N-API rewrite and removes that path; it still supports Node ≥22, and every API this repo uses (`Database`, `prepare`, `exec`, `pragma`-style PRAGMAs, `close`) is unchanged. The suite's crash-retry shim stays as belt-and-braces for older installs, but the shipped driver no longer needs it on current Node. Verified: three consecutive Linux `test-full` runs at 114/114 plus a 40-command add/list/search loop with zero aborts (was ~50% per process).
1512
+
1472
1513
  - **D71 — the scope seed is the folder opened, never a filesystem root.**
1473
1514
  OpenCode v1 hands the plugin two roots: `worktree` (the repo root — but
1474
1515
  the filesystem root itself, `C:\` or `/`, when the opened folder is not a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.0-alpha.13",
3
+ "version": "0.7.0-alpha.15",
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",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.30.1",
26
- "better-sqlite3": "^11.7.0",
26
+ "better-sqlite3": "^13.0.3",
27
27
  "js-yaml": "^4.1.0",
28
28
  "zod": "^4.1.8"
29
29
  },
@@ -9,6 +9,13 @@ import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } f
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
11
11
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
12
+ import {
13
+ TURN_ECHO_THRESHOLD,
14
+ TURN_ECHO_WINDOW_MS,
15
+ isTurnEcho,
16
+ freshCaptures,
17
+ formatHandoffBlock,
18
+ } from "../src/capture/handoff.ts";
12
19
  import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
13
20
  import { z } from "zod";
14
21
  import { memoryAddArgs } from "../src/tools/ops.ts";
@@ -615,6 +622,50 @@ const near = similarity(
615
622
  ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
616
623
  ok("similarity empty → 0", similarity("", "anything") === 0);
617
624
 
625
+ console.log("== handoff (turn echo, D73) ==");
626
+ // One "remember ..." sentence produced three memories on 2026-10-04: the
627
+ // hook's verbatim capture plus two agent paraphrases. The hook capture is
628
+ // what the store already holds; the guard must refuse a re-save of it and
629
+ // must NOT eat a genuinely new fact the same sentence carried.
630
+ const HOOK_CAPTURE =
631
+ "this folder is for my spark tank ideas for ipipeline. I have ideas for AI Agents and shared memory/knowledge accross agents and employees.";
632
+ const reworded = [
633
+ `User's iPipeline spark-tank themes: AI Agents, and shared memory/knowledge across agents and across employees.`,
634
+ `This folder (C:\\temp\\AI_Inovation) is the user's "spark tank" for iPipeline ideas`,
635
+ "Planned work in this folder: AI Agents plus shared memory/knowledge across agents and employees",
636
+ ];
637
+ const newFacts = [
638
+ "Deploy smoke tests live in tests/smoke/deploy.spec.ts and need a staging token",
639
+ "The staging cluster runs 3 nodes in us-east-1",
640
+ "Never deploy on Fridays after 15:00 UTC",
641
+ ];
642
+ for (const r of reworded) {
643
+ const s = similarity(HOOK_CAPTURE, r);
644
+ ok(`refuses a re-save (${s.toFixed(2)} >= ${TURN_ECHO_THRESHOLD})`, isTurnEcho(s));
645
+ }
646
+ for (const f of newFacts) {
647
+ const s = similarity(HOOK_CAPTURE, f);
648
+ ok(`keeps a new fact (${s.toFixed(2)} < ${TURN_ECHO_THRESHOLD})`, !isTurnEcho(s));
649
+ }
650
+ ok("threshold is inclusive", isTurnEcho(TURN_ECHO_THRESHOLD));
651
+ ok("below threshold passes", !isTurnEcho(TURN_ECHO_THRESHOLD - 0.001));
652
+
653
+ const now = 1_700_000_000_000;
654
+ const rec = { id: "01ABC", scopeKey: "project__demo__0123456789ab", content: HOOK_CAPTURE, at: now };
655
+ ok("fresh inside window", freshCaptures([rec], now + 1000).length === 1);
656
+ ok("stale past window", freshCaptures([rec], now + TURN_ECHO_WINDOW_MS + 1).length === 0);
657
+ ok("no records → no block", formatHandoffBlock([]) === "");
658
+ const block = formatHandoffBlock([rec]);
659
+ ok("block names the id", block.includes("01ABC"));
660
+ ok("block shows the stored text", block.includes("spark tank"));
661
+ ok("block says do not re-add", block.includes("Do not call memory_add"));
662
+ ok("block points at supersede", block.includes("memory_supersede"));
663
+ ok(
664
+ "block collapses whitespace",
665
+ !formatHandoffBlock([{ ...rec, content: "a\n\n b" }]).includes("\n\n"),
666
+ );
667
+ ok("window is 10 minutes", TURN_ECHO_WINDOW_MS === 10 * 60 * 1000);
668
+
618
669
  console.log("== init --global: userMcpConfigPath / mergeServerEntry ==");
619
670
  // D45: user-level MCP config locations, per platform (platform param is injectable).
620
671
  ok("vscode win32", userMcpConfigPath("vscode", "win32").endsWith(path.join("Code", "User", "mcp.json")));
@@ -25,6 +25,12 @@ open-memex add "I prefer concise diffs" --scope personal # applies everywhere
25
25
  Keep each memory one self-contained statement. Scope routing: facts about the
26
26
  user ("I"/"me") → `personal`; everything else → the current project.
27
27
 
28
+ One thing the user said is one memory. When their own wording triggered keyword
29
+ capture (`remember …`, `记住…`), that sentence is already stored — do not save it
30
+ again in any rewording. Save only what the stored text does not contain, as its
31
+ own statement; if your version replaces it, use `memory_supersede`. `memory_add`
32
+ refuses the echo and hands you the stored id.
33
+
28
34
  Worth saving: decisions and their reasons, preferences, conventions, gotchas,
29
35
  approaches tried and abandoned. Not worth saving: one-off task details or
30
36
  anything re-derivable from the code.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Turn hand-off between keyword capture and the agent (D73).
3
+ *
4
+ * The keyword hook and the agent are two writers for one user turn. The hook
5
+ * stores the user's sentence verbatim, with no agent in the loop; the agent
6
+ * then sees only the chat transcript and cannot tell the sentence is already
7
+ * stored, so it saves its own paraphrase — the same statement lands two or
8
+ * three times (observed 2026-10-04: one `remember …` sentence → one
9
+ * `source: keyword` file plus two `source: tool` files).
10
+ *
11
+ * Two mechanisms, in this order:
12
+ * (a) the hand-off note — this module's `formatHandoffBlock`, pushed into the
13
+ * turn's system context so the agent knows what is already stored;
14
+ * (b) the turn-echo guard — `TURN_ECHO_THRESHOLD` below, enforced in
15
+ * `addMemory` (src/tools/ops.ts) over what the hook just wrote.
16
+ *
17
+ * Pure module: no db, no fs, no config, so the threshold behavior is testable
18
+ * in scripts/smoke-pure.ts.
19
+ */
20
+
21
+ /**
22
+ * Overlap bar for refusing a re-save of a just-captured user statement.
23
+ *
24
+ * Deliberately high, and only ever applied to memories the *hook* wrote
25
+ * (`source: keyword` / `user`) inside `TURN_ECHO_WINDOW_MS`. Measured on the
26
+ * reported case and its near neighbours (token Jaccard, see
27
+ * `similarity` in src/store/lifecycle.ts): a wholesale re-save of the captured
28
+ * sentence scores 0.29–0.36, while a genuinely distinct fact the same
29
+ * sentence also contained scores 0.21 and below. Lexical overlap cannot
30
+ * separate an agent's gloss from a new fact — those two measured 0.211 and
31
+ * 0.214 — so the guard sits at the high-confidence end and everything under
32
+ * the bar is left to (a) and the agent's judgment. A refusal is always
33
+ * reported with the stored id, so the caller can supersede or rephrase.
34
+ */
35
+ export const TURN_ECHO_THRESHOLD = 0.28;
36
+
37
+ /** How long a hook capture counts as "this turn" (10 minutes). */
38
+ export const TURN_ECHO_WINDOW_MS = 10 * 60 * 1000;
39
+
40
+ /** One hook capture, in memory only — never persisted (like `injectedSessions`). */
41
+ export interface CaptureRecord {
42
+ id: string;
43
+ scopeKey: string;
44
+ content: string;
45
+ at: number;
46
+ /** The note has already been pushed for this capture. */
47
+ delivered?: boolean;
48
+ }
49
+
50
+ export function isTurnEcho(score: number): boolean {
51
+ return score >= TURN_ECHO_THRESHOLD;
52
+ }
53
+
54
+ /** Records still inside the window. */
55
+ export function freshCaptures(records: CaptureRecord[], now: number): CaptureRecord[] {
56
+ return records.filter((r) => now - r.at <= TURN_ECHO_WINDOW_MS);
57
+ }
58
+
59
+ function preview(content: string): string {
60
+ const one = content.replace(/\s+/g, " ").trim();
61
+ return one.length > 160 ? `${one.slice(0, 160)}…` : one;
62
+ }
63
+
64
+ /** The hand-off note (a). Empty string when there is nothing to hand off. */
65
+ export function formatHandoffBlock(records: CaptureRecord[]): string {
66
+ if (records.length === 0) return "";
67
+ const lines = records.map((r) => `- id=${r.id} (${r.scopeKey}) — "${preview(r.content)}"`);
68
+ return [
69
+ "[open-memex] The user's own wording in this turn was ALREADY stored verbatim, before you saw it. It is not unsaved.",
70
+ ...lines,
71
+ "Do not call memory_add to save that text again in any rewording — one statement is already one memory, and a rewording only adds a second copy that retrieval will return alongside the first.",
72
+ "Save a memory only for information those lines do NOT contain, as its own self-contained statement. If your version is meant to replace one of them, call memory_supersede with that id instead of memory_add.",
73
+ ].join("\n");
74
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  PLUGIN_ID,
4
4
  bootstrapPlugin,
5
5
  captureFromText,
6
+ captureHandoff,
6
7
  injectOnce,
7
8
  } from "./plugin-core.ts";
8
9
  import { pickScopeRoot } from "./scope.ts";
@@ -28,19 +29,24 @@ const server: Plugin = async ({ worktree, directory }) => {
28
29
  return {
29
30
  tool: tools,
30
31
 
31
- async "chat.message"(_input, output) {
32
+ async "chat.message"(input, output) {
32
33
  const parts = (output.parts ?? []) as Array<{ type: string; text?: string }>;
33
34
  const text = parts
34
35
  .map((p) => (p?.type === "text" ? p.text ?? "" : ""))
35
36
  .filter(Boolean)
36
37
  .join("\n");
37
- captureFromText(text, state);
38
+ captureFromText(text, state, input.sessionID ?? "");
38
39
  },
39
40
 
40
41
  async "experimental.chat.system.transform"(input, output) {
41
- injectOnce(input.sessionID ?? "", state, (block) => {
42
+ const sessionID = input.sessionID ?? "";
43
+ injectOnce(sessionID, state, (block) => {
42
44
  output.system.push(block);
43
45
  });
46
+ // D73: runs on every request, so the note lands on the very turn whose
47
+ // message the hook just captured. Delivered once per capture.
48
+ const handoff = captureHandoff(sessionID, state);
49
+ if (handoff) output.system.push(handoff);
44
50
  },
45
51
  };
46
52
  };
@@ -3,6 +3,7 @@ import {
3
3
  PLUGIN_ID,
4
4
  bootstrapPlugin,
5
5
  captureFromText,
6
+ captureHandoff,
6
7
  injectOnce,
7
8
  type PluginState,
8
9
  } from "./plugin-core.ts";
@@ -101,14 +102,18 @@ export const v2Plugin = {
101
102
 
102
103
  registrations.push(
103
104
  await ctx.session.hook("prompt", (ev: V2PromptEvent) => {
104
- captureFromText(ev.prompt?.text ?? "", state);
105
+ captureFromText(ev.prompt?.text ?? "", state, ev.sessionID ?? "");
105
106
  }),
106
107
  );
107
108
  registrations.push(
108
109
  await ctx.session.hook("context", (ev: V2ContextEvent) => {
109
- injectOnce(ev.sessionID ?? "", state, (block) => {
110
+ const sessionID = ev.sessionID ?? "";
111
+ injectOnce(sessionID, state, (block) => {
110
112
  ev.system.push({ type: "text", text: block });
111
113
  });
114
+ // D73: tell the agent what the keyword hook already stored this turn.
115
+ const handoff = captureHandoff(sessionID, state);
116
+ if (handoff) ev.system.push({ type: "text", text: handoff });
112
117
  }),
113
118
  );
114
119
 
@@ -12,6 +12,12 @@ import {
12
12
  } from "./store/markdown.ts";
13
13
  import { buildContextBlock } from "./retrieve/inject.ts";
14
14
  import { detectKeywords } from "./capture/keywords.ts";
15
+ import {
16
+ TURN_ECHO_WINDOW_MS,
17
+ formatHandoffBlock,
18
+ freshCaptures,
19
+ type CaptureRecord,
20
+ } from "./capture/handoff.ts";
15
21
  import { findDuplicates } from "./store/lifecycle.ts";
16
22
  import { redact } from "./redact.ts";
17
23
 
@@ -28,6 +34,12 @@ export interface PluginState {
28
34
  scope: Scope;
29
35
  /** Sessions that already received the one-time context injection. */
30
36
  injectedSessions: Set<string>;
37
+ /**
38
+ * D73: what the keyword hook stored this session, so the agent can be told
39
+ * before it re-saves the same statement. In memory only, like
40
+ * `injectedSessions` — never persisted, pruned by the turn window.
41
+ */
42
+ captures: Map<string, CaptureRecord[]>;
31
43
  }
32
44
 
33
45
  /**
@@ -60,15 +72,21 @@ export function bootstrapPlugin(root: string): PluginState {
60
72
  } catch (err) {
61
73
  console.error("[open-memex] init failed:", err);
62
74
  }
63
- return { cfg, scope, injectedSessions: new Set<string>() };
75
+ return { cfg, scope, injectedSessions: new Set<string>(), captures: new Map() };
64
76
  }
65
77
 
66
78
  /**
67
79
  * Keyword capture (§ capture): scan one user message for "remember …"
68
80
  * triggers and save each hit. Shared by the v1 `chat.message` hook and
69
- * the v2 session `prompt` hook — both hand us the message text.
81
+ * the v2 session `prompt` hook — both hand us the message text and the
82
+ * session id. The session id is what makes the D73 hand-off possible: without
83
+ * it the agent never learns the sentence is already stored.
70
84
  */
71
- export function captureFromText(text: string, state: PluginState): void {
85
+ export function captureFromText(
86
+ text: string,
87
+ state: PluginState,
88
+ sessionID?: string,
89
+ ): void {
72
90
  const { cfg, scope } = state;
73
91
  if (!cfg.keywordCaptureEnabled || !text) return;
74
92
  const hits = detectKeywords(text, cfg);
@@ -119,12 +137,53 @@ export function captureFromText(text: string, state: PluginState): void {
119
137
  // offers no toast channel, so the plugin log is the feedback surface.
120
138
  const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
121
139
  console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
140
+ // D73 hand-off: the agent gets told about this capture in the turn's
141
+ // system context, so it does not save the same statement again.
142
+ recordCapture(state, sessionID, { id: fm.id, scopeKey: target.key, content, at: now });
122
143
  } catch (err) {
123
144
  console.error("[open-memex] keyword capture failed:", err);
124
145
  }
125
146
  }
126
147
  }
127
148
 
149
+ /** Note one hook capture against its session (D73). No-op without a session. */
150
+ function recordCapture(
151
+ state: PluginState,
152
+ sessionID: string | undefined,
153
+ record: CaptureRecord,
154
+ ): void {
155
+ if (!sessionID) return;
156
+ const list = state.captures.get(sessionID) ?? [];
157
+ list.push(record);
158
+ state.captures.set(sessionID, list);
159
+ }
160
+
161
+ /**
162
+ * D73 hand-off note (a): what the keyword hook already stored for this
163
+ * session, delivered once per capture into the turn's system context. The
164
+ * agent cannot avoid re-saving a statement it cannot see is stored, so the
165
+ * store tells it. Returns "" when there is nothing new to hand off; stale
166
+ * records are pruned (in-memory only, like `injectedSessions`).
167
+ *
168
+ * Both host adapters push this from their system/context hook — the one that
169
+ * runs before the model request, i.e. after `chat.message` / `prompt`.
170
+ */
171
+ export function captureHandoff(sessionID: string, state: PluginState): string {
172
+ if (!sessionID) return "";
173
+ const list = state.captures.get(sessionID);
174
+ if (!list || list.length === 0) return "";
175
+ const now = Date.now();
176
+ const fresh = freshCaptures(list, now);
177
+ if (fresh.length !== list.length) {
178
+ if (fresh.length === 0) state.captures.delete(sessionID);
179
+ else state.captures.set(sessionID, fresh);
180
+ }
181
+ const pending = fresh.filter((r) => !r.delivered);
182
+ if (pending.length === 0) return "";
183
+ for (const r of pending) r.delivered = true;
184
+ return formatHandoffBlock(pending);
185
+ }
186
+
128
187
  /**
129
188
  * First-turn context injection: exactly once per session. The host adapter
130
189
  * supplies `push` (v1: output.system.push; v2: the session context hook's
@@ -9,6 +9,7 @@ import {
9
9
  } from "../paths.ts";
10
10
  import { loadConfig } from "../config.ts";
11
11
  import { cjkIndexText } from "../retrieve/cjk.ts";
12
+ import { isTurnEcho } from "../capture/handoff.ts";
12
13
  import {
13
14
  parse,
14
15
  serialize,
@@ -67,6 +68,11 @@ export interface DupResult {
67
68
 
68
69
  export const NEAR_DUP_THRESHOLD = 0.8;
69
70
 
71
+ /** `source` values the keyword hook (or an agent marking a user statement) writes.
72
+ * Only these are turn-echo candidates — a `tool` memory is the agent's own
73
+ * inference and keeps the 0.8 near-dup notice instead of a refusal. */
74
+ const ECHO_SOURCES = "('keyword','user')";
75
+
70
76
  interface IndexRow {
71
77
  id: string;
72
78
  content_hash: string;
@@ -103,6 +109,52 @@ export function findDuplicates(
103
109
  return { exact, near: near.slice(0, 3) };
104
110
  }
105
111
 
112
+ export interface TurnEchoCandidate extends DuplicateCandidate {
113
+ source: string;
114
+ }
115
+
116
+ /**
117
+ * D73: memories written from the user's own words moments ago — the keyword
118
+ * hook's captures, plus agent saves marked `source: user`. `memory_add` uses
119
+ * this to refuse a re-save of a statement the store already holds verbatim
120
+ * (see TURN_ECHO_THRESHOLD in src/capture/handoff.ts for why the bar is high).
121
+ * `created_at` is epoch ms in the index, so the window is a numeric compare.
122
+ */
123
+ export function findTurnEcho(
124
+ scopeKey: string,
125
+ body: string,
126
+ windowMs: number,
127
+ ): TurnEchoCandidate[] {
128
+ const cutoff = Date.now() - windowMs;
129
+ let rows: Array<{ id: string; content: string; source: string; created_at: number }>;
130
+ try {
131
+ rows = db()
132
+ .prepare(
133
+ `SELECT id, content, source, created_at FROM memories
134
+ WHERE scope_key = ? AND status = 'active'
135
+ AND source IN ${ECHO_SOURCES} AND created_at >= ?`,
136
+ )
137
+ .all(scopeKey, cutoff) as typeof rows;
138
+ } catch {
139
+ return [];
140
+ }
141
+ const out: TurnEchoCandidate[] = [];
142
+ for (const r of rows) {
143
+ if (typeof r.created_at !== "number" || r.created_at < cutoff) continue;
144
+ const score = similarity(body, r.content);
145
+ if (isTurnEcho(score)) {
146
+ out.push({
147
+ id: r.id,
148
+ score,
149
+ source: r.source,
150
+ snippet: r.content.replace(/\s+/g, " ").trim().slice(0, 120),
151
+ });
152
+ }
153
+ }
154
+ out.sort((x, y) => y.score - x.score);
155
+ return out.slice(0, 3);
156
+ }
157
+
106
158
  /** Locate a memory file by id: index file_path first, then dir-scan fallback. */
107
159
  export function findMemoryFile(id: string): MemoryFile | null {
108
160
  const { memories } = paths();
package/src/tools/ops.ts CHANGED
@@ -23,7 +23,8 @@ import {
23
23
  type Frontmatter,
24
24
  } from "../store/markdown.ts";
25
25
  import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
26
- import { findDuplicates, supersede, setStatus } from "../store/lifecycle.ts";
26
+ import { findDuplicates, findTurnEcho, supersede, setStatus } from "../store/lifecycle.ts";
27
+ import { TURN_ECHO_WINDOW_MS } from "../capture/handoff.ts";
27
28
  import { db } from "../store/db.ts";
28
29
  import { redact } from "../redact.ts";
29
30
  import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.ts";
@@ -65,7 +66,7 @@ export async function withOutboxNote(
65
66
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
66
67
  export const TOOL_DESCRIPTIONS = {
67
68
  memory_add:
68
- "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
69
+ "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Worth saving: decisions and their reasons, preferences, conventions, gotchas, approaches tried and abandoned. Not worth saving: one-off task details or anything re-derivable from the code. Do not wait to be asked. For facts you infer yourself rather than the user stating, propose them first and save only on approval. Keep each memory to one self-contained statement; attach aliases when the parameter is available. If a note tells you the user's own wording was already stored verbatim this turn, that statement is already saved: do not retry it in a rewording — save only what that text does not contain, or use memory_supersede on the given id. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
69
70
  memory_search:
70
71
  "Search persistent memory by keyword (BM25 full-text). Returns matching memories from the current project and/or personal scope. Call before asking the user about past decisions, conventions, or preferences they may have told you before. Search well: break the question into its concepts and try 2–3 phrasings per concept — synonyms, the user's other language, shorter keyword forms — and check the other scope too, before concluding nothing is stored.",
71
72
  memory_list:
@@ -294,6 +295,27 @@ export async function addMemory(
294
295
  output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
295
296
  };
296
297
  }
298
+ // D73: one user statement, one memory. The keyword hook stores the user's
299
+ // sentence verbatim with no agent in the loop; a paraphrase of that same
300
+ // sentence is refused instead of written beside it. The stored id comes back
301
+ // so the caller can supersede it or save only the genuinely new part.
302
+ const echo = findTurnEcho(s.key, redacted, TURN_ECHO_WINDOW_MS);
303
+ if (echo.length > 0) {
304
+ const top = echo[0];
305
+ const others = echo
306
+ .slice(1)
307
+ .map((e) => `id=${e.id} (${e.source}, overlap ${e.score.toFixed(2)})`)
308
+ .join(", ");
309
+ return {
310
+ title: "memory: already captured verbatim",
311
+ output:
312
+ `Not saved — the user's own words were already stored moments ago (source: ${top.source}, overlap ${top.score.toFixed(2)}): ` +
313
+ `id=${top.id} — ${top.snippet}` +
314
+ (others ? `\nAlso matching: ${others}` : "") +
315
+ `\nDo not retry with a rewording: that only adds a second copy of one statement. ` +
316
+ `Save a separate memory only for information that stored text does not contain, or call memory_supersede on that id if this replaces it.`,
317
+ };
318
+ }
297
319
  const fm = buildFrontmatter(s, {
298
320
  type: args.type ?? "fact",
299
321
  tags: args.tags ?? [],