open-memex 0.6.2 → 0.7.0-alpha.11

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 (68) hide show
  1. package/AGENTS.md +25 -7
  2. package/CHANGELOG.md +103 -1
  3. package/README.md +54 -16
  4. package/README.zh-CN.md +30 -15
  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 +180 -79
  11. package/dist/export.js +12 -2
  12. package/dist/index.js +20 -112
  13. package/dist/init.js +103 -11
  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/opencode-v2.js +43 -0
  18. package/dist/paths.js +34 -2
  19. package/dist/plugin-core.js +138 -0
  20. package/dist/retrieve/display.js +161 -0
  21. package/dist/retrieve/inject.js +1 -1
  22. package/dist/retrieve/search.js +44 -5
  23. package/dist/review.js +9 -3
  24. package/dist/store/db.js +11 -9
  25. package/dist/store/lifecycle.js +20 -4
  26. package/dist/store/markdown.js +53 -7
  27. package/dist/store/migrate.js +14 -3
  28. package/dist/store/sync.js +5 -3
  29. package/dist/store/v2migrate.js +2 -2
  30. package/dist/submit.js +13 -5
  31. package/dist/tools/memory.js +28 -0
  32. package/dist/tools/ops.js +127 -16
  33. package/docs/CURATOR.md +7 -3
  34. package/docs/V2-DESIGN.md +335 -32
  35. package/docs/memory-visibility-design.md +130 -0
  36. package/package.json +2 -2
  37. package/scripts/build-stamp.mjs +63 -0
  38. package/scripts/smoke-mcp.ts +47 -1
  39. package/scripts/smoke-pure.ts +237 -3
  40. package/scripts/test-full.ts +362 -5
  41. package/skills/open-memex/SKILL.md +53 -1
  42. package/src/capture/keywords.ts +56 -6
  43. package/src/cli.ts +282 -39
  44. package/src/config.ts +104 -17
  45. package/src/distill-agents.ts +19 -0
  46. package/src/doctor.ts +181 -71
  47. package/src/export.ts +14 -2
  48. package/src/index.ts +25 -112
  49. package/src/init.ts +108 -11
  50. package/src/inventory-html.ts +191 -0
  51. package/src/inventory.ts +242 -0
  52. package/src/mcp.ts +21 -5
  53. package/src/opencode-v2.ts +119 -0
  54. package/src/paths.ts +35 -1
  55. package/src/plugin-core.ts +152 -0
  56. package/src/retrieve/display.ts +174 -0
  57. package/src/retrieve/inject.ts +1 -1
  58. package/src/retrieve/search.ts +63 -6
  59. package/src/review.ts +9 -2
  60. package/src/store/db.ts +11 -9
  61. package/src/store/lifecycle.ts +24 -3
  62. package/src/store/markdown.ts +60 -7
  63. package/src/store/migrate.ts +15 -2
  64. package/src/store/sync.ts +6 -3
  65. package/src/store/v2migrate.ts +2 -1
  66. package/src/submit.ts +12 -4
  67. package/src/tools/memory.ts +28 -0
  68. package/src/tools/ops.ts +147 -17
