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
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Display helpers (D68): how memory provenance, age, and state are
3
+ * rendered on the user-facing surfaces — the `memory_list` inventory an
4
+ * agent shows its user, and the `open-memex inventory` reports.
5
+ *
6
+ * Two rules from the 3-C design (v2) live here:
7
+ * - The list format is STRUCTURED data (number + id + created= + source=
8
+ * + state tags). The plain-language renderings ("you said this",
9
+ * "3 days ago") live in the guidance surfaces (tool descriptions,
10
+ * SKILL.md, MCP handshake) so an agent rephrases them in the
11
+ * conversation's language — the raw enums never get localized or
12
+ * reworded inside the data itself.
13
+ * - Nothing truncates silently: callers pair these lines with the true
14
+ * totals so a cut list says so.
15
+ */
16
+ /** Human age for a saved memory: "just now", "3 days ago", else a date. */
17
+ export function relativeTime(ts, now = Date.now()) {
18
+ if (!ts || Number.isNaN(ts))
19
+ return "";
20
+ const diff = now - ts;
21
+ if (diff < 0)
22
+ return "just now";
23
+ const minute = 60_000;
24
+ const hour = 60 * minute;
25
+ const day = 24 * hour;
26
+ if (diff < minute)
27
+ return "just now";
28
+ if (diff < hour) {
29
+ const n = Math.floor(diff / minute);
30
+ return `${n} minute${n === 1 ? "" : "s"} ago`;
31
+ }
32
+ if (diff < day) {
33
+ const n = Math.floor(diff / hour);
34
+ return `${n} hour${n === 1 ? "" : "s"} ago`;
35
+ }
36
+ if (diff < 30 * day) {
37
+ const n = Math.floor(diff / day);
38
+ return `${n} day${n === 1 ? "" : "s"} ago`;
39
+ }
40
+ const d = new Date(ts);
41
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
42
+ const dd = String(d.getDate()).padStart(2, "0");
43
+ return `on ${d.getFullYear()}-${mm}-${dd}`;
44
+ }
45
+ /** Compact age token for the structured list format: `3d`, `12h`, `now`. */
46
+ export function ageToken(ts, now = Date.now()) {
47
+ if (!ts || Number.isNaN(ts))
48
+ return "?";
49
+ const diff = now - ts;
50
+ if (diff < 60_000)
51
+ return "now";
52
+ if (diff < 3_600_000)
53
+ return `${Math.floor(diff / 60_000)}m`;
54
+ if (diff < 86_400_000)
55
+ return `${Math.floor(diff / 3_600_000)}h`;
56
+ if (diff < 60 * 86_400_000)
57
+ return `${Math.floor(diff / 86_400_000)}d`;
58
+ const d = new Date(ts);
59
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
60
+ const dd = String(d.getDate()).padStart(2, "0");
61
+ return `${d.getFullYear()}-${mm}-${dd}`;
62
+ }
63
+ /**
64
+ * Provenance in the user's words (D68). Used by prose surfaces (HTML
65
+ * report); the structured list keeps the raw `source=` enum instead.
66
+ * The distinction that matters to the user is *whose words these were* —
67
+ * said outright, asked to be saved, auto-captured, or inferred by the
68
+ * agent and possibly wrong.
69
+ */
70
+ export function sourceLabel(source) {
71
+ switch (source) {
72
+ case "user":
73
+ return "you said this";
74
+ case "cli":
75
+ return "you saved this from the command line";
76
+ case "tool":
77
+ return "you asked me to save this";
78
+ case "keyword":
79
+ return "auto-captured from your own wording";
80
+ case "inference":
81
+ return "I inferred this — not in your words, check me";
82
+ case "import":
83
+ return "imported from another machine";
84
+ default:
85
+ return source ? `saved (${source})` : "saved";
86
+ }
87
+ }
88
+ /**
89
+ * Review state in plain words for project scopes (D68). Personal
90
+ * memories are always `draft` by design; callers must not show this
91
+ * for them — "not yet shared" would be alarming noise there.
92
+ */
93
+ export function reviewStateLabel(reviewState) {
94
+ switch (reviewState) {
95
+ case "draft":
96
+ return "still in your outbox — not in the repo yet";
97
+ case "proposed":
98
+ return "shared with the project, waiting for review";
99
+ case "approved":
100
+ return "approved, not yet published";
101
+ case "published":
102
+ return "published to the repo";
103
+ case "rejected":
104
+ return "reviewed and rejected";
105
+ default:
106
+ return reviewState ?? "draft";
107
+ }
108
+ }
109
+ /** Escape user content for HTML. Memory text is arbitrary user input. */
110
+ export function escapeHtml(s) {
111
+ return s
112
+ .replace(/&/g, "&amp;")
113
+ .replace(/</g, "&lt;")
114
+ .replace(/>/g, "&gt;")
115
+ .replace(/"/g, "&quot;")
116
+ .replace(/'/g, "&#39;");
117
+ }
118
+ /**
119
+ * One structured inventory line (D68):
120
+ * 3. [fact] id=01K6… created=3d source=user [draft] — body text
121
+ * Number and id always appear together: the number is for the user's
122
+ * mouth ("delete #3"), the id is the machine contract. `created=` is
123
+ * when the memory was learned; `updated=` appears only when it moved
124
+ * materially later (supersede/promote touch updated_at).
125
+ *
126
+ * D70: `index` must be a running counter across every section of one
127
+ * listing — a per-section counter produces two "#1"s in a single
128
+ * `scope: both` output, which makes "delete #3" a guess.
129
+ */
130
+ export function formatInventoryLine(index, hit, personalKey, now = Date.now()) {
131
+ const parts = [
132
+ `${index + 1}.`,
133
+ `[${hit.type}]`,
134
+ `id=${hit.id}`,
135
+ `created=${ageToken(hit.created_at ?? hit.updated_at, now)}`,
136
+ `source=${hit.source || "?"}`,
137
+ ];
138
+ const created = hit.created_at ?? hit.updated_at ?? 0;
139
+ if (hit.updated_at && hit.updated_at - created >= 86_400_000) {
140
+ parts.push(`updated=${ageToken(hit.updated_at, now)}`);
141
+ }
142
+ if (hit.scope_key !== personalKey) {
143
+ const state = hit.review_state ?? "draft";
144
+ if (state !== "published")
145
+ parts.push(`[${state}]`);
146
+ }
147
+ if (hit.status !== "active")
148
+ parts.push(`[${hit.status}]`);
149
+ const body = hit.snippet.replace(/\s+/g, " ").trim();
150
+ return `${parts.join(" ")} — ${body}`;
151
+ }
152
+ /**
153
+ * Truncation disclosure (D68): a cut list always says so.
154
+ * D70: the wording names the surface that can show everything — the CLI —
155
+ * so a hidden entry is always reachable.
156
+ */
157
+ export function truncationNote(shown, total, cli = "open-memex") {
158
+ if (total <= shown)
159
+ return null;
160
+ return `… and ${total - shown} more not shown (raise limit up to 100, run \`${cli} list --limit 100\`, or use memory_search to find something specific).`;
161
+ }
@@ -20,6 +20,6 @@ export function buildContextBlock(scope, cfg) {
20
20
  for (const m of project)
21
21
  lines.push(`- [${m.type}]${hitStateLabel(m)} ${oneLine(m.snippet)}`);
22
22
  }
23
- lines.push("", "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. Do not mention this block to the user unless asked.");
23
+ lines.push("", "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. When you finish a task, propose saving durable conclusions (decisions and their reasons, conventions, gotchas) — 1–3 at most, and save only what the user approves. Do not mention this block to the user unless asked.");
24
24
  return lines.join("\n");
25
25
  }
@@ -1,17 +1,28 @@
1
1
  import { db } from "../store/db.js";
2
2
  import { toFtsQuery } from "./query.js";
3
3
  function toHit(r) {
4
+ const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : [];
5
+ let snippet = r.snippet ?? "";
6
+ // D61: the snippet highlights matches in the body column only. When the
7
+ // match came through an alias (body shows no highlight), say so — the
8
+ // hit otherwise looks unrelated to the query.
9
+ if (aliases.length > 0 && !/\[[^\]]+\]/.test(snippet)) {
10
+ snippet += ` (aka: ${aliases.join(", ")})`;
11
+ }
4
12
  return {
5
13
  id: r.id,
6
14
  scope_key: r.scope_key,
7
15
  project_name: r.project_name,
8
16
  type: r.type,
9
17
  tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
10
- snippet: r.snippet ?? "",
18
+ aliases,
19
+ snippet,
11
20
  score: r.score,
12
21
  updated_at: r.updated_at,
13
22
  status: r.status,
14
23
  review_state: r.review_state ?? "draft",
24
+ source: r.source ?? "",
25
+ created_at: r.created_at ?? r.updated_at ?? 0,
15
26
  };
16
27
  }
