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

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
@@ -102,7 +102,7 @@ The published `open-memex` bin points at `dist/cli.js` (compiled at publish time
102
102
  From a source checkout, `npm run cli` / `npm run mcp` still run `src/` directly
103
103
  with type-stripping — no build step needed for development.
104
104
 
105
- There is **no `npm test`** and no CI. Verification loop is: `npm run typecheck` + `smoke-pure.ts` + (if touching sqlite) `npm run cli -- reindex` against a scratch `MY_O_MEMORY_HOME`.
105
+ There is **no `npm test`** and no CI. Verification loop is: `npm run typecheck` + `smoke-pure.ts` + (if touching sqlite) `npm run cli -- reindex` against a scratch `OPEN_MEMEX_HOME`.
106
106
 
107
107
  To load the plugin in opencode locally, `~/.config/opencode/opencode.jsonc` must have:
108
108
  ```
@@ -115,7 +115,7 @@ Restart opencode after any change — the plugin is not hot-reloaded.
115
115
  Storage root:
116
116
  - Windows: `%APPDATA%\open-memex\`
117
117
  - Linux/macOS: `$XDG_DATA_HOME/open-memex/` (fallback `~/.local/share/open-memex/`)
118
- - Override with `MY_O_MEMORY_HOME` (use this for tests to avoid clobbering real data).
118
+ - Override with `OPEN_MEMEX_HOME` (use this for tests to avoid clobbering real data). The pre-rename `MY_O_MEMORY_HOME` is still honored as a fallback (D62).
119
119
 
120
120
  Layout: `memories/<scope_key>/<id>.md` (YAML frontmatter + body) + `index.db` (SQLite FTS5).
121
121
 
@@ -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,15 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Environment variable names (D62)
13
+
14
+ - The override variables now answer to `OPEN_MEMEX_HOME` (storage root) and `OPEN_MEMEX_CONFIG` (config path). The pre-rename `MY_O_MEMORY_HOME` / `MY_O_MEMORY_CONFIG` names keep working as fallbacks — nothing to migrate, the data root itself does not move. `doctor` notes when the storage root came from the legacy variable.
15
+
16
+ ## Proactive capture: checkpoint proposals and aliases (D61)
17
+
18
+ - 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.
19
+ - 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.
20
+
12
21
  ## One plugin, both opencode generations (D60)
13
22
 
14
23
  - 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
@@ -566,8 +578,9 @@ branches and PRs like any other file.
566
578
 
567
579
  Settings live in `~/.config/opencode/open-memex.jsonc` — the `opencode` in the
568
580
  path is historical; this one file is shared by every client. Override the config
569
- path with `MY_O_MEMORY_CONFIG` and the storage root with `MY_O_MEMORY_HOME`
570
- (legacy environment names from before the rename, still honored).
581
+ path with `OPEN_MEMEX_CONFIG` and the storage root with `OPEN_MEMEX_HOME`
582
+ (the pre-rename `MY_O_MEMORY_CONFIG` / `MY_O_MEMORY_HOME` names are still
583
+ honored as fallbacks).
571
584
 
572
585
  Defaults:
573
586
 
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
 
@@ -388,7 +391,7 @@ npm install -g open-memex@latest # 或 @alpha
388
391
 
389
392
  ## 配置
390
393
 
391
- 设置放在 `~/.config/opencode/open-memex.jsonc`——路径里的 `opencode` 是历史名;这一个文件由所有客户端共享。用 `MY_O_MEMORY_CONFIG` 覆盖配置路径,用 `MY_O_MEMORY_HOME` 覆盖存储根目录(改名之前留下的旧环境变量名,仍然有效)。
394
+ 设置放在 `~/.config/opencode/open-memex.jsonc`——路径里的 `opencode` 是历史名;这一个文件由所有客户端共享。用 `OPEN_MEMEX_CONFIG` 覆盖配置路径,用 `OPEN_MEMEX_HOME` 覆盖存储根目录(改名之前的旧变量名 `MY_O_MEMORY_CONFIG` / `MY_O_MEMORY_HOME` 仍然有效,作为回退)。
392
395
 
393
396
  默认值:
394
397
 
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+(.+)$",
@@ -45,9 +46,13 @@ function stripJsonComments(raw) {
45
46
  out = out.replace(/,(\s*[}\]])/g, "$1");
46
47
  return out;
47
48
  }
48
- /** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
49
+ /**
50
+ * Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
51
+ * (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
52
+ */
49
53
  export function configFilePath() {
50
- return (process.env.MY_O_MEMORY_CONFIG ??
54
+ return (process.env.OPEN_MEMEX_CONFIG ??
55
+ process.env.MY_O_MEMORY_CONFIG ??
51
56
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"));
52
57
  }
53
58
  function toBool(v) {
@@ -79,6 +84,7 @@ export const SETTABLE_KEYS = {
79
84
  maxProfileItems: toNonNegInt,
80
85
  injectOnFirstTurn: toBool,
81
86
  keywordCaptureEnabled: toBool,
87
+ captureAliases: toBool,
82
88
  memoryDir: toRelativeDir,
83
89
  "sync.autoPull": toBool,
84
90
  logLevel: (v) => {
@@ -109,6 +115,7 @@ export function saveConfig(patch) {
109
115
  /** Path of the config file in effect, or null when using built-in defaults. */
110
116
  export function configSource() {
111
117
  const candidates = [
118
+ process.env.OPEN_MEMEX_CONFIG,
112
119
  process.env.MY_O_MEMORY_CONFIG,
113
120
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
114
121
  path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
@@ -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/doctor.js CHANGED
@@ -8,7 +8,7 @@ import path from "node:path";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.js";
11
- import { paths } from "./paths.js";
11
+ import { paths, homeOverride } from "./paths.js";
12
12
  import { resolveCwdScope } from "./scope.js";
13
13
  import { db, backendName } from "./store/db.js";
14
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.js";
@@ -54,7 +54,13 @@ function storageCheck() {
54
54
  try {
55
55
  const p = paths();
56
56
  fs.accessSync(p.memories, fs.constants.W_OK);
57
- return { name: "storage", ok: true, detail: `${p.root} (writable)` };
57
+ // D62: when the root came from the legacy env var, say so (still ok —
58
+ // the fallback is supported, doctor just names the current one).
59
+ const via = homeOverride()?.via;
60
+ const note = via === "MY_O_MEMORY_HOME"
61
+ ? " (via legacy MY_O_MEMORY_HOME — OPEN_MEMEX_HOME is the current name)"
62
+ : "";
63
+ return { name: "storage", ok: true, detail: `${p.root} (writable)${note}` };
58
64
  }
59
65
  catch (err) {
60
66
  return { name: "storage", ok: false, detail: String(err) };
@@ -559,9 +565,11 @@ export async function runDoctor() {
559
565
  function legacyCheck() {
560
566
  const name = "legacy my-o-memory";
561
567
  const found = [];
562
- const envHome = process.env.MY_O_MEMORY_HOME;
568
+ // D62: the override may arrive via either env var name; check the value
569
+ // in effect, not just the legacy one.
570
+ const envHome = homeOverride()?.dir;
563
571
  if (envHome && envHome.includes(LEGACY_PACKAGE_NAME))
564
- found.push(`MY_O_MEMORY_HOME points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
572
+ found.push(`memory home points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
565
573
  const root = paths().root;
566
574
  const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
567
575
  if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory())
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,
package/dist/paths.js CHANGED
@@ -2,9 +2,23 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import { execFileSync } from "node:child_process";
5
- function dataRoot() {
5
+ /**
6
+ * D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
7
+ * name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
8
+ * fallback so existing setups and scripts keep working. New name wins
9
+ * when both are set.
10
+ */
11
+ export function homeOverride() {
12
+ if (process.env.OPEN_MEMEX_HOME)
13
+ return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
6
14
  if (process.env.MY_O_MEMORY_HOME)
7
- return path.resolve(process.env.MY_O_MEMORY_HOME);
15
+ return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
16
+ return null;
17
+ }
18
+ function dataRoot() {
19
+ const override = homeOverride();
20
+ if (override)
21
+ return override.dir;
8
22
  if (process.platform === "win32") {
9
23
  const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
10
24
  return path.join(appData, "open-memex");
@@ -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);