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

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 (66) hide show
  1. package/AGENTS.md +23 -5
  2. package/CHANGELOG.md +88 -0
  3. package/README.md +46 -15
  4. package/README.zh-CN.md +27 -14
  5. package/dist/.build-stamp +1 -0
  6. package/dist/capture/keywords.js +41 -8
  7. package/dist/cli.js +259 -40
  8. package/dist/config.js +102 -18
  9. package/dist/distill-agents.js +9 -1
  10. package/dist/doctor.js +37 -47
  11. package/dist/export.js +12 -2
  12. package/dist/index.js +4 -2
  13. package/dist/init.js +23 -6
  14. package/dist/inventory-html.js +150 -0
  15. package/dist/inventory.js +176 -0
  16. package/dist/mcp.js +21 -5
  17. package/dist/paths.js +34 -2
  18. package/dist/retrieve/display.js +161 -0
  19. package/dist/retrieve/inject.js +1 -1
  20. package/dist/retrieve/search.js +44 -5
  21. package/dist/review.js +9 -3
  22. package/dist/scope.js +17 -0
  23. package/dist/store/db.js +11 -9
  24. package/dist/store/lifecycle.js +20 -4
  25. package/dist/store/markdown.js +53 -7
  26. package/dist/store/migrate.js +14 -3
  27. package/dist/store/sync.js +5 -3
  28. package/dist/store/v2migrate.js +2 -2
  29. package/dist/submit.js +13 -5
  30. package/dist/tools/memory.js +28 -0
  31. package/dist/tools/ops.js +127 -16
  32. package/docs/CURATOR.md +7 -3
  33. package/docs/V2-DESIGN.md +299 -32
  34. package/docs/memory-visibility-design.md +130 -0
  35. package/package.json +2 -2
  36. package/scripts/build-stamp.mjs +63 -0
  37. package/scripts/smoke-mcp.ts +47 -1
  38. package/scripts/smoke-pure.ts +227 -3
  39. package/scripts/test-full.ts +435 -5
  40. package/skills/open-memex/SKILL.md +53 -1
  41. package/src/capture/keywords.ts +56 -6
  42. package/src/cli.ts +282 -39
  43. package/src/config.ts +104 -17
  44. package/src/distill-agents.ts +19 -0
  45. package/src/doctor.ts +41 -42
  46. package/src/export.ts +14 -2
  47. package/src/index.ts +4 -2
  48. package/src/init.ts +25 -6
  49. package/src/inventory-html.ts +191 -0
  50. package/src/inventory.ts +242 -0
  51. package/src/mcp.ts +21 -5
  52. package/src/paths.ts +35 -1
  53. package/src/retrieve/display.ts +174 -0
  54. package/src/retrieve/inject.ts +1 -1
  55. package/src/retrieve/search.ts +63 -6
  56. package/src/review.ts +9 -2
  57. package/src/scope.ts +18 -0
  58. package/src/store/db.ts +11 -9
  59. package/src/store/lifecycle.ts +24 -3
  60. package/src/store/markdown.ts +60 -7
  61. package/src/store/migrate.ts +15 -2
  62. package/src/store/sync.ts +6 -3
  63. package/src/store/v2migrate.ts +2 -1
  64. package/src/submit.ts +12 -4
  65. package/src/tools/memory.ts +28 -0
  66. package/src/tools/ops.ts +147 -17
package/dist/submit.js CHANGED
@@ -25,7 +25,7 @@ import { loadConfig } from "./config.js";
25
25
  import { inRepoMemoriesDir, memoriesDirPath, projectRoot } from "./paths.js";
26
26
  import { resolveProjectScope } from "./scope.js";
27
27
  import { contentHash } from "./store/lifecycle.js";
28
- import { msToRfc3339, parse as parseMemory, readMemoryFile, serialize, } from "./store/markdown.js";
28
+ import { msToRfc3339, parse as parseMemory, readMemoryFile, serialize, atomicWriteTextSync, } from "./store/markdown.js";
29
29
  import { upsertFromFile, recordSync, readSyncState } from "./store/sync.js";
30
30
  import { db } from "./store/db.js";
31
31
  import { currentAuthor } from "./review.js";
@@ -127,7 +127,7 @@ export function outboxDraftCount(scopeKey) {
127
127
  .get(scopeKey, outboxDir + path.sep);
128
128
  return row?.n ?? 0;
129
129
  }
