open-memex 0.7.0-alpha.8 → 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.
Files changed (44) hide show
  1. package/AGENTS.md +8 -3
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +17 -3
  4. package/README.zh-CN.md +12 -3
  5. package/dist/.build-stamp +1 -1
  6. package/dist/capture/handoff.js +59 -0
  7. package/dist/cli.js +188 -19
  8. package/dist/distill-agents.js +4 -1
  9. package/dist/index.js +14 -6
  10. package/dist/inventory-html.js +150 -0
  11. package/dist/inventory.js +176 -0
  12. package/dist/mcp.js +9 -0
  13. package/dist/opencode-v2.js +8 -3
  14. package/dist/paths.js +18 -0
  15. package/dist/plugin-core.js +48 -3
  16. package/dist/retrieve/display.js +161 -0
  17. package/dist/retrieve/search.js +33 -3
  18. package/dist/scope.js +17 -0
  19. package/dist/store/lifecycle.js +42 -0
  20. package/dist/tools/ops.js +92 -22
  21. package/docs/TEST-PLAN.md +1 -1
  22. package/docs/V2-DESIGN.md +117 -2
  23. package/docs/memory-visibility-design.md +130 -0
  24. package/package.json +2 -2
  25. package/scripts/build-stamp.mjs +62 -62
  26. package/scripts/smoke-mcp.ts +47 -1
  27. package/scripts/smoke-pure.ts +85 -1
  28. package/scripts/test-full.ts +192 -5
  29. package/skills/open-memex/SKILL.md +29 -0
  30. package/src/capture/handoff.ts +74 -0
  31. package/src/cli.ts +202 -19
  32. package/src/distill-agents.ts +7 -0
  33. package/src/index.ts +13 -5
  34. package/src/inventory-html.ts +191 -0
  35. package/src/inventory.ts +242 -0
  36. package/src/mcp.ts +9 -0
  37. package/src/opencode-v2.ts +7 -2
  38. package/src/paths.ts +19 -0
  39. package/src/plugin-core.ts +62 -3
  40. package/src/retrieve/display.ts +174 -0
  41. package/src/retrieve/search.ts +49 -4
  42. package/src/scope.ts +18 -0
  43. package/src/store/lifecycle.ts +52 -0
  44. package/src/tools/ops.ts +99 -27
@@ -21,6 +21,8 @@ function toHit(r) {
21
21
  updated_at: r.updated_at,
22
22
  status: r.status,
23
23
  review_state: r.review_state ?? "draft",
24
+ source: r.source ?? "",
25
+ created_at: r.created_at ?? r.updated_at ?? 0,
24
26
  };
25
27
  }
26
28
  /**
@@ -61,7 +63,7 @@ function resolveVisible(rows, limit, stats) {
61
63
  return cached;
62
64
  const r = db()
63
65
  .prepare(`SELECT id, scope_key, project_name, type, tags, updated_at, status,
64
- superseded_by, review_state, substr(content, 1, 240) AS snippet
66
+ superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
65
67
  FROM memories WHERE id = ?`)
66
68
  .get(id);
67
69
  if (!r)
@@ -134,7 +136,7 @@ export function search(query, opts = {}) {
134
136
  const typeFilter = opts.type ? ` AND m.type = ?` : "";
135
137
  const sql = `
136
138
  SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
137
- m.status, m.superseded_by, m.review_state,
139
+ m.status, m.superseded_by, m.review_state, m.source, m.created_at,
138
140
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
139
141
  bm25(memories_fts) AS score
140
142
  FROM memories_fts
@@ -164,7 +166,7 @@ export function list(scopeKey, opts = {}) {
164
166
  const typeFilter = opts.type ? ` AND type = ?` : "";
165
167
  const sql = `
166
168
  SELECT id, scope_key, project_name, type, tags, updated_at, status,
167
- superseded_by, review_state, substr(content, 1, 240) AS snippet
169
+ superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
168
170
  FROM memories
169
171
  WHERE scope_key = ?${typeFilter}
170
172
  ORDER BY updated_at DESC
@@ -178,5 +180,33 @@ export function list(scopeKey, opts = {}) {
178
180
  .prepare(sql)
179
181
  .all(...params);
180
182
  const raw = rows.map((r) => ({ ...r, score: 1 }));
183
+ if (opts.include === "all") {
184
+ // D68: the audit view — every row as stored (superseded versions,
185
+ // retracted, archived), no chain collapsing, capped at limit.
186
+ return raw.slice(0, limit).map(toHit);
187
+ }
181
188
  return resolveVisible(raw, limit);
182
189
  }
190
+ /**
191
+ * True totals for the inventory's truncation disclosure (D68). Under
192
+ * `active` the visible set is the newest of each chain, which in a
193
+ * consistent store is exactly the rows whose status is active/deprecated.
194
+ *
195
+ * D70: one deliberate exception — a `superseded` row whose chain target is
196
+ * missing (a dangling pointer, which the read path self-heals with a
197
+ * warning) is returned as a visible hit by resolveVisible but is not
198
+ * counted here, so the total can read one low in that state. Not worth
199
+ * widening the query for a self-healing case; the disclosure stays honest
200
+ * in every state a user can reach deliberately.
201
+ */
202
+ export function countList(scopeKey, opts = {}) {
203
+ const typeFilter = opts.type ? ` AND type = ?` : "";
204
+ const statusFilter = opts.include === "all" ? "" : ` AND status IN ('active', 'deprecated')`;
205
+ const params = [scopeKey];
206
+ if (opts.type)
207
+ params.push(opts.type);
208
+ const row = db()
209
+ .prepare(`SELECT COUNT(*) AS n FROM memories WHERE scope_key = ?${statusFilter}${typeFilter}`)
210
+ .get(...params);
211
+ return row.n;
212
+ }
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);
@@ -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
@@ -7,18 +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
- import { search, list, hitStateLabel } from "../retrieve/search.js";
12
+ import { search, list, countList, hitStateLabel } from "../retrieve/search.js";
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 } 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";
20
21
  import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
