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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -137,6 +137,15 @@ Every write path (tool, keyword hook, CLI `add`) must:
137
137
  3. Check `findDuplicates` (design §3.4): identical content is idempotent (return existing id); near-duplicates (similarity ≥ 0.8) warn but save — suggest `supersede` when the new content replaces the old.
138
138
  4. `writeMemoryFile` first, then `readMemoryFile` + `upsertFromFile` to keep FTS in sync.
139
139
 
140
+ Capture-time aliases (D61): `memory_add`/`supersede` may carry up to 4
141
+ `aliases` (alternate phrasings) into frontmatter; they index in their own FTS
142
+ column. Only when `cfg.captureAliases` is on (init asks once, default on) —
143
+ when off, passed aliases are dropped. Always pass them through
144
+ `normalizeAliases`; the keyword-capture path (no agent in the loop) never
145
+ invents aliases. Checkpoint-proposal guidance (propose, never auto-draft) is
146
+ copy, not mechanism: keep the rubric wording in sync across the skill, the
147
+ AGENTS.md hygiene footer, the MCP handshake, and the tool descriptions.
148
+
140
149
  ## Lifecycle invariants (design §3.3)
141
150
 
142
151
  - Statuses: `active → superseded | deprecated | retracted | archived`. Retrieval excludes `retracted`/`archived`, ranks `active` above `deprecated`.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,11 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Proactive capture: checkpoint proposals and aliases (D61)
13
+
14
+ - At task checkpoints the agent now proposes instead of waiting to be asked: when a task wraps up, it offers 1–3 short candidate memories distilled from the session (decisions and their reasons, conventions, gotchas, approaches tried and abandoned) and saves only the ones you approve — approved inferences are marked `source: "inference"`. One memorability rubric and one search discipline (break the question into concepts, try several phrasings, check both scopes) are written into every guidance surface — the bundled skill, the AGENTS.md memory-hygiene footer, the MCP handshake, and the tool descriptions — so behavior is consistent across editors. Nothing is auto-drafted in the background; that posture waits on memory visibility.
15
+ - Memories can carry up to 4 **aliases** — alternate phrasings, synonyms, other-language equivalents — stored in the memory file and indexed with it, so a differently-worded question still matches ("vacation days" finds the holiday policy; "节假日" too). `init` asks once (default on); `open-memex config set captureAliases false` turns it off. Existing memory files are untouched — the index rebuilds itself on first use.
16
+
12
17
  ## One plugin, both opencode generations (D60)
13
18
 
14
19
  - The native plugin now loads on **opencode 2** as well as opencode 1. `open-memex init --client opencode --global` writes both config spellings — the v1 `"plugin"` file entry and the v2 `"plugins"` directory entry — and each host reads its own. Previously an opencode 2 host silently skipped the plugin (a file entry is not a directory it can load), leaving no tools and no explanation.
package/README.md CHANGED
@@ -373,7 +373,19 @@ Three ways memories get in:
373
373
  (or it saves on its own when you state a fact worth keeping) — it calls
374
374
  `memory_add`. The routing above is a heuristic; you can always say "save this
375
375
  to my personal memory" or use the CLI with `--scope` to be explicit.