130
- export function formatSyncStatus(st) {
130
+ export function formatSyncStatus(st, opts = {}) {
131
131
  const lines = [];
132
132
  lines.push(`project: ${st.projectName} (${st.scopeKey})`);
133
133
  // D31: when the index was last synced and what triggered it.
@@ -138,19 +138,27 @@ export function formatSyncStatus(st) {
138
138
  else {
139
139
  lines.push(`last sync: never`);
140
140
  }
141
+ // Agent-facing renders cap each section so a big store can't flood a
142
+ // tool result; the cap line points at the CLI, which shows everything.
143
+ const CAP = opts.cap ?? 20;
144
+ const more = (n) => ` … and ${n} more (full list: open-memex sync-status)`;
141
145
  lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
142
- for (const e of st.outbox) {
146
+ for (const e of st.outbox.slice(0, CAP)) {
143
147
  lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
144
148
  }
149
+ if (st.outbox.length > CAP)
150
+ lines.push(more(st.outbox.length - CAP));
145
151
  const byState = new Map();
146
152
  for (const e of st.inRepo)
147
153
  byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
148
154
  const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
149
155
  lines.push(`repo .ai/open-memex: ${st.inRepo.length} (${breakdown})`);
150
- for (const e of st.inRepo) {
156
+ for (const e of st.inRepo.slice(0, CAP)) {
151
157
  const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
152
158
  lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
153
159
  }
160
+ if (st.inRepo.length > CAP)
161
+ lines.push(more(st.inRepo.length - CAP));
154
162
  if (st.uncommitted.length > 0) {
155
163
  lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
156
164
  }
@@ -279,7 +287,7 @@ export function submitMemories(ids, opts = {}) {
279
287
  ],
280
288
  };
281
289
  const text = serialize(fm, parsed.body);
282
- fs.writeFileSync(destPath, text, "utf8");
290
+ atomicWriteTextSync(destPath, text);
283
291
  written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(parsed.body) });
284
292
  submitted.push({ id: v.id, filePath: destPath, reviewState: destState });
285
293
  }
@@ -1,3 +1,5 @@
1
+ import { PERSONAL_SCOPE } from "../scope.js";
2
+ import { syncScope } from "../store/sync.js";
1
3
  import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, withOutboxNote, } from "./ops.js";
2
4
  // Local stand-in for the SDK's tool() helper — same signature, and the SDK
3
5
  // function is a runtime identity (`return input`). Do NOT import it from
@@ -9,10 +11,32 @@ function tool(input) {
9
11
  return input;
10
12
  }
11
13
  export function makeTools(getScope, cfg) {
14
+ // D64: the plugin host syncs only at bootstrap, so without this the
15
+ // index goes stale for the whole session — memories added through the
16
+ // CLI or another client stay invisible to the agent, and same-content
17
+ // duplicates accumulate. Mirror the MCP server: reconcile from disk
18
+ // before every tool call. syncScope is mtime-based, so the steady-state
19
+ // cost is one directory scan.
20
+ const syncFirst = () => {
21
+ try {
22
+ syncScope(getScope().key, "request");
23
+ syncScope(PERSONAL_SCOPE.key, "request");
24
+ }
25
+ catch (err) {
26
+ // Deliberate asymmetry with the MCP adapter (which lets sync errors
27
+ // propagate as tool errors): a failed sync must not take the plugin
28
+ // tools down — the index may be stale for this call but the tool
29
+ // still answers. Loud at debug, silent otherwise.
30
+ if (cfg.logLevel === "debug") {
31
+ console.error("[open-memex] pre-tool sync failed:", err);
32
+ }
33
+ }
34
+ };
12
35
  const memory_add = tool({
13
36
  description: TOOL_DESCRIPTIONS.memory_add,
14
37
  args: memoryAddArgs,
15
38
  async execute(args) {
39
+ syncFirst();
16
40
  return withOutboxNote(getScope().key, addMemory(getScope, cfg, args));
17
41
  },
18
42
  });
@@ -20,6 +44,7 @@ export function makeTools(getScope, cfg) {
20
44
  description: TOOL_DESCRIPTIONS.memory_search,
21
45
  args: memorySearchArgs,
22
46
  async execute(args) {
47
+ syncFirst();
23
48
  return searchMemories(getScope, args);
24
49
  },
25
50
  });
@@ -27,6 +52,7 @@ export function makeTools(getScope, cfg) {
27
52
  description: TOOL_DESCRIPTIONS.memory_list,
28
53
  args: memoryListArgs,
29
54
  async execute(args) {
55
+ syncFirst();
30
56
  return listMemories(getScope, args);
31
57
  },
32
58
  });
