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 +9 -0
- package/CHANGELOG.md +5 -0
- package/README.md +13 -1
- package/README.zh-CN.md +4 -1
- package/dist/cli.js +31 -6
- package/dist/config.js +2 -0
- package/dist/distill-agents.js +1 -1
- package/dist/init.js +5 -0
- package/dist/mcp.js +12 -5
- package/dist/retrieve/inject.js +1 -1
- package/dist/retrieve/search.js +11 -2
- package/dist/store/db.js +11 -9
- package/dist/store/lifecycle.js +2 -0
- package/dist/store/markdown.js +39 -1
- package/dist/store/sync.js +5 -3
- package/dist/tools/ops.js +22 -3
- package/docs/V2-DESIGN.md +54 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +25 -1
- package/scripts/test-full.ts +28 -0
- package/skills/open-memex/SKILL.md +30 -1
- package/src/cli.ts +36 -5
- package/src/config.ts +10 -0
- package/src/distill-agents.ts +6 -0
- package/src/init.ts +8 -0
- package/src/mcp.ts +12 -5
- package/src/retrieve/inject.ts +1 -1
- package/src/retrieve/search.ts +14 -2
- package/src/store/db.ts +11 -9
- package/src/store/lifecycle.ts +3 -0
- package/src/store/markdown.ts +45 -1
- package/src/store/sync.ts +6 -3
- package/src/tools/ops.ts +31 -2
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
|
-
- **
|
|
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
|
-
-
|
|
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
|
|
61
|
-
--type
|
|
62
|
-
--tag
|
|
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) => {
|
package/dist/distill-agents.js
CHANGED
|
@@ -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 —
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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/retrieve/inject.js
CHANGED
|
@@ -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
|
}
|
package/dist/retrieve/search.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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;
|
package/dist/store/lifecycle.js
CHANGED
|
@@ -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,
|
package/dist/store/markdown.js
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/store/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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",
|
package/scripts/smoke-pure.ts
CHANGED
|
@@ -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"));
|
package/scripts/test-full.ts
CHANGED
|
@@ -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
|
|
71
|
-
--type
|
|
72
|
-
--tag
|
|
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) => {
|
package/src/distill-agents.ts
CHANGED
|
@@ -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 —
|
|
116
|
-
|
|
117
|
-
|
|
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 },
|
package/src/retrieve/inject.ts
CHANGED
|
@@ -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");
|
package/src/retrieve/search.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 };
|
package/src/store/lifecycle.ts
CHANGED
|
@@ -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,
|
package/src/store/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|