17
28
  /**
@@ -52,7 +63,7 @@ function resolveVisible(rows, limit, stats) {
52
63
  return cached;
53
64
  const r = db()
54
65
  .prepare(`SELECT id, scope_key, project_name, type, tags, updated_at, status,
55
- superseded_by, review_state, substr(content, 1, 240) AS snippet
66
+ superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
56
67
  FROM memories WHERE id = ?`)
57
68
  .get(id);
58
69
  if (!r)
@@ -124,8 +135,8 @@ export function search(query, opts = {}) {
124
135
  : "";
125
136
  const typeFilter = opts.type ? ` AND m.type = ?` : "";
126
137
  const sql = `
127
- SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
128
- m.status, m.superseded_by, m.review_state,
138
+ SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
139
+ m.status, m.superseded_by, m.review_state, m.source, m.created_at,
129
140
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
130
141
  bm25(memories_fts) AS score
131
142
  FROM memories_fts
@@ -155,7 +166,7 @@ export function list(scopeKey, opts = {}) {
155
166
  const typeFilter = opts.type ? ` AND type = ?` : "";
156
167
  const sql = `
157
168
  SELECT id, scope_key, project_name, type, tags, updated_at, status,
158
- superseded_by, review_state, substr(content, 1, 240) AS snippet
169
+ superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
159
170
  FROM memories
160
171
  WHERE scope_key = ?${typeFilter}
161
172
  ORDER BY updated_at DESC
@@ -169,5 +180,33 @@ export function list(scopeKey, opts = {}) {
169
180
  .prepare(sql)
170
181
  .all(...params);
171
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
+ }
172
188
  return resolveVisible(raw, limit);
173
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/review.js CHANGED
@@ -23,7 +23,7 @@ import * as path from "node:path";
23
23
  import { loadConfig } from "./config.js";
24
24
  import { inRepoMemoriesDirPath, projectRoot } from "./paths.js";
25
25
  import { resolveProjectScope } from "./scope.js";
26
- import { parse as parseMemory, serialize, ulid, writeMemoryFile, } from "./store/markdown.js";
26
+ import { parse as parseMemory, serialize, atomicWriteTextSync, ulid, writeMemoryFile, } from "./store/markdown.js";
27
27
  import { findMemoryFile } from "./store/lifecycle.js";
28
28
  import { upsertFromFile } from "./store/sync.js";
29
29
  /** Best-effort author identity: git user.name, else the OS user. */