21
- import { paths } from "../paths.js";
22
+ import { isInRepoMemoryFile } from "../paths.js";
22
23
  import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
23
24
  /**
24
25
  * D53: push, don't poll. Append the project-outbox pending count to mutating
@@ -37,11 +38,11 @@ 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
- memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
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.",
44
- memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
45
+ memory_forget: "Delete a memory by id. Use when the user asks to forget something. Deletion is permanent: before deleting, restate the memory's content to the user and get a confirmation. With soft=true the memory is hidden instead (retracted: out of lists and search, file kept, one-way).",
45
46
  memory_status: "Show the project memory sync pipeline: drafts waiting in the outbox (appdata), memories in the repo awaiting review or published, and any repo files not yet committed. Call this at session start, when the server reports drafts waiting for review, or when the user says 'sync memory' (or '同步记忆'); then ask the user which drafts to sync. (MCP server and the `open-memex sync-status` CLI; the opencode native plugin does not expose this tool.)",
46
47
  memory_submit: "Move outbox drafts into the repo memory dir for review: copies the drafts in as proposed (or keeps a local approval), commits locally on the current branch, and moves the outbox originals out. Never creates a branch on its own — pass branch= only with the user's explicit approval for the full chain. Prints the push and PR commands — those need the user's explicit approval and are never run automatically. (MCP server and the `open-memex submit` CLI; the opencode native plugin does not expose this tool.)",
47
48
  memory_propose: "Copy personal memories into the project outbox as review drafts. The personal originals stay put. (MCP server and the `open-memex propose` CLI; the opencode native plugin does not expose this tool.)",
@@ -91,6 +92,10 @@ export const memoryListArgs = {
91
92
  .describe("Which scope(s) to list. `project` = tied to this repo. `personal` = global across all your projects. `both` lists each scope's newest. Default: project."),
92
93
  type: z.string().optional(),
93
94
  limit: z.number().int().min(1).max(100).optional(),
95
+ include: z
96
+ .enum(["active", "all"])
97
+ .optional()
98
+ .describe("D68: `active` (default) shows what is currently remembered — newest version of each fact, retracted/archived hidden. `all` is the audit view: every stored row including superseded versions and hidden ones. The default CHANGED in D68: superseded versions no longer appear unless include=all."),
94
99
  };
95
100
  export const memorySupersedeArgs = {
96
101
  id: z.string().min(1).describe("ID of the memory being replaced."),
@@ -104,6 +109,10 @@ export const memorySupersedeArgs = {
104
109
  };
105
110
  export const memoryForgetArgs = {
106
111
  id: z.string().min(1),
112
+ soft: z
113
+ .boolean()
114
+ .optional()
115
+ .describe("D68: hide instead of delete. The memory is retracted — excluded from lists and search — but its file stays. Retraction is one-way (D64): it does not come back; to restore the fact, save it again as a new memory. Offer `soft` when the user is unsure about deleting."),
107
116
  };
108
117
  export const memoryStatusArgs = {};
109
118
  export const memorySubmitArgs = {
@@ -189,6 +198,26 @@ export async function addMemory(getScope, cfg, args) {
189
198
  output: `Identical memory already exists: id=${dups.exact.id}. Not duplicated.`,
190
199
  };
191
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
+ }
192
221
  const fm = buildFrontmatter(s, {
193
222
  type: args.type ?? "fact",
194
223
  tags: args.tags ?? [],
@@ -233,32 +262,52 @@ export async function searchMemories(getScope, args) {
233
262
  return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
234
263
  }
235
264
  export async function listMemories(getScope, args) {
265
+ // D68: the list is the user's inventory of what is remembered. Lines
266
+ // are structured (number + id + created= + source= + state tags) and
267
+ // every section reports its true total — a truncated list says so
268
+ // instead of quietly looking complete (the D66 sync-status lesson).
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;
273
+ const render = (scopeKey) => {
274
+ const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
275
+ const total = countList(scopeKey, { type: args.type, include });
276
+ const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
277
+ const note = truncationNote(hits.length, total);
278
+ if (note)
279
+ lines.push(note);
280
+ return lines;
281
+ };
236
282
  if (args.scope === "both") {
237
283
  // D64: "what do you remember about me" spans both stores; search
238
284
  // already defaults to both, list now matches. Each scope lists its
239
285
  // own newest (the limit applies per scope).
240
286
  const project = getScope();
241
287
  const sections = [];
242
- for (const s of [project, PERSONAL_SCOPE]) {
243
- const hits = list(s.key, { type: args.type, limit: args.limit });
244
- if (hits.length === 0) {
245
- sections.push(`## ${s.kind} (${s.key})\n (none)`);
246
- continue;
247
- }
248
- const lines = hits.map((h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
249
- sections.push(`## ${s.kind} (${s.key})\n${lines.join("\n")}`);
288
+ for (const s of [PERSONAL_SCOPE, project]) {
289
+ const lines = render(s.key);
290
+ const heading = s.key === PERSONAL_SCOPE.key
291
+ ? "## About you (personal — stays on this machine, applies everywhere)"
292
+ : `## This project (${s.projectName || s.key})`;
293
+ sections.push(lines.length === 0 ? `${heading}\n(none yet)` : `${heading}\n${lines.join("\n")}`);
250
294
  }
251
- return { title: "memory: list (both scopes)", output: sections.join("\n") };
295
+ return { title: "memory: list (both scopes)", output: sections.join("\n\n") };
252
296
  }
253
297
  const s = resolveScope(getScope, args.scope === "project" || args.scope === "personal" || args.scope === "user"
254
298
  ? args.scope
255
299
  : undefined);
256
- const hits = list(s.key, { type: args.type, limit: args.limit });
257
- if (hits.length === 0) {
300
+ const lines = render(s.key);
301
+ if (lines.length === 0) {
258
302
  return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
259
303
  }
260
- const lines = hits.map((h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
261
- return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
304
+ const heading = s.key === PERSONAL_SCOPE.key
305
+ ? "## About you (personal — stays on this machine, applies everywhere)"
306
+ : `## This project (${s.projectName || s.key})`;
307
+ return {
308
+ title: `memory: ${countList(s.key, { type: args.type, include })} in ${s.key}`,
309
+ output: `${heading}\n${lines.join("\n")}`,
310
+ };
262
311
  }
263
312
  export async function supersedeMemory(cfg, args) {
264
313
  const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
@@ -296,6 +345,28 @@ export async function forgetMemory(args) {
296
345
  if (!row) {
297
346
  return { title: "memory: not found", output: `No memory with id ${args.id}.` };
298
347
  }
348
+ if (args.soft) {
349
+ // D68: hide instead of delete. The status flip is the whole action —
350
+ // retrieval and the inventory exclude retracted, the file stays put.
351
+ // One-way (D64): no un-hide; restoring the fact means saving it again.
352
+ try {
353
+ const mf = setStatus(args.id, "retracted");
354
+ upsertFromFile(mf);
355
+ return {
356
+ title: "memory: hidden",
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
+ : ""),
364
+ };
365
+ }
366
+ catch (e) {
367
+ return { title: "memory: hide failed", output: e.message };
368
+ }
369
+ }
299
370
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
300
371
  // Only ENOENT counts as "deleted": on any other unlink failure the file
301
372
  // is still the source of truth and would be re-indexed on next sync, so
@@ -318,8 +389,7 @@ export async function forgetMemory(args) {
318
389
  // D64: forgetting an in-repo memory deletes a file inside the user's
319
390
  // git working tree. Say so — the deletion should ride a commit, or the
320
391
  // file silently diverges from what teammates have.
321
- const inRepo = row.file_path.includes(`${path.sep}.ai${path.sep}`) &&
322
- !row.file_path.startsWith(paths().root);
392
+ const inRepo = isInRepoMemoryFile(row.file_path);
323
393
  return {
324
394
  title: "memory: forgotten",
325
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–D67 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;
@@ -1444,6 +1444,121 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1444
1444
  "full list: open-memex sync-status" pointer) after D66's CLI-parity test
1445
1445
  had dropped that coverage.
1446
1446
 
1447
+ - **D68 — memory visibility: the inventory is a protocol surface.** Group 3-C
1448
+ (design: `docs/memory-visibility-design.md` v2). `memory_list` output
1449
+ becomes a numbered, structured inventory (`[type] id=… created=…
1450
+ source=…`, state tags for project scopes; `created=` is when the fact
1451
+ was learned, `updated=` shown only when it moved materially later). The
1452
+ raw enums stay in the data; plain-language renderings live in the
1453
+ guidance surfaces (tool descriptions, MCP handshake, SKILL.md,
1454
+ distill-agents footer) so agents rephrase in the conversation's
1455
+ language. New input `include: "active" | "all"` (default `active`) —
1456
+ a deliberate behavior change: superseded versions and retracted /
1457
+ archived memories leave the default listing (audit view via `all`).
1458
+ Every listing states its true total; truncation is disclosed, never
1459
+ silent (the D66 lesson applied to this surface). `open-memex
1460
+ inventory` renders the same data layer as text (default) or JSON
1461
+ (`open-memex-inventory/1`); a report containing personal memories
1462
+ refuses to be written inside a git working tree without
1463
+ `--allow-personal`. `memory_forget` gains `soft` (CLI `forget
1464
+ --soft`): retraction as a first-class tool action — hidden from lists
1465
+ and search, file kept, still one-way per D64 (restoring the fact means
1466
+ saving it again; no un-hide, no undo, no recycle bin). Deletion stays
1467
+ a confirmed conversation: agents re-list before acting on a number
1468
+ (numbers are only valid for the listing just produced) and read the
1469
+ entry back before deleting. The HTML rendering of the same data layer
1470
+ follows as a separate change; auto-draft stays gated on the unlock
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.
1561
+
1447
1562
  ## Open Questions
1448
1563
 
1449
1564
  _Some early questions are resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull). The entries below are open unless marked decided._
@@ -1463,7 +1578,7 @@ _Some early questions are resolved — see D10 (rename), D11 (type/role split),
1463
1578
 
1464
1579
  - **Novice experience: first success and plain language (open, 2026-10-03):** from an AI-novice review. The install ends with instructions, not an experience: candidate fix is a 60-second magic moment — init offers to save one sample memory and immediately has the user's agent answer a question using it, so the user *sees* recall work before being asked to trust it. Same screen should state the privacy model in plain words (personal never leaves this machine, no cloud, detected secrets are masked — all already true, all currently buried in docs), and the first time a suspected secret is masked, the capture path should say so. Underneath: a plain-language layer over the state machine ("saved on this computer" / "shared with the team") with outbox/submit/promote vocabulary becoming the advanced view — this is the same vocabulary wall the PM/BA question above hits from the other side. **Status (2026-10-03): partially addressed** — `init` now states the privacy model in plain words (D56); the 60-second magic moment and the plain-language layer remain open.
1465
1580
 
1466
- - **Memory visibility and correctability (open, 2026-10-03):** also from the novice review, and arguably the trust foundation for every other feature. A non-CLI user currently cannot answer "what does my assistant remember about me?" without reading Markdown files. Candidate directions: a human-readable inventory (local HTML export or equivalent) listing memories in plain language with provenance shown the way a person asks for it ("saved 3 days ago while you were discussing deploys in project X") and one-step forget/undo from the same view. Provenance fields already exist in frontmatter; this is presentation, not schema. Without it, proactive capture (above) will read to novices as surveillance rather than help — so this question probably gates the auto-draft posture decision in the capture question.
1581
+ - **Memory visibility and correctability (open, 2026-10-03):** also from the novice review, and arguably the trust foundation for every other feature. A non-CLI user currently cannot answer "what does my assistant remember about me?" without reading Markdown files. Candidate directions: a human-readable inventory (local HTML export or equivalent) listing memories in plain language with provenance shown the way a person asks for it ("saved 3 days ago while you were discussing deploys in project X") and one-step forget/undo from the same view. Provenance fields already exist in frontmatter; this is presentation, not schema. Without it, proactive capture (above) will read to novices as surveillance rather than help — so this question probably gates the auto-draft posture decision in the capture question. **Status (2026-10-03): decided (D68)** — the inventory ships as structured list + text/JSON report with conversational rendering taught to agents; "one-step forget/undo from the view" was deliberately not built (deletion stays a confirmed conversation; soft-hide added instead); the HTML rendering follows separately. Auto-draft remains gated on the unlock conditions in `docs/memory-visibility-design.md`.
1467
1582
 
1468
1583
  ---
1469
1584