@@ -34,6 +60,7 @@ export function makeTools(getScope, cfg) {
34
60
  description: TOOL_DESCRIPTIONS.memory_supersede,
35
61
  args: memorySupersedeArgs,
36
62
  async execute(args) {
63
+ syncFirst();
37
64
  return withOutboxNote(getScope().key, supersedeMemory(cfg, args));
38
65
  },
39
66
  });
@@ -41,6 +68,7 @@ export function makeTools(getScope, cfg) {
41
68
  description: TOOL_DESCRIPTIONS.memory_forget,
42
69
  args: memoryForgetArgs,
43
70
  async execute(args) {
71
+ syncFirst();
44
72
  return withOutboxNote(getScope().key, forgetMemory(args));
45
73
  },
46
74
  });
package/dist/tools/ops.js CHANGED
@@ -9,14 +9,16 @@
9
9
  import fs from "node:fs";
10
10
  import { z } from "zod";
11
11
  import { PERSONAL_SCOPE } from "../scope.js";
12
- import { search, list, hitStateLabel } from "../retrieve/search.js";
13
- import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
12
+ import { search, list, countList, hitStateLabel } from "../retrieve/search.js";
13
+ import { formatInventoryLine, truncationNote } from "../retrieve/display.js";
14
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, normalizeAliases, } from "../store/markdown.js";
14
15
  import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
15
- import { findDuplicates, supersede } from "../store/lifecycle.js";
16
+ import { findDuplicates, supersede, setStatus } from "../store/lifecycle.js";
16
17
  import { db } from "../store/db.js";
17
18
  import { redact } from "../redact.js";
18
19
  import { getSyncStatus, formatSyncStatus, submitMemories, outboxDraftCount } from "../submit.js";
19
20
  import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
21
+ import { isInRepoMemoryFile } from "../paths.js";
20
22
  import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
