open-memex 0.7.0-alpha.9 → 0.7.0

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/dist/tools/ops.js CHANGED
@@ -7,19 +7,19 @@
7
7
  * result; each host adapts that to its own tool-result shape.
8
8
  */
9
9
  import fs from "node:fs";
10
- import path from "node:path";
11
10
  import { z } from "zod";
12
11
  import { PERSONAL_SCOPE } from "../scope.js";
13
12
  import { search, list, countList, hitStateLabel } from "../retrieve/search.js";
14
13
  import { formatInventoryLine, truncationNote } from "../retrieve/display.js";
15
14
  import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, normalizeAliases, } from "../store/markdown.js";
16
15
  import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
17
- 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";
18
18
  import { db } from "../store/db.js";
19
19
  import { redact } from "../redact.js";
20
20
  import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.js";
21
21
  import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
22
- import { paths } from "../paths.js";
22
+ import { isInRepoMemoryFile } from "../paths.js";
23
23
  import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
24
24
  /**
25
25
  * D53: push, don't poll. Append the project-outbox pending count to mutating
@@ -38,7 +38,7 @@ export async function withOutboxNote(scopeKey, p, reviewHint) {
38
38
  }
39
39
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
40
40
  export const TOOL_DESCRIPTIONS = {
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. 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.",
42
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.",
43
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).",
44
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.",
@@ -198,6 +198,26 @@ export async function addMemory(getScope, cfg, args) {
198
198
  output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
199
199
  };
200
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
+ }
201
221
  const fm = buildFrontmatter(s, {
202
222
  type: args.type ?? "fact",
203
223
  tags: args.tags ?? [],
@@ -247,10 +267,13 @@ export async function listMemories(getScope, args) {
247
267
  // every section reports its true total — a truncated list says so
248
268
  // instead of quietly looking complete (the D66 sync-status lesson).
249
269
  const include = args.include ?? "active";
270
+ // D70: numbering runs across every section of THIS listing, so "delete
271
+ // #3" names exactly one memory. A per-section counter emitted two "#1"s.
272
+ let seq = 0;
250
273
  const render = (scopeKey) => {
251
274
  const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
252
275
  const total = countList(scopeKey, { type: args.type, include });
253
- const lines = hits.map((h, i) => formatInventoryLine(i, h, PERSONAL_SCOPE.key));
276
+ const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
254
277
  const note = truncationNote(hits.length, total);
255
278
  if (note)
256
279
  lines.push(note);
@@ -331,7 +354,13 @@ export async function forgetMemory(args) {
331
354
  upsertFromFile(mf);
332
355
  return {
333
356
  title: "memory: hidden",
334
- output: `Hidden ${args.id} (retracted). It no longer appears in lists or search, but the file is kept. This is one-way — to bring the fact back, save it again as a new memory.`,
357
+ output: `Hidden ${args.id} (retracted). It no longer appears in lists or search, but the file is kept. This is one-way — to bring the fact back, save it again as a new memory.` +
358
+ // D70: a retraction of an in-repo memory is a local working-tree
359
+ // edit like the delete — without a commit (and a push) the memory
360
+ // stands on every teammate's clone, so "hidden" would be a lie.
361
+ (isInRepoMemoryFile(row.file_path)
362
+ ? ` Note: this memory lives in the repo (.ai/open-memex/), so the retraction is only local until you commit and push it — until then the team still sees it.`
363
+ : ""),
335
364
  };
336
365
  }
337
366
  catch (e) {
@@ -360,8 +389,7 @@ export async function forgetMemory(args) {
360
389
  // D64: forgetting an in-repo memory deletes a file inside the user's
361
390
  // git working tree. Say so — the deletion should ride a commit, or the
362
391
  // file silently diverges from what teammates have.
363
- const inRepo = row.file_path.includes(`${path.sep}.ai${path.sep}`) &&
364
- !row.file_path.startsWith(paths().root);
392
+ const inRepo = isInRepoMemoryFile(row.file_path);
365
393
  return {
366
394
  title: "memory: forgotten",
367
395
  output: inRepo
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–D68 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,95 @@ 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
+
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.
1529
+
1530
+ - **D70 — visibility follow-ups: one number, one guard, one truth.** The
1531
+ 3-C review of D68/D69 found the surfaces honest individually and
1532
+ inconsistent with each other. (1) **Every visibility surface discloses
1533
+ truncation.** `memory_list` did; `open-memex list` — the command a
1534
+ sceptical user runs first — silently showed 20 of 25. It now renders
1535
+ the same structured line as the tool and states how many entries it
1536
+ dropped, naming the command that shows everything. (2) **Numbers are
1537
+ unique within a listing.** Ordinals ran per scope, so a single
1538
+ `scope: both` output contained two `#1`s and "delete #3" was a guess;
1539
+ the counter now runs across every section of one listing (tool, CLI and
1540
+ the text report). (3) **A correction that touches the repo says so.**
1541
+ The hard delete already did (D64); the soft hide did not, so a
1542
+ retraction of an in-repo memory read as "hidden" while it was only a
1543
+ local working-tree edit — the team still had the memory. Both surfaces
1544
+ now name the commit and push. (4) **The report guard fails closed and
1545
+ exists once.** `isInsideWorkTree` ran `git -C` on the output's parent
1546
+ directory, so a not-yet-created directory made the check throw and
1547
+ report "not in a worktree" — a fail-open on the one path where personal
1548
+ memories would ride a commit; it now resolves the nearest existing
1549
+ ancestor, reports a missing directory as itself, and both formats share
1550
+ a single refusal helper instead of two copies. (5) **`open-memex-inventory/2`**
1551
+ drops the absolute `file` path: the JSON renderer exists to be handed to
1552
+ an agent, i.e. pasted into a cloud conversation, and it carried the
1553
+ user's home directory. (6) Smaller honesty fixes: an empty scope is
1554
+ reported as `(0)` instead of vanishing; only the HTML audit view loads
1555
+ the hidden rows themselves (text/json pay for counts); the page's
1556
+ "no network requests" claim is now enforced by a `default-src 'none'`
1557
+ CSP rather than asserted; and `countList`'s comment states its one known
1558
+ divergence (a self-healed dangling chain pointer) instead of claiming
1559
+ equivalence. Deliberately NOT done, pending a decision: HTML i18n and a
1560
+ size cap for very large stores.
1472
1561
 
1473
1562
  ## Open Questions
1474
1563
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.0-alpha.9",
3
+ "version": "0.7.0",
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
  },
@@ -128,7 +128,23 @@ try {
128
128
  check("memory_list keeps raw provenance field (D68)", listText.includes("source=tool"), listText.slice(0, 160));
129
129
  check("memory_list shows age token (D68)", /created=(now|\d+[mhd])/.test(listText), listText.slice(0, 160));
130
130
  const listBoth = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
131
- check("memory_list both scopes (D68)", (listBoth.result?.content?.[0]?.text ?? "").includes("About you"));
131
+ const listBothText = listBoth.result?.content?.[0]?.text ?? "";
132
+ check("memory_list both scopes (D68)", listBothText.includes("About you"));
133
+ // D70: numbers run across the whole listing, so "delete #3" names one memory.
134
+ // Needs a personal entry too, or there is only one section to number.
135
+ const personalAdd = await req("tools/call", {
136
+ name: "memory_add",
137
+ arguments: { content: "mcp e2e probe: a personal note for the both-scopes listing", type: "fact", scope: "personal" },
138
+ });
139
+ check("personal memory added for the both-scopes probe", (personalAdd.result?.content?.[0]?.text ?? "").includes("id="), (personalAdd.result?.content?.[0]?.text ?? "").slice(0, 120));
140
+ const listBoth2 = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
141
+ const listBothText2 = listBoth2.result?.content?.[0]?.text ?? "";
142
+ const bothNums = [...listBothText2.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
143
+ check(
144
+ "memory_list numbers are unique across scopes (D70)",
145
+ bothNums.length > 1 && new Set(bothNums).size === bothNums.length && bothNums[0] === 1,
146
+ JSON.stringify(bothNums),
147
+ );
132
148
 
133
149
  // D68: truncation is disclosed, never silent — one shown, total stated.
134
150
  const add2 = await req("tools/call", {
@@ -5,10 +5,17 @@ import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
6
  import { detectKeywords, scanKeywords } from "../src/capture/keywords.ts";
7
7
  import { DEFAULT_CONFIG } from "../src/config.ts";
8
- import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
8
+ import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } from "../src/scope.ts";
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";
@@ -379,6 +386,17 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
379
386
  // (This repo does have an origin after `git push`.)
380
387
  console.log(" cwd.key =", cwdOnly.key);
381
388
 
389
+ console.log("== scope (pickScopeRoot, D71) ==");
390
+ // OpenCode v1 reports the filesystem root as `worktree` for non-git folders;
391
+ // seeding a scope on it merges every non-git folder on the drive into one
392
+ // shared `project__workspace__…` bucket. A real worktree still wins.
393
+ const fsRoot = path.parse(process.cwd()).root;
394
+ ok("real worktree wins", pickScopeRoot(process.cwd(), "/elsewhere/opened") === process.cwd());
395
+ ok("fs-root worktree yields to directory", pickScopeRoot(fsRoot, process.cwd()) === process.cwd());
396
+ ok("missing worktree uses directory", pickScopeRoot(null, process.cwd()) === process.cwd());
397
+ ok("nothing but fs root: last resort", pickScopeRoot(fsRoot, null) === fsRoot);
398
+ ok("no candidates falls back to cwd", pickScopeRoot(null, null) === process.cwd());
399
+
382
400
  console.log("== cjk ==");
383
401
  ok("detects han", hasCjk("中文记忆"));
384
402
  ok("detects mixed", hasCjk("open中文"));
@@ -604,6 +622,50 @@ const near = similarity(
604
622
  ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
605
623
  ok("similarity empty → 0", similarity("", "anything") === 0);
606
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
+
607
669
  console.log("== init --global: userMcpConfigPath / mergeServerEntry ==");
608
670
  // D45: user-level MCP config locations, per platform (platform param is injectable).
609
671
  ok("vscode win32", userMcpConfigPath("vscode", "win32").endsWith(path.join("Code", "User", "mcp.json")));
@@ -320,7 +320,7 @@ ok("inventory text lists the personal memory", r.out.includes("fulltest inventor
320
320
  ok("inventory text keeps structured provenance", /source=cli/.test(r.out));
321
321
  const invJson = cliRetry(["inventory", "--format", "json"], PROJ);
322
322
  const invData = JSON.parse(invJson.out.slice(invJson.out.indexOf("{")));
323
- ok("inventory json is the shared data layer", invData.format === "open-memex-inventory/1" && invData.total >= 1 && Array.isArray(invData.scopes));
323
+ ok("inventory json is the shared data layer", invData.format === "open-memex-inventory/2" && invData.total >= 1 && Array.isArray(invData.scopes), invData.format);
324
324
  const invFile = path.join(T, "inventory.json");
325
325
  r = cliRetry(["inventory", "--format", "json", "--out", invFile], PROJ);
326
326
  ok("inventory --out writes the file", r.code === 0 && fs.existsSync(invFile), (r.err || r.out).slice(0, 150));
@@ -341,6 +341,22 @@ r = cliRetry(["list", "--include", "all"], PROJ);
341
341
  ok("hidden memory visible with --include all", r.out.includes(softId) && r.out.includes("[retracted]"), r.out.slice(0, 160));
342
342
  r = cliRetry(["forget", softId], PROJ);
343
343
  ok("hard forget still deletes a hidden memory", r.code === 0 && /deleted/.test(r.out));
344
+ // D68 follow-up: the HTML rendering of the same data layer.
345
+ const htmlId = addMem(["fulltest html page entry"], PROJ);
346
+ cliRetry(["forget", htmlId, "--soft"], PROJ);
347
+ const htmlFile = path.join(T, "inventory-page.html");
348
+ r = cliRetry(["inventory", "--format", "html", "--out", htmlFile], PROJ);
349
+ ok("inventory --format html writes the page", r.code === 0 && fs.existsSync(htmlFile), (r.err || r.out).slice(0, 150));
350
+ const page = fs.readFileSync(htmlFile, "utf8");
351
+ ok("html page shows current memories", page.includes("fulltest inventory entry"));
352
+ ok("html page escapes memory content", page.includes("&lt;script&gt;alert(1)&lt;/script&gt;") && !page.includes("<script>alert(1)</script>"));
353
+ ok("html page folds the hidden history", page.includes("Replaced &amp; hidden history") && page.includes(htmlId) && page.includes("retracted"));
354
+ ok("html page enforces 'no network' with a CSP", /Content-Security-Policy/.test(page) && /default-src 'none'/.test(page));
355
+ r = cliRetry(["inventory", "--format", "html"], PROJ);
356
+ ok("html defaults to the data dir", r.code === 0 && fs.existsSync(path.join(T, "data", "inventory.html")), (r.err || r.out).slice(0, 150));
357
+ const inTreePage = path.join(PROJ, "page.html");
358
+ r = cliRetry(["inventory", "--format", "html", "--out", inTreePage], PROJ);
359
+ ok("html refuses a worktree path without --allow-personal", r.code !== 0 && !fs.existsSync(inTreePage));
344
360
 
345
361
  // ---------- 8. pull / push ----------
346
362
  console.log("== pull / push ==");
@@ -612,6 +628,147 @@ console.log("== D64: p1 fixes (propose visibility / supersede published / status
612
628
  ok("uninstall finds and cleans .jsonc global config", r.code === 0 && !/open-memex/.test(afterText), (r.out + r.err).slice(0, 160) + " || " + afterText.slice(0, 120));
613
629
  }
614
630
 
631
+ // ---------- 12. D70: the visibility fixes from the 3-C review ----------
632
+ // Last section on purpose: nothing here may perturb the flows above it.
633
+ // It writes only into a scratch project and into appdata fixtures (no
634
+ // commits in PROJ, no extra CLI spawns beyond a handful) so the suite stays
635
+ // order-independent and cheap — the shared PROJ git state is never dirtied.
636
+ console.log("== D70: honest counts, stable numbers, one guard ==");
637
+ {
638
+ const fixture = (dir: string, id: string, body: string, scopeKey: string) => {
639
+ const now = new Date().toISOString();
640
+ fs.mkdirSync(dir, { recursive: true });
641
+ fs.writeFileSync(
642
+ path.join(dir, `${id}.md`),
643
+ `---
644
+ id: ${id}
645
+ schema_version: 2
646
+ scope_key: ${scopeKey}
647
+ scope: project
648
+ visibility: internal
649
+ project_name: d70
650
+ type: fact
651
+ role: knowledge
652
+ importance: normal
653
+ status: active
654
+ tags: []
655
+ source: cli
656
+ created_at: "${now}"
657
+ updated_at: "${now}"
658
+ supersedes: null
659
+ superseded_by: null
660
+ review_state: draft
661
+ proposed_by: null
662
+ approved_by: null
663
+ derived_from: null
664
+ review_note: null
665
+ review_history: []
666
+ ---
667
+
668
+ ${body}
669
+ `,
670
+ "utf8",
671
+ );
672
+ };
673
+
674
+ // The guard must not fail open: a --out whose parent directory does not
675
+ // exist used to skip the worktree check (git -C on a missing dir throws)
676
+ // and then die with a raw ENOENT. Outside a worktree the missing directory
677
+ // is the only problem left, so it gets its own message.
678
+ const missingDir = path.join(T, "no-such-dir", "report.json");
679
+ r = cliRetry(["inventory", "--out", missingDir], PROJ);
680
+ ok(
681
+ "inventory explains a missing parent directory",
682
+ r.code !== 0 && /does not exist/.test(r.err) && !r.err.includes("ENOENT"),
683
+ (r.err || r.out).slice(0, 160),
684
+ );
685
+ // ...and inside a worktree the personal-content refusal wins over it.
686
+ const missingInTree = path.join(PROJ, "no-such-dir", "report.json");
687
+ r = cliRetry(["inventory", "--out", missingInTree], PROJ);
688
+ ok(
689
+ "worktree refusal wins over a missing directory",
690
+ r.code !== 0 && /git working tree/.test(r.err) && !fs.existsSync(missingInTree),
691
+ (r.err || r.out).slice(0, 160),
692
+ );
693
+
694
+ // `open-memex list` is the surface a sceptical user runs first: it must
695
+ // not look complete when it isn't. Fixtures go straight into the outbox
696
+ // (appdata, never the repo), so this needs no commits and no cleanup in git.
697
+ const d70Key = (/project:\s*(\S+)/.exec(cli(["where"], PROJ).out) || [])[1] ?? "";
698
+ const outboxDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", d70Key);
699
+ const fillerIds: string[] = [];
700
+ for (let i = 0; i < 22; i++) {
701
+ const cid = `01P1D70${String(i).padStart(19, "0")}`;
702
+ fillerIds.push(cid);
703
+ fixture(outboxDir, cid, `fulltest d70 filler ${i} padding text`, d70Key);
704
+ }
705
+ cli(["reindex"], PROJ);
706
+ r = cliRetry(["list", "--limit", "5"], PROJ);
707
+ ok("cli list discloses truncation", /… and \d+ more not shown/.test(r.out), r.out.slice(-160));
708
+ ok(
709
+ "cli list renders the structured inventory line",
710
+ /^\d+\. \[[a-z]+\] id=\S+ created=\S+ source=/m.test(r.out),
711
+ r.out.slice(0, 200),
712
+ );
713
+ // Numbering runs across the whole listing, so a number names one entry.
714
+ r = cliRetry(["list", "--scope", "both", "--limit", "5"], PROJ);
715
+ const nums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
716
+ ok(
717
+ "numbers are unique and ascending across scopes",
718
+ nums.length > 2 && new Set(nums).size === nums.length && nums[0] === 1 && nums.every((n, i) => i === 0 || n === nums[i - 1]! + 1),
719
+ JSON.stringify(nums),
720
+ );
721
+ // The text report numbers the same way.
722
+ r = cliRetry(["inventory", "--scope", "project"], PROJ);
723
+ const textNums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
724
+ ok(
725
+ "inventory text numbers continuously too",
726
+ textNums.length > 2 && new Set(textNums).size === textNums.length && textNums.every((n, i) => i === 0 || n === textNums[i - 1]! + 1),
727
+ JSON.stringify(textNums.slice(0, 8)),
728
+ );
729
+ for (const id of fillerIds) fs.rmSync(path.join(outboxDir, `${id}.md`), { force: true });
730
+ cli(["reindex"], PROJ);
731
+
732
+ // A retraction of an in-repo memory is a local working-tree edit until it
733
+ // is committed - "hidden" must not read as "gone for the team". Runs in its
734
+ // own project so PROJ's git state stays clean for the sections above.
735
+ const d70Proj = mkproj("d70-proj");
736
+ const repoHideId = addMem(["fulltest d70 repo retraction"], d70Proj);
737
+ cliRetry(["submit", repoHideId], d70Proj);
738
+ r = cliRetry(["forget", repoHideId, "--soft"], d70Proj);
739
+ ok(
740
+ "soft hide of a repo memory warns about the commit",
741
+ /commit and push/i.test(r.out) && /team still sees it/i.test(r.out),
742
+ r.out.slice(0, 200),
743
+ );
744
+ r = cliRetry(["forget", repoHideId], d70Proj);
745
+ ok("hard forget of a hidden repo memory still deletes", r.code === 0 && /deleted/i.test(r.out), (r.err || r.out).slice(0, 120));
746
+
747
+ // An empty scope must still appear: "this project has nothing" is an answer.
748
+ const emptyProj = mkproj("empty-proj");
749
+ r = cliRetry(["inventory", "--scope", "project"], emptyProj);
750
+ ok(
751
+ "inventory shows an empty scope as 0",
752
+ /Project:.*\(0\)/.test(r.out) && /nothing remembered/i.test(r.out),
753
+ r.out.slice(0, 200),
754
+ );
755
+
756
+ // JSON: no absolute local paths, and the format id records the change.
757
+ r = cliRetry(["inventory", "--format", "json"], PROJ);
758
+ const invJson2 = JSON.parse(r.out.slice(r.out.indexOf("{")));
759
+ ok(
760
+ "inventory json is /2 and carries no absolute file paths",
761
+ invJson2.format === "open-memex-inventory/2" &&
762
+ !JSON.stringify(invJson2).includes(TESTENV.MY_O_MEMORY_HOME as string) &&
763
+ !/"file":/.test(JSON.stringify(invJson2)),
764
+ `${invJson2.format} ${JSON.stringify(invJson2).slice(0, 120)}`,
765
+ );
766
+ ok(
767
+ "inventory json counts hidden without the entries",
768
+ invJson2.hidden !== undefined && !("hiddenEntries" in invJson2),
769
+ );
770
+ }
771
+
615
772
  console.log("\n================ SUMMARY ================");
616
773
  console.log(`pass: ${pass}, fail: ${fail}`);
617
774
  if (failures.length) { console.log("failed tests:"); for (const f of failures) console.log(" - " + f); }
@@ -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.
@@ -78,7 +84,9 @@ plain words (who the words came from, when it was saved, project or
78
84
  personal) instead of reading raw ids. If the user asks to remove one,
79
85
  read the entry back in full and get a confirmation before deleting —
80
86
  deletion is permanent, there is no undo. If they hesitate, offer hiding
81
- instead (`--soft`): it leaves lists and search but the file survives.
87
+ instead (`--soft`): it disappears from lists and search, but the file
88
+ survives. Either way, if the memory lives in the repo, the change is only
89
+ yours until you commit and push it.
82
90
 
83
91
  ```sh
84
92
  open-memex forget <id> # delete (permanent)
@@ -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
+ }