open-memex 0.7.0-alpha.11 → 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 +6 -3
- package/CHANGELOG.md +17 -1
- package/README.md +5 -1
- package/README.zh-CN.md +1 -1
- package/dist/.build-stamp +1 -1
- package/dist/capture/handoff.js +59 -0
- package/dist/index.js +14 -6
- package/dist/opencode-v2.js +8 -3
- package/dist/plugin-core.js +48 -3
- package/dist/scope.js +17 -0
- package/dist/store/lifecycle.js +42 -0
- package/dist/tools/ops.js +23 -2
- package/docs/TEST-PLAN.md +1 -1
- package/docs/V2-DESIGN.md +58 -13
- package/package.json +2 -2
- package/scripts/smoke-pure.ts +63 -1
- package/scripts/test-full.ts +141 -68
- package/skills/open-memex/SKILL.md +6 -0
- package/src/capture/handoff.ts +74 -0
- package/src/index.ts +13 -5
- package/src/opencode-v2.ts +7 -2
- package/src/plugin-core.ts +62 -3
- package/src/scope.ts +18 -0
- package/src/store/lifecycle.ts +52 -0
- package/src/tools/ops.ts +24 -2
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
|
|
|
@@ -126,6 +126,7 @@ Layout: `memories/<scope_key>/<id>.md` (YAML frontmatter + body) + `index.db` (S
|
|
|
126
126
|
|
|
127
127
|
- `personal` scope key is literal `"personal"` (v1 called this `user`; renamed in v2, design §19).
|
|
128
128
|
- `project` scope key: `project__<sanitized-name>__<12-hex-sha256>`, seeded from normalized git origin URL, else lowercased cwd. See `src/scope.ts`. Same repo across machines → same key (intentional; enables future git-commit of memories).
|
|
129
|
+
- The root a host adapter seeds on matters: OpenCode v1 reports the **filesystem root** as `worktree` for non-git folders, so the v1 adapter goes through `pickScopeRoot` (src/scope.ts) — `worktree` only when it is a real directory below the filesystem root, else the opened `directory` (D71). Seeding on a filesystem root merges every non-git folder on a drive into one `project__workspace__…` bucket.
|
|
129
130
|
- `org`, `team`, `public` are reserved — the schema rejects writes. See `docs/SCOPES.md`.
|
|
130
131
|
- The scope key **changes** when a repo gains/loses a git origin. `src/index.ts` logs a one-shot warning on load if it finds files under the legacy cwd-only key (`resolveCwdScope`). Use `cli scopes` to enumerate all scope dirs and `cli migrate --from <old>` to reconcile — see `src/store/migrate.ts`. No auto-migration; two unrelated repos at the same cwd would silently merge.
|
|
131
132
|
- Read-only callers must use `memoriesDirPath` (in `src/paths.ts`), never `memoriesDirFor`. The latter `mkdir -p`s the directory as a side effect and will pollute storage with empty scope dirs.
|
|
@@ -161,15 +162,17 @@ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
|
|
|
161
162
|
|
|
162
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).
|
|
163
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
|
+
|
|
164
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.
|
|
165
168
|
|
|
166
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.
|
|
167
170
|
|
|
168
171
|
## Design constraints — read the frozen design first
|
|
169
172
|
|
|
170
|
-
`docs/V2-DESIGN.md` is the frozen protocol v0.2 (decisions settled through
|
|
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:
|
|
171
174
|
AGENTS.md answers "how should AI work here"; the design doc answers "why is it
|
|
172
|
-
built this way" (principles, iron rules, the append-only decision log, currently D1–
|
|
175
|
+
built this way" (principles, iron rules, the append-only decision log, currently D1–D73). Before changing
|
|
173
176
|
architecture, scope semantics, lifecycle, or the protocol surface (frontmatter
|
|
174
177
|
schema, MCP tools, CLI contract), read the relevant design section — the decision
|
|
175
178
|
log records what was already considered and rejected.
|
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,22 @@ 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
|
+
|
|
24
|
+
## Non-git folders shared one scope bucket (D71)
|
|
25
|
+
|
|
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>`.
|
|
27
|
+
|
|
12
28
|
## Visibility follow-ups: one number, one guard, one truth (D70)
|
|
13
29
|
|
|
14
30
|
Review of the D68/D69 inventory surfaces — each was honest on its own, and they disagreed with each other:
|
|
@@ -29,7 +45,7 @@ Review of the D68/D69 inventory surfaces — each was honest on its own, and the
|
|
|
29
45
|
Group 3-C — the surface a user reads to learn what the store actually remembers:
|
|
30
46
|
|
|
31
47
|
- **`memory_list` is now a numbered inventory.** Lines are structured (`[type] id=… created=… source=…`), keep the raw provenance, and every listing states its true total — a truncated list says how many entries it is not showing instead of quietly looking complete. New input `include: "active" | "all"` (default `active`): **behavior change** — superseded versions, retracted and archived memories no longer appear unless you pass `include=all` (the audit view). The CLI gains the matching `list --include` flag.
|
|
32
|
-
- **`open-memex inventory`** renders the same data as readable text (default) or JSON (`--format json`, format `open-memex-inventory/
|
|
48
|
+
- **`open-memex inventory`** renders the same data as readable text (default) or JSON (`--format json`, format `open-memex-inventory/1`) — personal plus the current project, outbox drafts in their own section, hidden history counted rather than silently dropped. A report containing personal memories refuses to be written inside a git working tree without `--allow-personal`.
|
|
33
49
|
- **Hide instead of delete:** `memory_forget` gains `soft=true` (CLI: `forget --soft`) — the memory is retracted: out of lists and search, file kept. Still one-way (D64): bringing the fact back means saving it again.
|
|
34
50
|
- **Guidance kept in one voice:** the tool descriptions, the MCP session-start instructions, SKILL.md and the `distill-agents` AGENTS.md snippet all teach the same flow — answer "what do you remember?" conversationally from the inventory, re-list before acting on a number, read an entry back before deleting it.
|
|
35
51
|
|
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
|
-
|
|
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,5 @@
|
|
|
1
|
-
import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
|
|
1
|
+
import { PLUGIN_ID, bootstrapPlugin, captureFromText, captureHandoff, injectOnce, } from "./plugin-core.js";
|
|
2
|
+
import { pickScopeRoot } from "./scope.js";
|
|
2
3
|
import { makeTools } from "./tools/memory.js";
|
|
3
4
|
import { v2Plugin } from "./opencode-v2.js";
|
|
4
5
|
/**
|
|
@@ -12,23 +13,30 @@ import { v2Plugin } from "./opencode-v2.js";
|
|
|
12
13
|
* type-only (D58), and v2's decode is structural.
|
|
13
14
|
*/
|
|
14
15
|
const server = async ({ worktree, directory }) => {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
// v1's `worktree` is the filesystem root for non-git folders — never seed a
|
|
17
|
+
// scope on it (D71). `pickScopeRoot` falls back to the opened `directory`.
|
|
18
|
+
const state = bootstrapPlugin(pickScopeRoot(worktree, directory));
|
|
17
19
|
const tools = makeTools(() => state.scope, state.cfg);
|
|
18
20
|
return {
|
|
19
21
|
tool: tools,
|
|
20
|
-
async "chat.message"(
|
|
22
|
+
async "chat.message"(input, output) {
|
|
21
23
|
const parts = (output.parts ?? []);
|
|
22
24
|
const text = parts
|
|
23
25
|
.map((p) => (p?.type === "text" ? p.text ?? "" : ""))
|
|
24
26
|
.filter(Boolean)
|
|
25
27
|
.join("\n");
|
|
26
|
-
captureFromText(text, state);
|
|
28
|
+
captureFromText(text, state, input.sessionID ?? "");
|
|
27
29
|
},
|
|
28
30
|
async "experimental.chat.system.transform"(input, output) {
|
|
29
|
-
|
|
31
|
+
const sessionID = input.sessionID ?? "";
|
|
32
|
+
injectOnce(sessionID, state, (block) => {
|
|
30
33
|
output.system.push(block);
|
|
31
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);
|
|
32
40
|
},
|
|
33
41
|
};
|
|
34
42
|
};
|
package/dist/opencode-v2.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/plugin-core.js
CHANGED
|
@@ -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
|
package/dist/scope.js
CHANGED
|
@@ -57,6 +57,23 @@ export function resolveCwdScope(worktree) {
|
|
|
57
57
|
projectName = "workspace";
|
|
58
58
|
return scopeFromSeed(seed, projectName);
|
|
59
59
|
}
|
|
60
|
+
/** Pick the directory that seeds scope resolution from a host's candidate
|
|
61
|
+
* roots. OpenCode v1 hands the plugin both `worktree` (the repo root — but
|
|
62
|
+
* the filesystem root, e.g. `C:\` or `/`, when the opened folder is not a
|
|
63
|
+
* git repo) and `directory` (the folder actually opened). Seeding on a
|
|
64
|
+
* filesystem root collapses every non-git folder on a drive into one shared
|
|
65
|
+
* `project__workspace__…` scope (D71), so prefer `worktree` only when it is
|
|
66
|
+
* a real directory below the filesystem root. */
|
|
67
|
+
export function pickScopeRoot(worktree, directory) {
|
|
68
|
+
const isFsRoot = (p) => path.parse(p).root === p;
|
|
69
|
+
if (worktree && !isFsRoot(worktree))
|
|
70
|
+
return worktree;
|
|
71
|
+
if (directory)
|
|
72
|
+
return directory;
|
|
73
|
+
if (worktree)
|
|
74
|
+
return worktree;
|
|
75
|
+
return process.cwd();
|
|
76
|
+
}
|
|
60
77
|
export function resolveProjectScope(worktree) {
|
|
61
78
|
const target = worktree && worktree.length > 0 ? worktree : ".";
|
|
62
79
|
const remote = tryGitRemote(target);
|
package/dist/store/lifecycle.js
CHANGED
|
@@ -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
|
-
- [
|
|
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–
|
|
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,18 +1469,63 @@ 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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
single
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
+
|
|
1513
|
+
- **D71 — the scope seed is the folder opened, never a filesystem root.**
|
|
1514
|
+
OpenCode v1 hands the plugin two roots: `worktree` (the repo root — but
|
|
1515
|
+
the filesystem root itself, `C:\` or `/`, when the opened folder is not a
|
|
1516
|
+
git repo) and `directory` (the folder actually opened). The v1 adapter
|
|
1517
|
+
preferred `worktree`, so every non-git folder on a drive seeded its scope
|
|
1518
|
+
on the drive root and collapsed into one shared
|
|
1519
|
+
`project__workspace__<hash of the root>` bucket — a `c:\temp` project
|
|
1520
|
+
landing in `project__workspace__3dc8472eaebe` (seed `c:\`) is what
|
|
1521
|
+
surfaced it. `pickScopeRoot` (src/scope.ts) now takes `worktree` only when
|
|
1522
|
+
it is a real directory below the filesystem root, else `directory`. The
|
|
1523
|
+
repo-root behavior is unchanged: a real worktree still wins, so opening a
|
|
1524
|
+
repo subfolder keeps the repo's scope. Memories already written under a
|
|
1525
|
+
root-seeded key stay where they are (no auto-migration, per the existing
|
|
1526
|
+
rule); `open-memex scopes` shows the stray bucket and
|
|
1527
|
+
`open-memex migrate --from <old-key>` moves them. The v2 adapter already
|
|
1528
|
+
seeded on the opened directory and is unaffected.
|
|
1484
1529
|
|
|
1485
1530
|
- **D70 — visibility follow-ups: one number, one guard, one truth.** The
|
|
1486
1531
|
3-C review of D68/D69 found the surfaces honest individually and
|
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.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": "^
|
|
26
|
+
"better-sqlite3": "^13.0.3",
|
|
27
27
|
"js-yaml": "^4.1.0",
|
|
28
28
|
"zod": "^4.1.8"
|
|
29
29
|
},
|