21
23
  /**
22
24
  * D53: push, don't poll. Append the project-outbox pending count to mutating
@@ -35,11 +37,11 @@ export async function withOutboxNote(scopeKey, p, reviewHint) {
35
37
  }
36
38
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
37
39
  export const TOOL_DESCRIPTIONS = {
38
- 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. Do not wait to be asked. Keep each memory to one self-contained statement. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
39
- 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 — try a few keyword variants, including the user's own language, when the first search comes up empty.",
40
- memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
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_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, 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).",
41
43
  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.",
42
- memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
44
+ 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).",
43
45
  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.)",
44
46
  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.)",
45
47
  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.)",
@@ -52,6 +54,10 @@ export const scopeArg = z
52
54
  .enum(["project", "personal", "user"])
53
55
  .optional()
54
56
  .describe("Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.");
57
+ export const aliasesArg = z
58
+ .array(z.string())
59
+ .optional()
60
+ .describe("Optional: alternate phrasings of this fact — synonyms, another way a question might be worded, equivalents in the user's other language (e.g. 节假日 for 'public holidays'). They are indexed with the memory so differently-worded questions still match. Only used when the install has capture aliases enabled (init default). Send at most 4; extras and blanks are dropped silently, never an error.");
55
61
  export const memoryAddArgs = {
56
62
  content: z.string().min(1).describe("The fact to remember. One idea per memory."),
57
63
  type: z
@@ -60,6 +66,7 @@ export const memoryAddArgs = {
60
66
  .describe("Category of memory. Default: fact."),
61
67
  scope: scopeArg,
62
68
  tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
69
+ aliases: aliasesArg,
63
70
  source: z
64
71
  .string()
65
72
  .optional()
@@ -78,9 +85,16 @@ export const memorySearchArgs = {
78
85
  limit: z.number().int().min(1).max(50).optional(),
79
86
  };
80
87
  export const memoryListArgs = {
81
- scope: scopeArg,
88
+ scope: z
89
+ .enum(["project", "personal", "user", "both"])
90
+ .optional()
91
+ .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."),
82
92
  type: z.string().optional(),
83
93
  limit: z.number().int().min(1).max(100).optional(),
94
+ include: z
95
+ .enum(["active", "all"])
96
+ .optional()
97
+ .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."),
84
98
  };
85
99
  export const memorySupersedeArgs = {
86
100
  id: z.string().min(1).describe("ID of the memory being replaced."),
@@ -90,9 +104,14 @@ export const memorySupersedeArgs = {
90
104
  .optional()
91
105
  .describe("Category of the new memory. Defaults to the old memory's type."),
92
106
  tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
107
+ aliases: aliasesArg,
93
108
  };
94
109
  export const memoryForgetArgs = {
95
110
  id: z.string().min(1),
111
+ soft: z
112
+ .boolean()
113
+ .optional()
114
+ .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."),
96
115
  };
97
116
  export const memoryStatusArgs = {};
98
117
  export const memorySubmitArgs = {
@@ -149,6 +168,7 @@ function buildFrontmatter(s, body) {
149
168
  importance: "normal",
150
169
  status: "active",
151
170
  tags: body.tags,
171
+ ...(body.aliases && body.aliases.length > 0 ? { aliases: body.aliases } : {}),
152
172
  source: body.source,
153
173
  created_at: rfc,
154
174
  updated_at: rfc,
@@ -180,6 +200,13 @@ export async function addMemory(getScope, cfg, args) {
180
200
  const fm = buildFrontmatter(s, {
181
201
  type: args.type ?? "fact",
182
202
  tags: args.tags ?? [],
203
+ // D61: aliases are stored only when the install enables them (init
204
+ // asks once, default on); normalize caps at 4 and drops junk silently.
205
+ // Aliases pass through the same redaction as content (write-path
206
+ // invariant) — an alias carrying a token gets masked, not stored raw.
207
+ aliases: cfg.captureAliases
208
+ ? normalizeAliases((args.aliases ?? []).map((a) => redact(a, cfg.redactPatterns).content))
209
+ : [],
183
210
  source: args.source ?? "tool",
184
211
  });
185
212
  const { filePath } = writeMemoryFile(fm, redacted);
@@ -214,13 +241,52 @@ export async function searchMemories(getScope, args) {
214
241
  return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
215
242
  }
216
243
  export async function listMemories(getScope, args) {
217
- const s = resolveScope(getScope, args.scope);
218
- const hits = list(s.key, { type: args.type, limit: args.limit });
219
- if (hits.length === 0) {
244
+ // D68: the list is the user's inventory of what is remembered. Lines
245
+ // are structured (number + id + created= + source= + state tags) and
246
+ // every section reports its true total — a truncated list says so
247
+ // instead of quietly looking complete (the D66 sync-status lesson).
248
+ const include = args.include ?? "active";
249
+ // D70: numbering runs across every section of THIS listing, so "delete
250
+ // #3" names exactly one memory. A per-section counter emitted two "#1"s.
251
+ let seq = 0;
252
+ const render = (scopeKey) => {
253
+ const hits = list(scopeKey, { type: args.type, limit: args.limit, include });
254
+ const total = countList(scopeKey, { type: args.type, include });
255
+ const lines = hits.map((h) => formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
256
+ const note = truncationNote(hits.length, total);
257
+ if (note)
258
+ lines.push(note);
259
+ return lines;
260
+ };
261
+ if (args.scope === "both") {
262
+ // D64: "what do you remember about me" spans both stores; search
263
+ // already defaults to both, list now matches. Each scope lists its
264
+ // own newest (the limit applies per scope).
265
+ const project = getScope();
266
+ const sections = [];
267
+ for (const s of [PERSONAL_SCOPE, project]) {
268
+ const lines = render(s.key);
269
+ const heading = s.key === PERSONAL_SCOPE.key
270
+ ? "## About you (personal — stays on this machine, applies everywhere)"
271
+ : `## This project (${s.projectName || s.key})`;
272
+ sections.push(lines.length === 0 ? `${heading}\n(none yet)` : `${heading}\n${lines.join("\n")}`);
273
+ }
274
+ return { title: "memory: list (both scopes)", output: sections.join("\n\n") };
275
+ }
276
+ const s = resolveScope(getScope, args.scope === "project" || args.scope === "personal" || args.scope === "user"
277
+ ? args.scope
278
+ : undefined);
279
+ const lines = render(s.key);
280
+ if (lines.length === 0) {
220
281
  return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
221
282
  }
222
- const lines = hits.map((h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
223
- return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
283
+ const heading = s.key === PERSONAL_SCOPE.key
284
+ ? "## About you (personal — stays on this machine, applies everywhere)"
285
+ : `## This project (${s.projectName || s.key})`;
286
+ return {
287
+ title: `memory: ${countList(s.key, { type: args.type, include })} in ${s.key}`,
288
+ output: `${heading}\n${lines.join("\n")}`,
289
+ };
224
290
  }
225
291
  export async function supersedeMemory(cfg, args) {
226
292
  const { content: redacted, hadSecret, matchedPattern } = redact(args.content, cfg.redactPatterns);
@@ -230,6 +296,11 @@ export async function supersedeMemory(cfg, args) {
230
296
  body: redacted,
231
297
  type: args.type,
232
298
  tags: args.tags,
299
+ // D61: explicit aliases replace the carried-over ones, only when
300
+ // the install enables capture aliases. Same redaction as content.
301
+ aliases: cfg.captureAliases && args.aliases !== undefined
302
+ ? normalizeAliases(args.aliases.map((a) => redact(a, cfg.redactPatterns).content))
303
+ : undefined,
233
304
  source: "tool",
234
305
  });
235
306
  upsertFromFile(oldMf);
@@ -253,17 +324,57 @@ export async function forgetMemory(args) {
253
324
  if (!row) {
254
325
  return { title: "memory: not found", output: `No memory with id ${args.id}.` };
255
326
  }
327
+ if (args.soft) {
328
+ // D68: hide instead of delete. The status flip is the whole action —
329
+ // retrieval and the inventory exclude retracted, the file stays put.
330
+ // One-way (D64): no un-hide; restoring the fact means saving it again.
331
+ try {
332
+ const mf = setStatus(args.id, "retracted");
333
+ upsertFromFile(mf);
334
+ return {
335
+ title: "memory: hidden",
336
+ 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.` +
337
+ // D70: a retraction of an in-repo memory is a local working-tree
338
+ // edit like the delete — without a commit (and a push) the memory
339
+ // stands on every teammate's clone, so "hidden" would be a lie.
340
+ (isInRepoMemoryFile(row.file_path)
341
+ ? ` 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.`
342
+ : ""),
343
+ };
344
+ }
345
+ catch (e) {
346
+ return { title: "memory: hide failed", output: e.message };
347
+ }
348
+ }
256
349
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
350
+ // Only ENOENT counts as "deleted": on any other unlink failure the file
351
+ // is still the source of truth and would be re-indexed on next sync, so
352
+ // report failure instead of lying (P0 review, 2026-10-03).
257
353
  if (row.file_path) {
258
354
  try {
259
355
  fs.unlinkSync(row.file_path);
260
356
  }
261
- catch {
262
- /* already gone */
357
+ catch (e) {
358
+ const code = e.code;
359
+ if (code !== "ENOENT") {
360
+ return {
361
+ title: "memory: forget failed",
362
+ output: `Could not delete ${row.file_path} (${code ?? "error"}) — the memory was NOT forgotten and is still in the index. Close anything holding the file and retry.`,
363
+ };
364
+ }
263
365
  }