@@ -0,0 +1,138 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "./scope.js";
3
+ import { db } from "./store/db.js";
4
+ import { syncScope, upsertFromFile } from "./store/sync.js";
5
+ import { scopeHasFiles } from "./store/migrate.js";
6
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
7
+ import { buildContextBlock } from "./retrieve/inject.js";
8
+ import { detectKeywords } from "./capture/keywords.js";
9
+ import { findDuplicates } from "./store/lifecycle.js";
10
+ import { redact } from "./redact.js";
11
+ /**
12
+ * Host-agnostic plugin core (D60). Both opencode host generations adapt to
13
+ * this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
14
+ * (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
15
+ * hook shapes — stays in the adapters; everything memory-shaped lives here.
16
+ */
17
+ export const PLUGIN_ID = "open-memex";
18
+ /**
19
+ * Resolve the project scope for a host working directory and bring the
20
+ * index up to date. Never throws: a broken store must not keep the host
21
+ * from starting — failures land in the host's log instead.
22
+ */
23
+ export function bootstrapPlugin(root) {
24
+ const cfg = loadConfig();
25
+ const scope = resolveProjectScope(root);
26
+ try {
27
+ db();
28
+ syncScope(scope.key);
29
+ syncScope(PERSONAL_SCOPE.key);
30
+ if (cfg.logLevel === "debug") {
31
+ console.log(`[open-memex] loaded. scope=${scope.key}`);
32
+ }
33
+ // If a git remote was added *after* memories were first captured, the
34
+ // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
35
+ // disk and tell the user how to migrate. We do NOT auto-migrate: two
36
+ // different repos at the same cwd would collide.
37
+ const cwdScope = resolveCwdScope(root);
38
+ if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
39
+ console.warn(`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
40
+ `Current project scope is "${scope.key}". ` +
41
+ `To move them: npm run cli -- migrate --from ${cwdScope.key}`);
42
+ }
43
+ }
44
+ catch (err) {
45
+ console.error("[open-memex] init failed:", err);
46
+ }
47
+ return { cfg, scope, injectedSessions: new Set() };
48
+ }
49
+ /**
50
+ * Keyword capture (§ capture): scan one user message for "remember …"
51
+ * triggers and save each hit. Shared by the v1 `chat.message` hook and
52
+ * the v2 session `prompt` hook — both hand us the message text.
53
+ */
54
+ export function captureFromText(text, state) {
55
+ const { cfg, scope } = state;
56
+ if (!cfg.keywordCaptureEnabled || !text)
57
+ return;
58
+ const hits = detectKeywords(text, cfg);
59
+ for (const h of hits) {
60
+ const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
61
+ // Secrets are masked (first 4 chars kept) and the capture proceeds;
62
+ // skip only when nothing usable remains.
63
+ if (content.length === 0)
64
+ continue;
65
+ if (hadSecret && cfg.logLevel === "debug") {
66
+ console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
67
+ }
68
+ // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
69
+ const target = h.personal ? PERSONAL_SCOPE : scope;
70
+ // Dedup (§3.4): skip exact duplicates captured before.
71
+ if (findDuplicates(target.key, content).exact)
72
+ continue;
73
+ const now = Date.now();
74
+ const rfc = msToRfc3339(now);
75
+ const fm = {
76
+ id: ulid(),
77
+ schema_version: 2,
78
+ scope_key: target.key,
79
+ scope: target.kind === "project" ? "project" : "personal",
80
+ visibility: target.kind === "project" ? "internal" : "private",
81
+ project_name: target.projectName,
82
+ type: "fact",
83
+ role: "knowledge",
84
+ importance: "normal",
85
+ status: "active",
86
+ tags: ["keyword"],
87
+ source: "keyword",
88
+ created_at: rfc,
89
+ updated_at: rfc,
90
+ supersedes: null,
91
+ superseded_by: null,
92
+ review_state: "draft",
93
+ proposed_by: null,
94
+ approved_by: null,
95
+ derived_from: null,
96
+ review_note: null,
97
+ review_history: [],
98
+ };
99
+ try {
100
+ const { filePath } = writeMemoryFile(fm, content);
101
+ const mf = readMemoryFile(filePath);
102
+ if (mf)
103
+ upsertFromFile(mf);
104
+ // Capture feedback: always visible (not debug-only) — the user said
105
+ // "记住…", they should see that it landed. The opencode plugin API
106
+ // offers no toast channel, so the plugin log is the feedback surface.
107
+ const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
108
+ console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
109
+ }
110
+ catch (err) {
111
+ console.error("[open-memex] keyword capture failed:", err);
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * First-turn context injection: exactly once per session. The host adapter
117
+ * supplies `push` (v1: output.system.push; v2: the session context hook's
118
+ * system array). Returns true when a block was pushed.
119
+ */
120
+ export function injectOnce(sessionID, state, push) {
121
+ const { cfg, scope } = state;
122
+ if (!cfg.injectOnFirstTurn)
123
+ return false;
124
+ if (state.injectedSessions.has(sessionID))
125
+ return false;
126
+ state.injectedSessions.add(sessionID);
127
+ try {
128
+ const block = buildContextBlock(scope, cfg);
129
+ if (block) {
130
+ push(block);
131
+ return true;
132
+ }
133
+ }
134
+ catch (err) {
135
+ console.error("[open-memex] context injection failed:", err);
136
+ }
137
+ return false;
138
+ }
@@ -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/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
  }