376
- - **The CLI**: `open-memex add "…"` with optional `--scope` / `--tags` / `--type`.
376
+ - **Checkpoint proposals**: when a task wraps up, the agent proposes 1–3 short
377
+ candidate memories distilled from the session — decisions and their reasons,
378
+ conventions, gotchas, approaches tried and abandoned — and saves only the
379
+ ones you approve. Nothing is written silently: no draft is created behind
380
+ your back.
381
+ - **The CLI**: `open-memex add "…"` with optional `--scope` / `--tags` / `--type`
382
+ / `--aliases`.
383
+
384
+ **Aliases.** Each saved memory can carry up to 4 alternate phrasings
385
+ (synonyms, another language's equivalent) that are indexed with it, so a
386
+ question worded differently still finds the memory — "vacation days" finds the
387
+ holiday policy. `init` asks once whether to enable this (default on); turn it
388
+ off any time with `open-memex config set captureAliases false`.
377
389
 
378
390
  **Redaction.** Wrap anything sensitive in `<private>…</private>` and it is
379
391
  stripped before saving. Recognized secrets (API keys, tokens, high-entropy
package/README.zh-CN.md CHANGED
@@ -254,7 +254,10 @@ open-memex uninstall --yes
254
254
 
255
255
  - **关键词触发**(仅 opencode 原生插件):说 `remember …`、`note that …`、`don't forget …`、`TIL …`、`save this …`——中文 `记住…` / `记一下` / `记录一下` / `别忘了…`——这句话就被存下来,不需要任何工具调用。捕获默认进当前 **project**;`remember for me …`、`记住我…`、`替我记…`、`我觉得…`、`我喜欢…` 这类"这是关于我"的信号会改为进 **personal**;`我们决定…`、`帮我们记住…` 等团队语境仍留在项目里。
256
256
  - **助手主动保存**:在任何编辑器里,你让助手记住某件事(或者你陈述了一个值得记的事实、它自己判断该保存),它会调用 `memory_add`。上面的路由是启发式规则;你可以明说"存到我的个人记忆",或用 CLI 加 `--scope` 明确指定。
257
- - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tags` / `--type`。
257
+ - **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
258
+ - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tags` / `--type` / `--aliases`。
259
+
260
+ **别名。** 每条记忆可以带最多 4 个换一种说法(同义词、另一种语言的对应词),和记忆一起进索引,这样问法不同也能找到——比如搜 "vacation days" 能找到假期政策那条。`init` 时会问一次是否开启(默认开);随时可用 `open-memex config set captureAliases false` 关掉。
258
261
 
259
262
  **脱敏。** 把敏感内容包在 `<private>…</private>` 里,它在保存前会被整段剥离。识别出的密钥(API key、token、高熵凭证)会被就地掩码——保留前 4 个字符(让你能认出是哪个 key),其余替换——记忆仍会保存。随时安全地预览一句话会被捕获成什么样:
260
263
 
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatR
10
10
  import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
11
11
  import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
12
12
  import { search, list, hitStateLabel } from "./retrieve/search.js";
13
- import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
13
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, normalizeAliases, } from "./store/markdown.js";
14
14
  import { loadConfig } from "./config.js";
15
15
  import { paths, projectRoot } from "./paths.js";
16
16
  import { redact } from "./redact.js";
@@ -54,18 +54,25 @@ Example:
54
54
  open-memex search "deploy checklist" --scope both`,
55
55
  add: `Save a fact, preference, decision, or note to local memory.
56
56
 
57
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
57
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
58
58
 
59
59
  Flags:
60
- --scope project (default) or personal (personal never leaves this machine)
61
- --type memory type (default: fact)
62
- --tag comma-separated tags
60
+ --scope project (default) or personal (personal never leaves this machine)
61
+ --type memory type (default: fact)
62
+ --tag comma-separated tags
63
+ --aliases alternate phrasings of this fact (up to 4), indexed with the
64
+ memory so differently-worded searches still match; separate
65
+ with ; (commas also accepted — use ; when an alias itself
66
+ contains one). Stored only when capture aliases are enabled
67
+ (init default)
63
68
 
64
69
  Example:
65
70
  open-memex add "We deploy on Fridays" --scope project --tag process`,
66
71
  supersede: `Replace a memory with a newer version. The old one is kept as history.
67
72
 
68
- Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
73
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
74
+
75
+ Without --aliases, the old memory's aliases carry over.
69
76
 
70
77
  Example:
71
78
  open-memex supersede 01ABC "We deploy on Thursdays now"`,
@@ -849,6 +856,18 @@ async function main() {
849
856
  .map((t) => t.trim())
850
857
  .filter(Boolean)
851
858
  : [],
859
+ // D61: --aliases "phrase one; phrase two" (comma also accepted; use
860
+ // ; when an alias itself contains a comma). A bare --aliases with no
861
+ // value makes parseFlags yield "true" — treat that as absent. Stored
862
+ // only when the install enables capture aliases, redacted like content.
863
+ ...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
864
+ ? {
865
+ aliases: normalizeAliases(flags.aliases
866
+ .split(/[;,]/)
867
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
868
+ .filter(Boolean)),
869
+ }
870
+ : {}),
852
871
  source: "cli",
853
872
  created_at: rfc,
854
873
  updated_at: rfc,
@@ -888,6 +907,12 @@ async function main() {
888
907
  tags: flags.tag
889
908
  ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
890
909
  : undefined,
910
+ aliases: typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
911
+ ? normalizeAliases(flags.aliases
912
+ .split(/[;,]/)
913
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
914
+ .filter(Boolean))
915
+ : undefined,
891
916
  source: "cli",
892
917
  });
893
918
  upsertFromFile(oldMf);
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ export const DEFAULT_CONFIG = {
6
6
  maxProfileItems: 5,
7
7
  injectOnFirstTurn: true,
8
8
  keywordCaptureEnabled: true,
9
+ captureAliases: true,
9
10
  keywordPatterns: [
10
11
  "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
11
12
  "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
@@ -79,6 +80,7 @@ export const SETTABLE_KEYS = {
79
80
  maxProfileItems: toNonNegInt,
80
81
  injectOnFirstTurn: toBool,
81
82
  keywordCaptureEnabled: toBool,
83
+ captureAliases: toBool,
82
84
  memoryDir: toRelativeDir,
83
85
  "sync.autoPull": toBool,
84
86
  logLevel: (v) => {
@@ -57,7 +57,7 @@ export function distillAgentsMarkdown(opts) {
57
57
  // (D53: the checkpoint mechanism is gone).
58
58
  lines.push(`### Memory hygiene (open-memex)`);
59
59
  lines.push(``);
60
- lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it.`);
60
+ lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, ` Worth keeping: decisions and their reasons, preferences, conventions,`, ` gotchas, approaches tried and abandoned. Not worth keeping: one-off task`, ` details or anything re-derivable from the code.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it.`, `- When saving via \`memory_add\`, attach 2–4 aliases (alternate phrasings,`, ` synonyms, equivalents in the user's other language) when the install has`, ` capture aliases enabled — they make reworded questions find the memory.`);
61
61
  lines.push(``);
62
62
  return lines.join("\n");
63
63
  }