264
366
  }
265
367
  deleteFromIndex(args.id);
266
- return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
368
+ // D64: forgetting an in-repo memory deletes a file inside the user's
369
+ // git working tree. Say so — the deletion should ride a commit, or the
370
+ // file silently diverges from what teammates have.
371
+ const inRepo = isInRepoMemoryFile(row.file_path);
372
+ return {
373
+ title: "memory: forgotten",
374
+ output: inRepo
375
+ ? `Deleted ${args.id}. Note: the file lived in the repo (.ai/open-memex/) — commit the deletion so the team sees it too.`
376
+ : `Deleted ${args.id}.`,
377
+ };
267
378
  }
268
379
  // ---------------------------------------------------------------------------
269
380
  // 2B/D26: review workflow ops (also exposed over MCP so agents can drive the
package/docs/CURATOR.md CHANGED
@@ -7,9 +7,13 @@ decide who is *allowed* to.
7
7
 
8
8
  ## What the curator does
9
9
 
10
- 1. **Triage proposals.** `open-memex propose` puts memories up for review.
11
- The curator reads them, then `open-memex promote <id>` to approve or
12
- `open-memex promote <id> --reject` to send back, with `--note` saying why.
10
+ 1. **Triage proposals.** Contributors put a memory up for review in two
11
+ steps: `open-memex propose` (personal memory → outbox draft), then
12
+ `open-memex submit` (draft lands in the repo as `proposed`, riding a
13
+ branch/PR). The curator reviews the proposed ones: `open-memex promote
14
+ <id>` approves (promote again to publish), or `open-memex promote <id>
15
+ --reject` to send back, with `--note` saying why. (`promote` refuses
16
+ drafts by design — an outbox draft must be submitted first.)
13
17
  2. **Resolve conflicts.** `open-memex resolve` lists file-level and semantic
14
18
  conflicts. The curator merges or picks a winner — Core never silently
15
19
  resolves a semantic conflict; both sides stay `active` until a human