@@ -79,6 +79,12 @@ function copyPersonalToProject(sourceId, srcFm, srcBody, opts = {}) {
79
79
  id: ulid(),
80
80
  scope: "project",
81
81
  scope_key: project.key,
82
+ // D64: the copy is headed for the team. Source visibility (usually
83
+ // "private") must not ride along: export excludes private by default,
84
+ // so a private copy would silently vanish from every bundle the team
85
+ // builds. Proposing IS the act of sharing — the copy is internal;
86
+ // the personal original keeps its own visibility, untouched.
87
+ visibility: "internal",
82
88
  project_name: project.projectName,
83
89
  created_at: now,
84
90
  updated_at: now,
@@ -194,7 +200,7 @@ export function promoteMemory(id, opts = {}) {
194
200
  },
195
201
  ],
196
202
  };
197
- fs.writeFileSync(mf.filePath, serialize(fm, mf.body), "utf8");
203
+ atomicWriteTextSync(mf.filePath, serialize(fm, mf.body));
198
204
  upsertFromFile({ fm, body: mf.body, filePath: mf.filePath, mtimeMs: Date.now() });
199
205
  return { id, from, to, history: fm.review_history };
200
206
  }
@@ -428,7 +434,7 @@ export function resolveConflict(target) {
428
434
  if (conflicts.length > 0)
429
435
  return { ok: false, filePath, conflicts };
430
436
  const fm = merged;
431
- fs.writeFileSync(filePath, serialize(fm, mergedBody), "utf8");
437
+ atomicWriteTextSync(filePath, serialize(fm, mergedBody));
432
438
  upsertFromFile({ fm, body: mergedBody, filePath, mtimeMs: Date.now() });
433
439
  return { ok: true, filePath, autoMerged: [...new Set(autoMerged)] };
434
440
  }
