open-memex 0.6.2 → 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/dist/init.js CHANGED
@@ -271,6 +271,15 @@ function opencodePluginUrl() {
271
271
  const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
272
272
  return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
273
273
  }
274
+ /**
275
+ * D60: the same plugin directory, in the form OpenCode 2 wants. v2 rejects
276
+ * file entries in `plugins` ("configured plugin path must be a directory")
277
+ * and loads `<dir>/index.ts` instead.
278
+ */
279
+ function opencodePluginDir() {
280
+ const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
281
+ return path.join(pkgRoot, "src");
282
+ }
274
283
  /**
275
284
  * D46: opencode's user-level config. opencode reads `~/.config/opencode/`
276
285
  * (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
@@ -297,6 +306,63 @@ export function mergePluginEntry(doc, url, force) {
297
306
  list.push(url);
298
307
  return "added";
299
308
  }
309
+ /** The package path an OpenCode 2 `plugins` entry refers to, if any. */
310
+ function v2EntryPackage(entry) {
311
+ if (typeof entry === "string")
312
+ return entry;
313
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
314
+ const p = entry.package;
315
+ return typeof p === "string" ? p : null;
316
+ }
317
+ return null;
318
+ }
319
+ /**
320
+ * D60: pure merge of the open-memex directory into the `plugins` array
321
+ * (the OpenCode 2 spelling). An existing open-memex entry (any form)
322
+ * counts as present; with force, stale open-memex entries are replaced
323
+ * by `dir`. Same "added"/"kept" contract as mergePluginEntry.
324
+ */
325
+ export function mergeV2PluginEntry(doc, dir, force) {
326
+ let plugins = doc["plugins"];
327
+ if (!Array.isArray(plugins)) {
328
+ plugins = [];
329
+ doc["plugins"] = plugins;
330
+ }
331
+ const list = plugins;
332
+ const ours = (e) => (v2EntryPackage(e) ?? "").includes("open-memex");
333
+ if (!force && list.some(ours))
334
+ return "kept";
335
+ for (let i = list.length - 1; i >= 0; i--) {
336
+ const p = v2EntryPackage(list[i]);
337
+ if (p === dir) {
338
+ if (!force)
339
+ return "kept";
340
+ list.splice(i, 1);
341
+ }
342
+ else if (force && ours(list[i])) {
343
+ list.splice(i, 1);
344
+ }
345
+ }
346
+ list.push(dir);
347
+ return "added";
348
+ }
349
+ /**
350
+ * Pure removal of open-memex entries from the `plugins` array (OpenCode 2
351
+ * spelling — strings and `{package}` objects). Drops the key when empty.
352
+ */
353
+ export function removeV2PluginEntry(doc, match) {
354
+ const plugins = doc["plugins"];
355
+ if (!Array.isArray(plugins))
356
+ return "absent";
357
+ const kept = plugins.filter((p) => !(v2EntryPackage(p) ?? "").includes(match));
358
+ if (kept.length === plugins.length)
359
+ return "absent";
360
+ if (kept.length === 0)
361
+ delete doc["plugins"];
362
+ else
363
+ doc["plugins"] = kept;
364
+ return "removed";
365
+ }
300
366
  /**
301
367
  * D46: wire the native plugin at the opencode user level — the one-time
302
368
  * global setup. Merges into the existing config when it parses as JSON;
@@ -309,6 +375,7 @@ function writeOpencodeGlobalPlugin(force) {
309
375
  .map((f) => path.join(dir, f))
310
376
  .find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
311
377
  const url = opencodePluginUrl();
378
+ const pluginDir = opencodePluginDir();
312
379
  let doc = {};
313
380
  if (fs.existsSync(file)) {
314
381
  try {
@@ -316,7 +383,8 @@ function writeOpencodeGlobalPlugin(force) {
316
383
  }
317
384
  catch {
318
385
  console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
319
- console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
386
+ console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] (opencode 1)`);
387
+ console.log(` and "plugins": ["${pluginDir}"] (opencode 2) to it.`);
320
388
  console.log(` Also remove any "${LEGACY_PACKAGE_NAME}" entries from "plugin" (stale — package renamed).`);
321
389
  return null;
322
390
  }
@@ -325,8 +393,12 @@ function writeOpencodeGlobalPlugin(force) {
325
393
  // alongside the new one — it writes to the OLD data dir, silently splitting
326
394
  // memories in two. Drop it wherever init touches the plugin list.
327
395
  const staleRemoved = removePluginEntry(doc, LEGACY_PACKAGE_NAME) === "removed";
396
+ removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
328
397
  const merged = mergePluginEntry(doc, url, force);
329
- if (merged === "kept" && !staleRemoved) {
398
+ // D60: write both key generations — opencode 1 reads `plugin`, opencode 2
399
+ // reads `plugins`; each host ignores the other's key.
400
+ const mergedV2 = mergeV2PluginEntry(doc, pluginDir, force);
401
+ if (merged === "kept" && mergedV2 === "kept" && !staleRemoved) {
330
402
  console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
331
403
  return file;
332
404
  }
@@ -334,7 +406,7 @@ function writeOpencodeGlobalPlugin(force) {
334
406
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
335
407
  if (staleRemoved)
336
408
  console.log(` - ${file} (stale my-o-memory plugin entry removed — package renamed to open-memex)`);
337
- if (merged === "added")
409
+ if (merged === "added" || mergedV2 === "added")
338
410
  console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
339
411
  return file;
340
412
  }
@@ -733,6 +805,11 @@ export async function initProject(opts) {
733
805
  const injectOnFirstTurn = await askBool("Inject relevant memories when a session starts?", DEFAULT_CONFIG.injectOnFirstTurn);
734
806
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
735
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;
736
813
  if (Object.keys(patch).length > 0) {
737
814
  const file = saveConfig(patch);
738
815
  console.log(` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`);
@@ -882,13 +959,16 @@ function removeOpencodePluginFile(file) {
882
959
  const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
883
960
  if (doc === null) {
884
961
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
885
- console.error(` Delete the open-memex URL from the "plugin" array.`);
962
+ console.error(` Delete the open-memex URL from the "plugin" array and the open-memex path from "plugins".`);
886
963
  return null;
887
964
  }
888
965
  const res = removePluginEntry(doc, "open-memex");
966
+ // D60: the v2 `plugins` entry goes too (strings and {package} objects).
967
+ const resV2 = removeV2PluginEntry(doc, "open-memex");
889
968
  // F30: also sweep the pre-rename leftover — see removeServerEntryFile.
890
969
  const stale = removePluginEntry(doc, LEGACY_PACKAGE_NAME);
891
- if (res === "removed" || stale === "removed") {
970
+ removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
971
+ if (res === "removed" || resV2 === "removed" || stale === "removed") {
892
972
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
893
973
  console.log(` - ${file} (memory-plugin entries removed)`);
894
974
  return "removed";
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,
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
3
+ import { makeTools } from "./tools/memory.js";
4
+ /** Register the five memory tools on a v2 host. */
5
+ async function registerTools(ctx, state) {
6
+ const tools = makeTools(() => state.scope, state.cfg);
7
+ await ctx.tool.transform((editor) => {
8
+ for (const [name, t] of Object.entries(tools)) {
9
+ editor.add({
10
+ name,
11
+ description: t.description,
12
+ input: z.toJSONSchema(z.object(t.args)),
13
+ options: { codemode: false },
14
+ execute: async (input) => {
15
+ const r = await t.execute(input, undefined);
16
+ const content = typeof r === "string" ? r : (r.output ?? JSON.stringify(r));
17
+ return { content };
18
+ },
19
+ });
20
+ }
21
+ });
22
+ }
23
+ export const v2Plugin = {
24
+ id: PLUGIN_ID,
25
+ async setup(ctx) {
26
+ const root = ctx.location?.directory ?? ctx.location?.project?.directory ?? process.cwd();
27
+ const state = bootstrapPlugin(root);
28
+ const registrations = [];
29
+ await registerTools(ctx, state);
30
+ registrations.push(await ctx.session.hook("prompt", (ev) => {
31
+ captureFromText(ev.prompt?.text ?? "", state);
32
+ }));
33
+ registrations.push(await ctx.session.hook("context", (ev) => {
34
+ injectOnce(ev.sessionID ?? "", state, (block) => {
35
+ ev.system.push({ type: "text", text: block });
36
+ });
37
+ }));
38
+ return () => {
39
+ for (const r of registrations)
40
+ void r.dispose?.();
41
+ };
42
+ },
43
+ };
@@ -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
+ }
@@ -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);