package/dist/init.js CHANGED
@@ -805,6 +805,11 @@ export async function initProject(opts) {
805
805
  const injectOnFirstTurn = await askBool("Inject relevant memories when a session starts?", DEFAULT_CONFIG.injectOnFirstTurn);
806
806
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
807
807
  patch.injectOnFirstTurn = injectOnFirstTurn;
808
+ // D61: capture-time aliases — agents attach alternate phrasings to each
809
+ // memory so differently-worded questions still find it.
810
+ const captureAliases = await askBool("Attach alternate phrasings (aliases) to memories so reworded questions still find them?", DEFAULT_CONFIG.captureAliases);
811
+ if (captureAliases !== DEFAULT_CONFIG.captureAliases)
812
+ patch.captureAliases = captureAliases;
808
813
  if (Object.keys(patch).length > 0) {
809
814
  const file = saveConfig(patch);
810
815
  console.log(` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`);
package/dist/mcp.js CHANGED
@@ -85,9 +85,10 @@ memory_resolve, memory_pr_status — always call them by these full names.
85
85
  docs, save it as type "reference" pointing at the doc instead of copying it.
86
86
  Long-form notes are fine ONLY when the user explicitly asks to save one.
87
87
  - Before asking the user about past decisions, conventions, or preferences they
88
- may have told you before, call memory_search first — try a few keyword
89
- variants (including the user's own language) when the first search comes up
90
- empty.
88
+ may have told you before, call memory_search first — search well: break the
89
+ question into its concepts and try 2–3 phrasings per concept (synonyms, the
90
+ user's other language, shorter keyword forms), and check the other scope too,
91
+ before concluding nothing is stored.
91
92
  - Memories default to this project's scope; use the personal scope for facts
92
93
  about the user that hold across all projects. When a saved fact becomes
93
94
  outdated, call memory_supersede (find the old memory's id with memory_search
@@ -141,9 +142,15 @@ export async function runMcpServer() {
141
142
  // D53: session-start outbox state, pushed. The stdio server starts fresh per
142
143
  // session, so construction-time state ≈ session-start state.
143
144
  const n = outboxDraftCount(scope.key);
144
- const instructions = n > 0
145
+ // D61: the alias guidance rides the handshake only when the install
146
+ // enables capture aliases — otherwise agents would attach aliases the
147
+ // server then drops.
148
+ const aliasGuidance = cfg.captureAliases
149
+ ? "\n- When you save a memory, attach 2–4 aliases via memory_add's aliases parameter: alternate phrasings, synonyms, equivalents in the user's other language. They are indexed with the memory so a differently-worded question still finds it."
150
+ : "";
151
+ const instructions = (n > 0
145
152
  ? `${SERVER_INSTRUCTIONS}\n\nSession start: the project outbox has ${n} draft${n === 1 ? "" : "s"} waiting for review — call memory_status to see them.`
146
- : SERVER_INSTRUCTIONS;
153
+ : SERVER_INSTRUCTIONS) + aliasGuidance;
147
154
  const server = new McpServer({ name: "open-memex", version: SERVER_VERSION }, { instructions });
148
155
  server.registerTool("memory_add", {
149
156
  description: TOOL_DESCRIPTIONS.memory_add,
@@ -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,13 +1,22 @@
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,
@@ -124,7 +133,7 @@ export function search(query, opts = {}) {
124
133
  : "";
125
134
  const typeFilter = opts.type ? ` AND m.type = ?` : "";
126
135
  const sql = `
127
- SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
136
+ SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
128
137
  m.status, m.superseded_by, m.review_state,
129
138
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
130
139
  bm25(memories_fts) AS score
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;
@@ -171,6 +171,8 @@ 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,
@@ -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. */
@@ -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);
package/dist/tools/ops.js CHANGED
@@ -10,7 +10,7 @@ import fs from "node:fs";
10
10
  import { z } from "zod";
11
11
  import { PERSONAL_SCOPE } from "../scope.js";
12
12
  import { search, list, hitStateLabel } from "../retrieve/search.js";
13
- import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
13
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, normalizeAliases, } from "../store/markdown.js";
14
14
  import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
15
15
  import { findDuplicates, supersede } from "../store/lifecycle.js";
16
16
  import { db } from "../store/db.js";
@@ -35,8 +35,8 @@ export async function withOutboxNote(scopeKey, p, reviewHint) {
35
35
  }
36
36
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
37
37
  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.",
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. 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.",
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. 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.",
40
40
  memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
41
41
  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
42
  memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
@@ -52,6 +52,10 @@ export const scopeArg = z
52
52
  .enum(["project", "personal", "user"])
53
53
  .optional()
54
54
  .describe("Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.");
55
+ export const aliasesArg = z
56
+ .array(z.string())
57
+ .optional()
58
+ .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
59
  export const memoryAddArgs = {
56
60
  content: z.string().min(1).describe("The fact to remember. One idea per memory."),
57
61
  type: z
@@ -60,6 +64,7 @@ export const memoryAddArgs = {
60
64
  .describe("Category of memory. Default: fact."),
61
65
  scope: scopeArg,
62
66
  tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
67
+ aliases: aliasesArg,
63
68
  source: z
64
69
  .string()
65
70
  .optional()
@@ -90,6 +95,7 @@ export const memorySupersedeArgs = {
90
95
  .optional()
91
96
  .describe("Category of the new memory. Defaults to the old memory's type."),
92
97
  tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
98
+ aliases: aliasesArg,
93
99
  };
94
100
  export const memoryForgetArgs = {
95
101
  id: z.string().min(1),
@@ -149,6 +155,7 @@ function buildFrontmatter(s, body) {
149
155
  importance: "normal",
150
156
  status: "active",
151
157
  tags: body.tags,
158
+ ...(body.aliases && body.aliases.length > 0 ? { aliases: body.aliases } : {}),
152
159
  source: body.source,
153
160
  created_at: rfc,
154
161
  updated_at: rfc,
@@ -180,6 +187,13 @@ export async function addMemory(getScope, cfg, args) {
180
187
  const fm = buildFrontmatter(s, {
181
188
  type: args.type ?? "fact",
182
189
  tags: args.tags ?? [],
190
+ // D61: aliases are stored only when the install enables them (init
191
+ // asks once, default on); normalize caps at 4 and drops junk silently.
192
+ // Aliases pass through the same redaction as content (write-path
193
+ // invariant) — an alias carrying a token gets masked, not stored raw.
194
+ aliases: cfg.captureAliases
195
+ ? normalizeAliases((args.aliases ?? []).map((a) => redact(a, cfg.redactPatterns).content))
196
+ : [],
183
197
  source: args.source ?? "tool",
184
198
  });
185
199
  const { filePath } = writeMemoryFile(fm, redacted);
@@ -230,6 +244,11 @@ export async function supersedeMemory(cfg, args) {
230
244
  body: redacted,
231
245
  type: args.type,
232
246
  tags: args.tags,
247
+ // D61: explicit aliases replace the carried-over ones, only when
248
+ // the install enables capture aliases. Same redaction as content.
249
+ aliases: cfg.captureAliases && args.aliases !== undefined
250
+ ? normalizeAliases(args.aliases.map((a) => redact(a, cfg.redactPatterns).content))
251
+ : undefined,
233
252
  source: "tool",
234
253
  });
235
254
  upsertFromFile(oldMf);
package/docs/V2-DESIGN.md CHANGED
@@ -1250,6 +1250,60 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1250
1250
  name entries installed via `opencode plugin add` are recorded but not
1251
1251
  path-checked). Field-verified on real hosts 1.18.34 and 2.0.1.
1252
1252
 
1253
+ - **D61 — proactive capture cluster: propose posture, a shared
1254
+ memorability rubric, and capture-time aliasing (retrieval layer 3).**
1255
+ Builds on the "proactive capture" open question below and the
1256
+ CHECKPOINT_PROACTIVITY / MEMORY_VISIBILITY notes. Three pieces,
1257
+ shipped together because they are one user-visible behavior — the
1258
+ agent capturing well without being asked each time:
1259
+ (1) **Posture: propose, not auto-draft.** When the agent finishes a
1260
+ task the user would describe in one sentence, it proposes 1–3 short
1261
+ candidate memories in the conversation and saves only what the user
1262
+ approves (approved inferences are saved with `source: "inference"`,
1263
+ the §3.5 convention). The guidance is text, not mechanism — the same
1264
+ rubric written into the bundled skill, the AGENTS.md memory-hygiene
1265
+ footer, the MCP handshake instructions, the `memory_add` tool
1266
+ description, and a one-line summary in the injected context block —
1267
+ with throttling rules (task end / decision / gotcha moments only, no
1268
+ mid-task interruptions, never re-propose a declined candidate). The
1269
+ auto-draft posture (write first, surface later in bulk) is deferred
1270
+ until memory visibility ships: without an inventory the user can
1271
+ read, silent drafting reads as surveillance, so D61 deliberately
1272
+ does not build it.
1273
+ (2) **Rubric.** One memorability standard, shared by this live
1274
+ capture path and the future retrospective-distillation group:
1275
+ worth saving — decisions and their reasons, preferences,
1276
+ conventions, gotchas, approaches tried and abandoned, and
1277
+ `reference` memories pointing at authoritative docs; not worth
1278
+ saving — one-off task details and anything re-derivable from the
1279
+ code. Retrieval robustness layer 2 (agent-side query expansion:
1280
+ decompose into concepts, 2–3 phrasings each, cross-language, check
1281
+ the other scope) ships as guidance in the same texts, on top of the
1282
+ D56 server-side layer 1.
1283
+ (3) **Capture-time aliases (layer 3).** When a memory is saved, the
1284
+ agent attaches up to 4 aliases — alternate phrasings, synonyms,
1285
+ equivalents in the user's other language — stored in a new optional
1286
+ frontmatter field `aliases` (additive; files without it parse
1287
+ unchanged, `schema_version` stays 2) and indexed in their own FTS
1288
+ column (index schema 6→7, rebuilt from markdown automatically).
1289
+ Gated by a new `captureAliases` config flag (init asks once, default
1290
+ on, `config set captureAliases off` opts out): when off, aliases
1291
+ passed by agents are dropped and the alias guidance disappears from
1292
+ the MCP handshake. Turning it off never rewrites existing memory
1293
+ files — aliases already stored stay stored and searchable, and
1294
+ supersede keeps carrying them forward; the flag gates new captures
1295
+ only (no silent data destruction). Aliases pass through the same
1296
+ secret redaction as memory content before they are stored, and a
1297
+ malformed alias list (blanks, more than 4) is normalized silently —
1298
+ never a tool-call error.
1299
+ Keyword capture (no agent in the loop) leaves
1300
+ aliases empty rather than inventing them; `supersede` carries
1301
+ aliases forward unless the caller replaces them. Aliases are
1302
+ normalized (trim, case-insensitive dedupe, cap 4) and bad ones cost
1303
+ nothing — they rank as bm25 noise, never an error. Retrospective
1304
+ distillation over host transcripts stays its own group; it will
1305
+ reuse this rubric.
1306
+
1253
1307
  ## Open Questions
1254
1308
 
1255
1309
  _All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.0-alpha.1",
3
+ "version": "0.7.0-alpha.2",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  // Quick smoke test — runs the pure-logic modules (no bun:sqlite dependency).
2
2
  // Usage: node --experimental-strip-types scripts\smoke-pure.ts
3
- import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, type Frontmatter } from "../src/store/markdown.ts";
3
+ import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, normalizeAliases, type Frontmatter } from "../src/store/markdown.ts";
4
4
  import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
6
  import { detectKeywords } from "../src/capture/keywords.ts";
@@ -10,6 +10,8 @@ import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
11
11
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
12
12
  import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
13
+ import { z } from "zod";
14
+ import { memoryAddArgs } from "../src/tools/ops.ts";
13
15
 
14
16
  let fails = 0;
15
17
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -57,6 +59,28 @@ ok("id survives", parsed?.fm.id === a);
57
59
  ok("tags survive", JSON.stringify(parsed?.fm.tags) === '["hello","world"]');
58
60
  ok("body survives", parsed?.body.trim() === body);
59
61
 
62
+ console.log("== capture aliases (D61) ==");
63
+ ok("normalize trims/dedupes/caps at 4",
64
+ JSON.stringify(normalizeAliases([" Time Off ", "time off", "", "节假日", "vacation", "PTO", "leave"])) === '["Time Off","节假日","vacation","PTO"]');
65
+ ok("normalize of undefined is empty", JSON.stringify(normalizeAliases(undefined)) === "[]");
66
+ const fmA: Frontmatter = { ...fm, id: ulid(), aliases: ["time off", "节假日", "vacation days"] };
67
+ const parsedA = parse(serialize(fmA, "body text"));
68
+ ok("aliases survive round-trip",
69
+ JSON.stringify(parsedA?.fm.aliases) === '["time off","节假日","vacation days"]');
70
+ ok("no aliases -> field omitted from file",
71
+ !/^aliases:/m.test(serialize(fm, "x")));
72
+ ok("empty aliases array -> field omitted",
73
+ !/^aliases:/m.test(serialize({ ...fm, aliases: [] }, "x")));
74
+ ok("parse caps hand-edited aliases at 4",
75
+ (normalizeFrontmatter({ id: "x", aliases: ["1", "2", "3", "4", "5"] } as never).aliases ?? []).length === 4);
76
+ {
77
+ // D61 review: the tool schema must never reject a sloppy alias list —
78
+ // normalization decides, not validation (bad aliases are noise).
79
+ const shape = z.object(memoryAddArgs);
80
+ const sloppy = shape.safeParse({ content: "x", aliases: ["a", "", "b", "c", "d", "e"] });
81
+ ok("tool schema accepts >4 / blank aliases (normalize drops them)", sloppy.success);
82
+ }
83
+
60
84
  console.log("== redact ==");
61
85
  const r1 = redact("api key is <private>sk-supersecretkey</private> ok", DEFAULT_CONFIG.redactPatterns);
62
86
  ok("private stripped", !r1.content.includes("sk-supersecretkey"));
@@ -133,6 +133,34 @@ ok("list --type filter", r.out.includes(idDecision) && !r.out.includes(idFact));
133
133
  r = cli(["search", "ff merges"], PROJ);
134
134
  ok("search finds decision", r.out.includes(idDecision), r.out.slice(0, 150));
135
135
 
136
+ // D61: capture-time aliases — a differently-worded query still hits.
137
+ const idAlias = addMem(["fulltest holiday policy lives in the handbook", "--aliases", "time off;vacation days"], PROJ);
138
+ r = cli(["search", "vacation"], PROJ);
139
+ ok("search hits via alias-only term", r.out.includes(idAlias), r.out.slice(0, 150));
140
+ ok("alias-only hit explains itself (aka)", /aka:/i.test(r.out), r.out.slice(0, 150));
141
+
142
+ // D61 review: aliases go through the same redaction as content. (The
143
+ // secret is assembled at runtime so no credential-shaped literal sits
144
+ // in this file.)
145
+ const fakePat = "ghp_" + "Ab3".repeat(12);
146
+ const idSecretAlias = addMem(["fulltest alias redaction target", "--aliases", `${fakePat}; benign alias`], PROJ);
147
+ r = cli(["search", "benign"], PROJ);
148
+ ok("benign alias still searchable", r.out.includes(idSecretAlias), r.out.slice(0, 150));
149
+ {
150
+ const dir = path.join(TESTENV.MY_O_MEMORY_HOME, "memories");
151
+ const stack = [dir];
152
+ let hit = "";
153
+ while (stack.length && !hit) {
154
+ const d = stack.pop()!;
155
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
156
+ const p = path.join(d, e.name);
157
+ if (e.isDirectory()) stack.push(p);
158
+ else if (p.endsWith(`${idSecretAlias}.md`)) hit = fs.readFileSync(p, "utf8");
159
+ }
160
+ }
161
+ ok("secret-looking alias is masked in the stored file", !!hit && !hit.includes(fakePat), hit.slice(0, 200));
162
+ }
163
+
136
164
  // ---------- 3. supersede / status / forget ----------
137
165
  console.log("== supersede / status / forget ==");
138
166
  r = cli(["supersede", idFact, "fulltest fact about deploys v2"], PROJ);
@@ -25,6 +25,32 @@ open-memex add "I prefer concise diffs" --scope personal # applies everywhere
25
25
  Keep each memory one self-contained statement. Scope routing: facts about the
26
26
  user ("I"/"me") → `personal`; everything else → the current project.
27
27
 
28
+ Worth saving: decisions and their reasons, preferences, conventions, gotchas,
29
+ approaches tried and abandoned. Not worth saving: one-off task details or
30
+ anything re-derivable from the code.
31
+
32
+ Attach alternate phrasings when saving — synonyms, another way the question
33
+ might be worded, equivalents in the user's other language — so reworded
34
+ questions still find the memory:
35
+
36
+ ```sh
37
+ open-memex add "Public holidays are listed in the HR portal" --aliases "holidays;time off;节假日"
38
+ ```
39
+
40
+ (Aliases are stored only when the install has capture aliases enabled — the
41
+ init default.)
42
+
43
+ ## At task checkpoints — propose, don't just save
44
+
45
+ For conclusions you infer yourself (the user never stated them): when you
46
+ finish a task the user would describe in one sentence, propose 1–3 short
47
+ memories capturing the useful conclusion (what was learned or decided, how an
48
+ issue was resolved, what to avoid, where the authoritative doc lives — not the
49
+ raw transcript). Save nothing the user did not approve; on approval, save it —
50
+ with the `memory_add` tool pass `source: "inference"` so the capture is marked
51
+ as agent-proposed. Don't interrupt mid-task, and don't re-propose something
52
+ the user already declined.
53
+
28
54
  ## Recall
29
55
 
30
56
  ```sh
@@ -33,7 +59,10 @@ open-memex list # recent memories in the current project
33
59
  ```
34
60
 
35
61
  Search before asking the user about past decisions or preferences they may
36
- have told you before.
62
+ have told you before. Search well: break the question into its concepts and
63
+ try 2–3 phrasings per concept — synonyms, the user's other language, shorter
64
+ keyword forms — and check the other scope too, before concluding nothing is
65
+ stored.
37
66
 
38
67
  ## Project outbox → repo (the sync flow)
39
68
 
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  readMemoryFile,
16
16
  ulid,
17
17
  msToRfc3339,
18
+ normalizeAliases,
18
19
  type Frontmatter,
19
20
  } from "./store/markdown.ts";
20
21
  import { loadConfig } from "./config.ts";
@@ -64,19 +65,26 @@ Example:
64
65
 
65
66
  add: `Save a fact, preference, decision, or note to local memory.
66
67
 
67
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
68
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
68
69
 
69
70
  Flags:
70
- --scope project (default) or personal (personal never leaves this machine)
71
- --type memory type (default: fact)
72
- --tag comma-separated tags
71
+ --scope project (default) or personal (personal never leaves this machine)
72
+ --type memory type (default: fact)
73
+ --tag comma-separated tags
74
+ --aliases alternate phrasings of this fact (up to 4), indexed with the
75
+ memory so differently-worded searches still match; separate
76
+ with ; (commas also accepted — use ; when an alias itself
77
+ contains one). Stored only when capture aliases are enabled
78
+ (init default)
73
79
 
74
80
  Example:
75
81
  open-memex add "We deploy on Fridays" --scope project --tag process`,
76
82
 
77
83
  supersede: `Replace a memory with a newer version. The old one is kept as history.
78
84
 
79
- Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
85
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
86
+
87
+ Without --aliases, the old memory's aliases carry over.
80
88
 
81
89
  Example:
82
90
  open-memex supersede 01ABC "We deploy on Thursdays now"`,
@@ -943,6 +951,20 @@ async function main() {
943
951
  .map((t) => t.trim())
944
952
  .filter(Boolean)
945
953
  : [],
954
+ // D61: --aliases "phrase one; phrase two" (comma also accepted; use
955
+ // ; when an alias itself contains a comma). A bare --aliases with no
956
+ // value makes parseFlags yield "true" — treat that as absent. Stored
957
+ // only when the install enables capture aliases, redacted like content.
958
+ ...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
959
+ ? {
960
+ aliases: normalizeAliases(
961
+ flags.aliases
962
+ .split(/[;,]/)
963
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
964
+ .filter(Boolean),
965
+ ),
966
+ }
967
+ : {}),
946
968
  source: "cli",
947
969
  created_at: rfc,
948
970
  updated_at: rfc,
@@ -985,6 +1007,15 @@ async function main() {
985
1007
  tags: flags.tag
986
1008
  ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
987
1009
  : undefined,
1010
+ aliases:
1011
+ typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
1012
+ ? normalizeAliases(
1013
+ flags.aliases
1014
+ .split(/[;,]/)
1015
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
1016
+ .filter(Boolean),
1017
+ )
1018
+ : undefined,
988
1019
  source: "cli",
989
1020
  });
990
1021
  upsertFromFile(oldMf);
package/src/config.ts CHANGED
@@ -7,6 +7,14 @@ export interface MyOMemoryConfig {
7
7
  maxProfileItems: number;
8
8
  injectOnFirstTurn: boolean;
9
9
  keywordCaptureEnabled: boolean;
10
+ /**
11
+ * D61: capture-time aliasing (retrieval layer 3). When on, agents are
12
+ * asked to attach 2–4 aliases (alternate phrasings, other-language
13
+ * equivalents) to each memory they save; ops stores them and the index
14
+ * searches them, so differently-worded questions still hit. Asked once
15
+ * at init, default on; off = aliases passed by agents are dropped.
16
+ */
17
+ captureAliases: boolean;
10
18
  /** Patterns whose capture group 1 becomes the memory body; saved to the current scope. */
11
19
  keywordPatterns: string[];
12
20
  /** Same shape, but hits are forced into the personal scope (e.g. "remember for me"). */
@@ -34,6 +42,7 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
34
42
  maxProfileItems: 5,
35
43
  injectOnFirstTurn: true,
36
44
  keywordCaptureEnabled: true,
45
+ captureAliases: true,
37
46
  keywordPatterns: [
38
47
  "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
39
48
  "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
@@ -110,6 +119,7 @@ export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
110
119
  maxProfileItems: toNonNegInt,
111
120
  injectOnFirstTurn: toBool,
112
121
  keywordCaptureEnabled: toBool,
122
+ captureAliases: toBool,
113
123
  memoryDir: toRelativeDir,
114
124
  "sync.autoPull": toBool,
115
125
  logLevel: (v) => {
@@ -78,8 +78,14 @@ export function distillAgentsMarkdown(opts: DistillAgentsOptions): string {
78
78
  ` conclusion — what was learned or decided, how an issue was resolved, what`,
79
79
  ` to avoid, where the authoritative doc lives — not the raw transcript.`,
80
80
  ` Save nothing without user approval.`,
81
+ ` Worth keeping: decisions and their reasons, preferences, conventions,`,
82
+ ` gotchas, approaches tried and abandoned. Not worth keeping: one-off task`,
83
+ ` details or anything re-derivable from the code.`,
81
84
  `- If the knowledge already lives in project docs, save a \`reference\` memory`,
82
85
  ` pointing at the doc instead of copying it.`,
86
+ `- When saving via \`memory_add\`, attach 2–4 aliases (alternate phrasings,`,
87
+ ` synonyms, equivalents in the user's other language) when the install has`,
88
+ ` capture aliases enabled — they make reworded questions find the memory.`,
83
89
  );
84
90
  lines.push(``);
85
91
  return lines.join("\n");
package/src/init.ts CHANGED
@@ -894,6 +894,14 @@ export async function initProject(opts: {
894
894
  );
895
895
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
896
896
  patch.injectOnFirstTurn = injectOnFirstTurn;
897
+ // D61: capture-time aliases — agents attach alternate phrasings to each
898
+ // memory so differently-worded questions still find it.
899
+ const captureAliases = await askBool(
900
+ "Attach alternate phrasings (aliases) to memories so reworded questions still find them?",
901
+ DEFAULT_CONFIG.captureAliases,
902
+ );
903
+ if (captureAliases !== DEFAULT_CONFIG.captureAliases)
904
+ patch.captureAliases = captureAliases;
897
905
  if (Object.keys(patch).length > 0) {
898
906
  const file = saveConfig(patch);
899
907
  console.log(
package/src/mcp.ts CHANGED
@@ -112,9 +112,10 @@ memory_resolve, memory_pr_status — always call them by these full names.
112
112
  docs, save it as type "reference" pointing at the doc instead of copying it.
113
113
  Long-form notes are fine ONLY when the user explicitly asks to save one.
114
114
  - Before asking the user about past decisions, conventions, or preferences they
115
- may have told you before, call memory_search first — try a few keyword
116
- variants (including the user's own language) when the first search comes up
117
- empty.
115
+ may have told you before, call memory_search first — search well: break the
116
+ question into its concepts and try 2–3 phrasings per concept (synonyms, the
117
+ user's other language, shorter keyword forms), and check the other scope too,
118
+ before concluding nothing is stored.
118
119
  - Memories default to this project's scope; use the personal scope for facts
119
120
  about the user that hold across all projects. When a saved fact becomes
120
121
  outdated, call memory_supersede (find the old memory's id with memory_search
@@ -178,10 +179,16 @@ export async function runMcpServer() {
178
179
  // D53: session-start outbox state, pushed. The stdio server starts fresh per
179
180
  // session, so construction-time state ≈ session-start state.
180
181
  const n = outboxDraftCount(scope.key);
182
+ // D61: the alias guidance rides the handshake only when the install
183
+ // enables capture aliases — otherwise agents would attach aliases the
184
+ // server then drops.
185
+ const aliasGuidance = cfg.captureAliases
186
+ ? "\n- When you save a memory, attach 2–4 aliases via memory_add's aliases parameter: alternate phrasings, synonyms, equivalents in the user's other language. They are indexed with the memory so a differently-worded question still finds it."
187
+ : "";
181
188
  const instructions =
182
- n > 0
189
+ (n > 0
183
190
  ? `${SERVER_INSTRUCTIONS}\n\nSession start: the project outbox has ${n} draft${n === 1 ? "" : "s"} waiting for review — call memory_status to see them.`
184
- : SERVER_INSTRUCTIONS;
191
+ : SERVER_INSTRUCTIONS) + aliasGuidance;
185
192
 
186
193
  const server = new McpServer(
187
194
  { name: "open-memex", version: SERVER_VERSION },
@@ -28,7 +28,7 @@ export function buildContextBlock(scope: Scope, cfg: MyOMemoryConfig): string |
28
28
 
29
29
  lines.push(
30
30
  "",
31
- "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.",
31
+ "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.",
32
32
  );
33
33
 
34
34
  return lines.join("\n");
@@ -7,6 +7,8 @@ export interface SearchHit {
7
7
  project_name: string;
8
8
  type: string;
9
9
  tags: string[];
10
+ /** D61: capture-time aliases stored on this memory (search hits only). */
11
+ aliases: string[];
10
12
  snippet: string;
11
13
  score: number;
12
14
  updated_at: number;
@@ -20,6 +22,7 @@ interface RawRow {
20
22
  project_name: string;
21
23
  type: string;
22
24
  tags: string;
25
+ aliases?: string;
23
26
  updated_at: number;
24
27
  snippet: string;
25
28
  score: number;
@@ -29,13 +32,22 @@ interface RawRow {
29
32
  }
30
33
 
31
34
  function toHit(r: RawRow): SearchHit {
35
+ const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : [];
36
+ let snippet = r.snippet ?? "";
37
+ // D61: the snippet highlights matches in the body column only. When the
38
+ // match came through an alias (body shows no highlight), say so — the
39
+ // hit otherwise looks unrelated to the query.
40
+ if (aliases.length > 0 && !/\[[^\]]+\]/.test(snippet)) {
41
+ snippet += ` (aka: ${aliases.join(", ")})`;
42
+ }
32
43
  return {
33
44
  id: r.id,
34
45
  scope_key: r.scope_key,
35
46
  project_name: r.project_name,
36
47
  type: r.type,
37
48
  tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
38
- snippet: r.snippet ?? "",
49
+ aliases,
50
+ snippet,
39
51
  score: r.score,
40
52
  updated_at: r.updated_at,
41
53
  status: r.status,
@@ -161,7 +173,7 @@ export function search(
161
173
  const typeFilter = opts.type ? ` AND m.type = ?` : "";
162
174
 
163
175
  const sql = `
164
- SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
176
+ SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
165
177
  m.status, m.superseded_by, m.review_state,
166
178
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
167
179
  bm25(memories_fts) AS score
package/src/store/db.ts CHANGED
@@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS memories (
35
35
  importance TEXT NOT NULL DEFAULT 'normal',
36
36
  status TEXT NOT NULL DEFAULT 'active',
37
37
  tags TEXT NOT NULL DEFAULT '',
38
+ aliases TEXT NOT NULL DEFAULT '',
38
39
  content TEXT NOT NULL,
39
40
  cjk TEXT NOT NULL DEFAULT '',
40
41
  content_hash TEXT NOT NULL DEFAULT '',
@@ -62,6 +63,7 @@ const FTS_SCHEMA = `
62
63
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
63
64
  content,
64
65
  tags,
66
+ aliases,
65
67
  type,
66
68
  cjk,
67
69
  scope_key UNINDEXED,
@@ -71,25 +73,25 @@ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
71
73
  );
72
74
 
73
75
  CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
74
- INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
75
- VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
76
+ INSERT INTO memories_fts(rowid, content, tags, aliases, type, cjk, scope_key)
77
+ VALUES (new.rowid, new.content, new.tags, new.aliases, new.type, new.cjk, new.scope_key);
76
78
  END;
77
79
 
78
80
  CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
79
- INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
80
- VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
81
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, aliases, type, cjk, scope_key)
82
+ VALUES ('delete', old.rowid, old.content, old.tags, old.aliases, old.type, old.cjk, old.scope_key);
81
83
  END;
82
84
 
83
85
  CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
84
- INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
85
- VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
86
- INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
87
- VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
86
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, aliases, type, cjk, scope_key)
87
+ VALUES ('delete', old.rowid, old.content, old.tags, old.aliases, old.type, old.cjk, old.scope_key);
88
+ INSERT INTO memories_fts(rowid, content, tags, aliases, type, cjk, scope_key)
89
+ VALUES (new.rowid, new.content, new.tags, new.aliases, new.type, new.cjk, new.scope_key);
88
90
  END;
89
91
  `;
90
92
 
91
93
  /** Current index schema version. Bump when TABLE_SCHEMA/FTS_SCHEMA change. */
92
- const SCHEMA_VERSION = 6;
94
+ const SCHEMA_VERSION = 7;
93
95
 
94
96
  function userVersion(d: AnyDatabase): number {
95
97
  const row = d.prepare("PRAGMA user_version").get() as { user_version: number };
@@ -206,6 +206,7 @@ export interface SupersedeInput {
206
206
  body: string;
207
207
  type?: string;
208
208
  tags?: string[];
209
+ aliases?: string[];
209
210
  source?: string;
210
211
  }
211
212
 
@@ -232,6 +233,8 @@ export function supersede(
232
233
  id: ulid(),
233
234
  type: input.type ?? oldMf.fm.type,
234
235
  tags: input.tags ?? oldMf.fm.tags,
236
+ // D61: aliases carry forward unless the caller replaces them.
237
+ aliases: input.aliases ?? oldMf.fm.aliases,
235
238
  source: input.source ?? oldMf.fm.source,
236
239
  status: "active",
237
240
  created_at: now,
@@ -53,6 +53,13 @@ export interface Frontmatter {
53
53
  importance: Importance;
54
54
  status: MemoryStatus;
55
55
  tags: string[];
56
+ /**
57
+ * D61: capture-time aliases — alternate phrasings / other-language
58
+ * equivalents of this memory, indexed alongside the body so a
59
+ * differently-worded question still matches (retrieval layer 3).
60
+ * Optional and additive: files without it parse unchanged.
61
+ */
62
+ aliases?: string[];
56
63
  source: string;
57
64
  created_at: string; // RFC 3339, never bare epoch (§3)
58
65
  updated_at: string; // RFC 3339
@@ -232,6 +239,14 @@ export function normalizeFrontmatter(
232
239
  tags: Array.isArray(raw.tags)
233
240
  ? raw.tags.filter((t): t is string => typeof t === "string")
234
241
  : [],
242
+ ...(Array.isArray(raw.aliases)
243
+ ? (() => {
244
+ const aliases = normalizeAliases(
245
+ raw.aliases.filter((a): a is string => typeof a === "string"),
246
+ );
247
+ return aliases.length > 0 ? { aliases } : {};
248
+ })()
249
+ : {}),
235
250
  source: typeof raw.source === "string" ? raw.source : "",
236
251
  created_at: msToRfc3339(timeToMs(raw.created_at)),
237
252
  updated_at: msToRfc3339(timeToMs(raw.updated_at)),
@@ -254,12 +269,41 @@ export function normalizeFrontmatter(
254
269
  };
255
270
  }
256
271
 
272
+ /**
273
+ * D61: normalize capture-time aliases — trim, drop empties, dedupe
274
+ * (case-insensitive), cap at 4. Bad aliases are noise, never an error.
275
+ * Shared by the tool ops, the CLI, and frontmatter parsing.
276
+ */
277
+ export function normalizeAliases(input?: string[]): string[] {
278
+ if (!input) return [];
279
+ const seen = new Set<string>();
280
+ const out: string[] = [];
281
+ for (const a of input) {
282
+ const t = a.trim();
283
+ if (!t) continue;
284
+ const k = t.toLowerCase();
285
+ if (seen.has(k)) continue;
286
+ seen.add(k);
287
+ out.push(t);
288
+ if (out.length >= 4) break;
289
+ }
290
+ return out;
291
+ }
292
+
257
293
  export function isTaxonomyType(t: string): boolean {
258
294
  return TAXONOMY.has(t);
259
295
  }
260
296
 
261
297
  export function serialize(fm: Frontmatter, body: string): string {
262
- const yml = yaml.dump(fm, { lineWidth: -1, quotingType: '"' });
298
+ // D61: an empty aliases list is noise in the file — drop the key entirely
299
+ // rather than relying on the YAML dumper to skip an undefined value
300
+ // (parse only sets the field when non-empty; keep both ends consistent).
301
+ let out: Frontmatter = fm;
302
+ if (fm.aliases && fm.aliases.length === 0) {
303
+ out = { ...fm };
304
+ delete out.aliases;
305
+ }
306
+ const yml = yaml.dump(out, { lineWidth: -1, quotingType: '"' });
263
307
  return `---\n${yml}---\n\n${body.trimEnd()}\n`;
264
308
  }
265
309
 
package/src/store/sync.ts CHANGED
@@ -14,8 +14,8 @@ import { projectRoot, paths } from "../paths.ts";
14
14
  import { loadConfig } from "../config.ts";
15
15
 
16
16
  const UPSERT_SQL = `
17
- 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)
18
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
17
+ 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)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
19
19
  ON CONFLICT(id) DO UPDATE SET
20
20
  scope_key = excluded.scope_key,
21
21
  scope = excluded.scope,
@@ -26,6 +26,7 @@ const UPSERT_SQL = `
26
26
  importance = excluded.importance,
27
27
  status = excluded.status,
28
28
  tags = excluded.tags,
29
+ aliases = excluded.aliases,
29
30
  content = excluded.content,
30
31
  cjk = excluded.cjk,
31
32
  content_hash = excluded.content_hash,
@@ -62,6 +63,7 @@ export function upsertFromFile(mf: MemoryFile): void {
62
63
  function writeRow(mf: MemoryFile, mtimeMs: number): void {
63
64
  const { fm, body, filePath } = mf;
64
65
  const tags = (fm.tags ?? []).join(",");
66
+ const aliases = (fm.aliases ?? []).join(",");
65
67
  db().prepare(UPSERT_SQL).run(
66
68
  fm.id,
67
69
  fm.scope_key,
@@ -73,8 +75,9 @@ function writeRow(mf: MemoryFile, mtimeMs: number): void {
73
75
  fm.importance,
74
76
  fm.status,
75
77
  tags,
78
+ aliases,
76
79
  body,
77
- cjkIndexText(body + "\n" + tags),
80
+ cjkIndexText(body + "\n" + tags + "\n" + aliases),
78
81
  contentHash(body),
79
82
  fm.superseded_by ?? null,
80
83
  fm.source ?? "",
package/src/tools/ops.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  ulid,
19
19
  msToRfc3339,
20
20
  MEMORY_TYPE_TAXONOMY,
21
+ normalizeAliases,
21
22
  type Frontmatter,
22
23
  } from "../store/markdown.ts";
23
24
  import { upsertFromFile, deleteFromIndex } from "../store/sync.ts";
@@ -62,9 +63,9 @@ export async function withOutboxNote(
62
63
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
63
64
  export const TOOL_DESCRIPTIONS = {
64
65
  memory_add:
65
- "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.",
66
+ "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.",
66
67
  memory_search:
67
- "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.",
68
+ "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.",
68
69
  memory_list:
69
70
  "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
70
71
  memory_supersede:
@@ -92,6 +93,13 @@ export const scopeArg = z
92
93
  "Memory scope. `project` = tied to this repo. `personal` = global across all your projects. `user` is a deprecated alias of `personal`. Default: project.",
93
94
  );
94
95
 
96
+ export const aliasesArg = z
97
+ .array(z.string())
98
+ .optional()
99
+ .describe(
100
+ "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.",
101
+ );
102
+
95
103
  export const memoryAddArgs = {
96
104
  content: z.string().min(1).describe("The fact to remember. One idea per memory."),
97
105
  type: z
@@ -100,6 +108,7 @@ export const memoryAddArgs = {
100
108
  .describe("Category of memory. Default: fact."),
101
109
  scope: scopeArg,
102
110
  tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
111
+ aliases: aliasesArg,
103
112
  source: z
104
113
  .string()
105
114
  .optional()
@@ -138,6 +147,7 @@ export const memorySupersedeArgs = {
138
147
  .optional()
139
148
  .describe("Category of the new memory. Defaults to the old memory's type."),
140
149
  tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
150
+ aliases: aliasesArg,
141
151
  };
142
152
  export type MemorySupersedeArgs = z.infer<z.ZodObject<typeof memorySupersedeArgs>>;
143
153
 
@@ -210,6 +220,7 @@ function buildFrontmatter(
210
220
  body: {
211
221
  type: Frontmatter["type"];
212
222
  tags: string[];
223
+ aliases?: string[];
213
224
  source: Frontmatter["source"];
214
225
  },
215
226
  ): Frontmatter {
@@ -226,6 +237,7 @@ function buildFrontmatter(
226
237
  importance: "normal",
227
238
  status: "active",
228
239
  tags: body.tags,
240
+ ...(body.aliases && body.aliases.length > 0 ? { aliases: body.aliases } : {}),
229
241
  source: body.source,
230
242
  created_at: rfc,
231
243
  updated_at: rfc,
@@ -265,6 +277,15 @@ export async function addMemory(
265
277
  const fm = buildFrontmatter(s, {
266
278
  type: args.type ?? "fact",
267
279
  tags: args.tags ?? [],
280
+ // D61: aliases are stored only when the install enables them (init
281
+ // asks once, default on); normalize caps at 4 and drops junk silently.
282
+ // Aliases pass through the same redaction as content (write-path
283
+ // invariant) — an alias carrying a token gets masked, not stored raw.
284
+ aliases: cfg.captureAliases
285
+ ? normalizeAliases(
286
+ (args.aliases ?? []).map((a) => redact(a, cfg.redactPatterns).content),
287
+ )
288
+ : [],
268
289
  source: args.source ?? "tool",
269
290
  });
270
291
  const { filePath } = writeMemoryFile(fm, redacted);
@@ -335,6 +356,14 @@ export async function supersedeMemory(
335
356
  body: redacted,
336
357
  type: args.type,
337
358
  tags: args.tags,
359
+ // D61: explicit aliases replace the carried-over ones, only when
360
+ // the install enables capture aliases. Same redaction as content.
361
+ aliases:
362
+ cfg.captureAliases && args.aliases !== undefined
363
+ ? normalizeAliases(
364
+ args.aliases.map((a) => redact(a, cfg.redactPatterns).content),
365
+ )
366
+ : undefined,
338
367
  source: "tool",
339
368
  });
340
369
  upsertFromFile(oldMf);