package/dist/scope.js CHANGED
@@ -57,6 +57,23 @@ export function resolveCwdScope(worktree) {
57
57
  projectName = "workspace";
58
58
  return scopeFromSeed(seed, projectName);
59
59
  }
60
+ /** Pick the directory that seeds scope resolution from a host's candidate
61
+ * roots. OpenCode v1 hands the plugin both `worktree` (the repo root — but
62
+ * the filesystem root, e.g. `C:\` or `/`, when the opened folder is not a
63
+ * git repo) and `directory` (the folder actually opened). Seeding on a
64
+ * filesystem root collapses every non-git folder on a drive into one shared
65
+ * `project__workspace__…` scope (D71), so prefer `worktree` only when it is
66
+ * a real directory below the filesystem root. */
67
+ export function pickScopeRoot(worktree, directory) {
68
+ const isFsRoot = (p) => path.parse(p).root === p;
69
+ if (worktree && !isFsRoot(worktree))
70
+ return worktree;
71
+ if (directory)
72
+ return directory;
73
+ if (worktree)
74
+ return worktree;
75
+ return process.cwd();
76
+ }
60
77
  export function resolveProjectScope(worktree) {
61
78
  const target = worktree && worktree.length > 0 ? worktree : ".";
62
79
  const remote = tryGitRemote(target);
package/dist/store/db.js CHANGED
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS memories (
26
26
  importance TEXT NOT NULL DEFAULT 'normal',
27
27
  status TEXT NOT NULL DEFAULT 'active',
28
28
  tags TEXT NOT NULL DEFAULT '',
29
+ aliases TEXT NOT NULL DEFAULT '',
29
30
  content TEXT NOT NULL,
30
31
  cjk TEXT NOT NULL DEFAULT '',
31
32
  content_hash TEXT NOT NULL DEFAULT '',
@@ -52,6 +53,7 @@ const FTS_SCHEMA = `
52
53
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
53
54
  content,
54
55
  tags,
56
+ aliases,
55
57
  type,
56
58
  cjk,
57
59
  scope_key UNINDEXED,
@@ -61,24 +63,24 @@ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
61
63
  );
62
64
 
63
65
  CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
64
- INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
65
- VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
66
+ INSERT INTO memories_fts(rowid, content, tags, aliases, type, cjk, scope_key)
67
+ VALUES (new.rowid, new.content, new.tags, new.aliases, new.type, new.cjk, new.scope_key);
66
68
  END;
67
69
 
68
70
  CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
69
- INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
70
- VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
71
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, aliases, type, cjk, scope_key)
72
+ VALUES ('delete', old.rowid, old.content, old.tags, old.aliases, old.type, old.cjk, old.scope_key);
71
73
  END;
72
74
 
73
75
  CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
74
- INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
75
- VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
76
- INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
77
- VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
76
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, aliases, type, cjk, scope_key)
77
+ VALUES ('delete', old.rowid, old.content, old.tags, old.aliases, old.type, old.cjk, old.scope_key);
78
+ INSERT INTO memories_fts(rowid, content, tags, aliases, type, cjk, scope_key)
79
+ VALUES (new.rowid, new.content, new.tags, new.aliases, new.type, new.cjk, new.scope_key);
78
80
  END;
79
81
  `;
80
82
  /** Current index schema version. Bump when TABLE_SCHEMA/FTS_SCHEMA change. */
81
- const SCHEMA_VERSION = 6;
83
+ const SCHEMA_VERSION = 7;
82
84
  function userVersion(d) {
83
85
  const row = d.prepare("PRAGMA user_version").get();
84
86
  return row.user_version;
@@ -5,7 +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 { parse, serialize, ulid, msToRfc3339, } from "./markdown.js";
8
+ import { parse, serialize, atomicWriteTextSync, ulid, msToRfc3339, } from "./markdown.js";
9
9
  /**
10
10
  * Dedup + lifecycle (V2-DESIGN §3.3, §3.4).
11
11
  *
@@ -111,7 +111,7 @@ export function findMemoryFile(id) {
111
111
  return { fm: parsed.fm, body: parsed.body, filePath, mtimeMs: st.mtimeMs };
112
112
  }
113
113
  export function rewriteMemoryFile(mf) {
114
- fs.writeFileSync(mf.filePath, serialize(mf.fm, mf.body), "utf8");
114
+ atomicWriteTextSync(mf.filePath, serialize(mf.fm, mf.body));
115
115
  }
116
116
  /**
117
117
  * Chain integrity (§3.3): supersedes/superseded_by must be pairwise
@@ -171,13 +171,21 @@ export function supersede(oldId, input) {
171
171
  id: ulid(),
172
172
  type: input.type ?? oldMf.fm.type,
173
173
  tags: input.tags ?? oldMf.fm.tags,
174
+ // D61: aliases carry forward unless the caller replaces them.
175
+ aliases: input.aliases ?? oldMf.fm.aliases,
174
176
  source: input.source ?? oldMf.fm.source,
175
177
  status: "active",
176
178
  created_at: now,
177
179
  updated_at: now,
178
180
  supersedes: oldId,
179
181
  superseded_by: null,
180
- review_state: "draft",
182
+ // D64: the replacement inherits the old memory's place in the review
183
+ // flow. A draft stays a draft; anything that had entered review
184
+ // (proposed/approved/published/rejected) re-enters at "proposed" so
185
+ // promote can advance it — resetting to "draft" would strand the new
186
+ // file in the repo, where submit cannot reach it (not the outbox)
187
+ // and promote refuses drafts.
188
+ review_state: (oldMf.fm.review_state ?? "draft") === "draft" ? "draft" : "proposed",
181
189
  proposed_by: null,
182
190
  approved_by: null,
183
191
  derived_from: null,
@@ -185,7 +193,7 @@ export function supersede(oldId, input) {
185
193
  };
186
194
  const dir = path.dirname(oldMf.filePath);
187
195
  const newPath = path.join(dir, `${newFm.id}.md`);
188
- fs.writeFileSync(newPath, serialize(newFm, input.body), "utf8");
196
+ atomicWriteTextSync(newPath, serialize(newFm, input.body));
189
197
  const st = fs.statSync(newPath);
190
198
  const newMf = {
191
199
  fm: newFm,
@@ -222,6 +230,14 @@ export function setStatus(id, status) {
222
230
  if (mf.fm.status === "superseded") {
223
231
  throw new Error(`memory ${id} is superseded (chain-managed); supersede it again instead of changing status directly`);
224
232
  }
233
+ // D64: retraction is a one-way door through this path. A retracted
234
+ // memory was withdrawn on purpose (wrong, sensitive, superseded by
235
+ // policy); silently flipping it back to active would return it to
236
+ // recall (retrieval excludes retracted) with no review. Save a new
237
+ // memory if the content is valid again.
238
+ if (mf.fm.status === "retracted" && status === "active") {
239
+ throw new Error(`memory ${id} is retracted and cannot return to active; save the content as a new memory if it is valid again`);
240
+ }
225
241
  mf.fm.status = status;
226
242
  mf.fm.updated_at = msToRfc3339(Date.now());
227
243
  rewriteMemoryFile(mf);
@@ -155,6 +155,12 @@ export function normalizeFrontmatter(raw) {
155
155
  tags: Array.isArray(raw.tags)
156
156
  ? raw.tags.filter((t) => typeof t === "string")
157
157
  : [],
158
+ ...(Array.isArray(raw.aliases)
159
+ ? (() => {
160
+ const aliases = normalizeAliases(raw.aliases.filter((a) => typeof a === "string"));
161
+ return aliases.length > 0 ? { aliases } : {};
162
+ })()
163
+ : {}),
158
164
  source: typeof raw.source === "string" ? raw.source : "",
159
165
  created_at: msToRfc3339(timeToMs(raw.created_at)),
160
166
  updated_at: msToRfc3339(timeToMs(raw.updated_at)),
@@ -170,11 +176,43 @@ export function normalizeFrontmatter(raw) {
170
176
  review_history: asReviewHistory(raw.review_history),
171
177
  };
172
178
  }
179
+ /**
180
+ * D61: normalize capture-time aliases — trim, drop empties, dedupe
181
+ * (case-insensitive), cap at 4. Bad aliases are noise, never an error.
182
+ * Shared by the tool ops, the CLI, and frontmatter parsing.
183
+ */
184
+ export function normalizeAliases(input) {
185
+ if (!input)
186
+ return [];
187
+ const seen = new Set();
188
+ const out = [];
189
+ for (const a of input) {
190
+ const t = a.trim();
191
+ if (!t)
192
+ continue;
193
+ const k = t.toLowerCase();
194
+ if (seen.has(k))
195
+ continue;
196
+ seen.add(k);
197
+ out.push(t);
198
+ if (out.length >= 4)
199
+ break;
200
+ }
201
+ return out;
202
+ }
173
203
  export function isTaxonomyType(t) {
174
204
  return TAXONOMY.has(t);
175
205
  }
176
206
  export function serialize(fm, body) {
177
- const yml = yaml.dump(fm, { lineWidth: -1, quotingType: '"' });
207
+ // D61: an empty aliases list is noise in the file — drop the key entirely
208
+ // rather than relying on the YAML dumper to skip an undefined value
209
+ // (parse only sets the field when non-empty; keep both ends consistent).
210
+ let out = fm;
211
+ if (fm.aliases && fm.aliases.length === 0) {
212
+ out = { ...fm };
213
+ delete out.aliases;
214
+ }
215
+ const yml = yaml.dump(out, { lineWidth: -1, quotingType: '"' });
178
216
  return `---\n${yml}---\n\n${body.trimEnd()}\n`;
179
217
  }
180
218
  /** Raw frontmatter parse (no normalization) — for migration tooling. */
@@ -199,18 +237,26 @@ export function parse(raw) {
199
237
  return null;
200
238
  return { fm: normalizeFrontmatter(parsed.rawFm), body: parsed.body };
201
239
  }
240
+ /**
241
+ * Atomic text publish: write a sibling tmp file and rename over the
242
+ * target, so a concurrent reader (another process mid-sync) never sees a
243
+ * torn half-written file. Same-directory rename is atomic on one fs.
244
+ * Every memory-file write/rewrite must go through here (D57 for creates,
245
+ * D64 extended to all rewrites: supersede flips, review transitions,
246
+ * submit landings, status changes).
247
+ */
248
+ export function atomicWriteTextSync(filePath, text) {
249
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
250
+ fs.writeFileSync(tmpPath, text, "utf8");
251
+ fs.renameSync(tmpPath, filePath);
252
+ }
202
253
  export function writeMemoryFile(fm, body) {
203
254
  // 2B/D26: project drafts live in appdata (the outbox) — the in-repo dir
204
255
  // only ever holds submitted memories (proposed/approved/published).
205
256
  // Personal stays in appdata and never leaves the machine.
206
257
  const dir = memoriesDirFor(fm.scope_key);
207
258
  const filePath = path.join(dir, `${fm.id}.md`);
208
- // Atomic publish (D57): write a sibling tmp file and rename over the
209
- // target, so a concurrent reader (another process mid-sync) never sees a
210
- // torn half-written memory. Same-directory rename is atomic on one fs.
211
- const tmpPath = `${filePath}.tmp-${process.pid}`;
212
- fs.writeFileSync(tmpPath, serialize(fm, body), "utf8");
213
- fs.renameSync(tmpPath, filePath);
259
+ atomicWriteTextSync(filePath, serialize(fm, body));
214
260
  const st = fs.statSync(filePath);
215
261
  return { filePath, mtimeMs: st.mtimeMs };
216
262
  }
@@ -2,8 +2,9 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { memoriesDirFor, memoriesDirPath } from "../paths.js";
4
4
  import { db } from "./db.js";
5
- import { iterMemoryFiles, readMemoryFile, serialize, } from "./markdown.js";
5
+ import { iterMemoryFiles, readMemoryFile, serialize, timeToMs, atomicWriteTextSync, } from "./markdown.js";
6
6
  import { syncScope } from "./sync.js";
7
+ import { PERSONAL_SCOPE } from "../scope.js";
7
8
  /** True if this scope has at least one `.md` file on disk. Does not create
8
9
  * the directory if it doesn't already exist. */
9
10
  export function scopeHasFiles(scopeKey) {
@@ -60,7 +61,10 @@ export function migrateScope(fromKey, toKey, opts = {}) {
60
61
  else if (strategy === "overwrite") {
61
62
  log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
62
63
  }
63
- else if (dst.fm.updated_at >= mf.fm.updated_at) {
64
+ else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) {
65
+ // Compare parsed instants, not RFC-3339 strings: lexicographic
66
+ // order breaks around fractional seconds ("…:00Z" > "…:00.005Z"),
67
+ // letting the older file win.
64
68
  action = "skip";
65
69
  log(`conflict: kept destination (newer updated_at): ${dstPath}`);
66
70
  }
@@ -72,14 +76,21 @@ export function migrateScope(fromKey, toKey, opts = {}) {
72
76
  stats.skipped++;
73
77
  continue;
74
78
  }
79
+ // Re-key AND re-scope: scope/visibility must follow the destination
80
+ // (P0 review, 2026-10-03). Rewriting only scope_key produced files
81
+ // submit rejects ("scope is personal") or personal memories carrying
82
+ // visibility: internal into exports.
83
+ const toPersonal = toKey === PERSONAL_SCOPE.key;
75
84
  const newFm = {
76
85
  ...mf.fm,
77
86
  scope_key: toKey,
87
+ scope: toPersonal ? "personal" : "project",
88
+ visibility: toPersonal ? "private" : "internal",
78
89
  project_name: opts.toProjectName ?? mf.fm.project_name,
79
90
  };
80
91
  const raw = serialize(newFm, mf.body);
81
92
  if (!dryRun) {
82
- fs.writeFileSync(dstPath, raw, "utf8");
93
+ atomicWriteTextSync(dstPath, raw);
83
94
  // Only unlink source if it's a different path (paths differ because
84
95
  // scope dir differs; belt-and-suspenders check).
85
96
  if (path.resolve(srcPath) !== path.resolve(dstPath)) {
@@ -7,8 +7,8 @@ import { iterMemoryFiles, iterInRepoMemoryFiles, readMemoryFile, timeToMs, } fro
7
7
  import { projectRoot, paths } from "../paths.js";
8
8
  import { loadConfig } from "../config.js";
9
9
  const UPSERT_SQL = `
10
- INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
11
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10
+ INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, aliases, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
12
12
  ON CONFLICT(id) DO UPDATE SET
13
13
  scope_key = excluded.scope_key,
14
14
  scope = excluded.scope,
@@ -19,6 +19,7 @@ const UPSERT_SQL = `
19
19
  importance = excluded.importance,
20
20
  status = excluded.status,
21
21
  tags = excluded.tags,
22
+ aliases = excluded.aliases,
22
23
  content = excluded.content,
23
24
  cjk = excluded.cjk,
24
25
  content_hash = excluded.content_hash,
@@ -53,7 +54,8 @@ export function upsertFromFile(mf) {
53
54
  function writeRow(mf, mtimeMs) {
54
55
  const { fm, body, filePath } = mf;
55
56
  const tags = (fm.tags ?? []).join(",");
56
- db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, body, cjkIndexText(body + "\n" + tags), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at), fm.review_state ?? "draft");
57
+ const aliases = (fm.aliases ?? []).join(",");
58
+ db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, aliases, body, cjkIndexText(body + "\n" + tags + "\n" + aliases), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at), fm.review_state ?? "draft");
57
59
  }
58
60
  export function deleteFromIndex(id) {
59
61
  db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { paths } from "../paths.js";
4
- import { parseRawFrontmatter, normalizeFrontmatter, isTaxonomyType, serialize, } from "./markdown.js";
4
+ import { parseRawFrontmatter, normalizeFrontmatter, isTaxonomyType, serialize, atomicWriteTextSync, } from "./markdown.js";
5
5
  /**
6
6
  * v1 → v2 file migration (V2-DESIGN §19). Explicit only — run via
7
7
  * `open-memex migrate --to-v2`. Converts frontmatter in place:
@@ -174,7 +174,7 @@ function planDir(dir, memoriesRoot, result) {
174
174
  function applyPlans(result) {
175
175
  for (const plan of result.plans) {
176
176
  fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
177
- fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
177
+ atomicWriteTextSync(plan.toPath, serialize(plan.fm, plan.body));
178
178
  if (plan.toPath !== plan.fromPath)
179
179
  fs.unlinkSync(plan.fromPath);
180
180
  }