open-memex 0.6.2 → 0.7.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -2
- package/CHANGELOG.md +24 -1
- package/README.md +21 -2
- package/README.zh-CN.md +7 -2
- package/dist/cli.js +31 -6
- package/dist/config.js +2 -0
- package/dist/distill-agents.js +1 -1
- package/dist/doctor.js +143 -32
- package/dist/index.js +20 -112
- package/dist/init.js +85 -5
- package/dist/mcp.js +12 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/plugin-core.js +138 -0
- 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 +94 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +47 -2
- 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/doctor.ts +140 -29
- package/src/index.ts +25 -112
- package/src/init.ts +91 -5
- package/src/mcp.ts +12 -5
- package/src/opencode-v2.ts +119 -0
- package/src/plugin-core.ts +152 -0
- 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/dist/init.js
CHANGED
|
@@ -271,6 +271,15 @@ function opencodePluginUrl() {
|
|
|
271
271
|
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
272
272
|
return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
|
|
273
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* D60: the same plugin directory, in the form OpenCode 2 wants. v2 rejects
|
|
276
|
+
* file entries in `plugins` ("configured plugin path must be a directory")
|
|
277
|
+
* and loads `<dir>/index.ts` instead.
|
|
278
|
+
*/
|
|
279
|
+
function opencodePluginDir() {
|
|
280
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
281
|
+
return path.join(pkgRoot, "src");
|
|
282
|
+
}
|
|
274
283
|
/**
|
|
275
284
|
* D46: opencode's user-level config. opencode reads `~/.config/opencode/`
|
|
276
285
|
* (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
|
|
@@ -297,6 +306,63 @@ export function mergePluginEntry(doc, url, force) {
|
|
|
297
306
|
list.push(url);
|
|
298
307
|
return "added";
|
|
299
308
|
}
|
|
309
|
+
/** The package path an OpenCode 2 `plugins` entry refers to, if any. */
|
|
310
|
+
function v2EntryPackage(entry) {
|
|
311
|
+
if (typeof entry === "string")
|
|
312
|
+
return entry;
|
|
313
|
+
if (entry !== null && typeof entry === "object" && "package" in entry) {
|
|
314
|
+
const p = entry.package;
|
|
315
|
+
return typeof p === "string" ? p : null;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* D60: pure merge of the open-memex directory into the `plugins` array
|
|
321
|
+
* (the OpenCode 2 spelling). An existing open-memex entry (any form)
|
|
322
|
+
* counts as present; with force, stale open-memex entries are replaced
|
|
323
|
+
* by `dir`. Same "added"/"kept" contract as mergePluginEntry.
|
|
324
|
+
*/
|
|
325
|
+
export function mergeV2PluginEntry(doc, dir, force) {
|
|
326
|
+
let plugins = doc["plugins"];
|
|
327
|
+
if (!Array.isArray(plugins)) {
|
|
328
|
+
plugins = [];
|
|
329
|
+
doc["plugins"] = plugins;
|
|
330
|
+
}
|
|
331
|
+
const list = plugins;
|
|
332
|
+
const ours = (e) => (v2EntryPackage(e) ?? "").includes("open-memex");
|
|
333
|
+
if (!force && list.some(ours))
|
|
334
|
+
return "kept";
|
|
335
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
336
|
+
const p = v2EntryPackage(list[i]);
|
|
337
|
+
if (p === dir) {
|
|
338
|
+
if (!force)
|
|
339
|
+
return "kept";
|
|
340
|
+
list.splice(i, 1);
|
|
341
|
+
}
|
|
342
|
+
else if (force && ours(list[i])) {
|
|
343
|
+
list.splice(i, 1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
list.push(dir);
|
|
347
|
+
return "added";
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Pure removal of open-memex entries from the `plugins` array (OpenCode 2
|
|
351
|
+
* spelling — strings and `{package}` objects). Drops the key when empty.
|
|
352
|
+
*/
|
|
353
|
+
export function removeV2PluginEntry(doc, match) {
|
|
354
|
+
const plugins = doc["plugins"];
|
|
355
|
+
if (!Array.isArray(plugins))
|
|
356
|
+
return "absent";
|
|
357
|
+
const kept = plugins.filter((p) => !(v2EntryPackage(p) ?? "").includes(match));
|
|
358
|
+
if (kept.length === plugins.length)
|
|
359
|
+
return "absent";
|
|
360
|
+
if (kept.length === 0)
|
|
361
|
+
delete doc["plugins"];
|
|
362
|
+
else
|
|
363
|
+
doc["plugins"] = kept;
|
|
364
|
+
return "removed";
|
|
365
|
+
}
|
|
300
366
|
/**
|
|
301
367
|
* D46: wire the native plugin at the opencode user level — the one-time
|
|
302
368
|
* global setup. Merges into the existing config when it parses as JSON;
|
|
@@ -309,6 +375,7 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
309
375
|
.map((f) => path.join(dir, f))
|
|
310
376
|
.find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
|
|
311
377
|
const url = opencodePluginUrl();
|
|
378
|
+
const pluginDir = opencodePluginDir();
|
|
312
379
|
let doc = {};
|
|
313
380
|
if (fs.existsSync(file)) {
|
|
314
381
|
try {
|
|
@@ -316,7 +383,8 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
316
383
|
}
|
|
317
384
|
catch {
|
|
318
385
|
console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
|
|
319
|
-
console.log(` To enable open-memex everywhere, add "plugin": ["${url}"]
|
|
386
|
+
console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] (opencode 1)`);
|
|
387
|
+
console.log(` and "plugins": ["${pluginDir}"] (opencode 2) to it.`);
|
|
320
388
|
console.log(` Also remove any "${LEGACY_PACKAGE_NAME}" entries from "plugin" (stale — package renamed).`);
|
|
321
389
|
return null;
|
|
322
390
|
}
|
|
@@ -325,8 +393,12 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
325
393
|
// alongside the new one — it writes to the OLD data dir, silently splitting
|
|
326
394
|
// memories in two. Drop it wherever init touches the plugin list.
|
|
327
395
|
const staleRemoved = removePluginEntry(doc, LEGACY_PACKAGE_NAME) === "removed";
|
|
396
|
+
removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
|
|
328
397
|
const merged = mergePluginEntry(doc, url, force);
|
|
329
|
-
|
|
398
|
+
// D60: write both key generations — opencode 1 reads `plugin`, opencode 2
|
|
399
|
+
// reads `plugins`; each host ignores the other's key.
|
|
400
|
+
const mergedV2 = mergeV2PluginEntry(doc, pluginDir, force);
|
|
401
|
+
if (merged === "kept" && mergedV2 === "kept" && !staleRemoved) {
|
|
330
402
|
console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
|
|
331
403
|
return file;
|
|
332
404
|
}
|
|
@@ -334,7 +406,7 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
334
406
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
335
407
|
if (staleRemoved)
|
|
336
408
|
console.log(` - ${file} (stale my-o-memory plugin entry removed — package renamed to open-memex)`);
|
|
337
|
-
if (merged === "added")
|
|
409
|
+
if (merged === "added" || mergedV2 === "added")
|
|
338
410
|
console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
|
|
339
411
|
return file;
|
|
340
412
|
}
|
|
@@ -733,6 +805,11 @@ export async function initProject(opts) {
|
|
|
733
805
|
const injectOnFirstTurn = await askBool("Inject relevant memories when a session starts?", DEFAULT_CONFIG.injectOnFirstTurn);
|
|
734
806
|
if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
|
|
735
807
|
patch.injectOnFirstTurn = injectOnFirstTurn;
|
|
808
|
+
// D61: capture-time aliases — agents attach alternate phrasings to each
|
|
809
|
+
// memory so differently-worded questions still find it.
|
|
810
|
+
const captureAliases = await askBool("Attach alternate phrasings (aliases) to memories so reworded questions still find them?", DEFAULT_CONFIG.captureAliases);
|
|
811
|
+
if (captureAliases !== DEFAULT_CONFIG.captureAliases)
|
|
812
|
+
patch.captureAliases = captureAliases;
|
|
736
813
|
if (Object.keys(patch).length > 0) {
|
|
737
814
|
const file = saveConfig(patch);
|
|
738
815
|
console.log(` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`);
|
|
@@ -882,13 +959,16 @@ function removeOpencodePluginFile(file) {
|
|
|
882
959
|
const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
|
|
883
960
|
if (doc === null) {
|
|
884
961
|
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
885
|
-
console.error(` Delete the open-memex URL from the "plugin" array.`);
|
|
962
|
+
console.error(` Delete the open-memex URL from the "plugin" array and the open-memex path from "plugins".`);
|
|
886
963
|
return null;
|
|
887
964
|
}
|
|
888
965
|
const res = removePluginEntry(doc, "open-memex");
|
|
966
|
+
// D60: the v2 `plugins` entry goes too (strings and {package} objects).
|
|
967
|
+
const resV2 = removeV2PluginEntry(doc, "open-memex");
|
|
889
968
|
// F30: also sweep the pre-rename leftover — see removeServerEntryFile.
|
|
890
969
|
const stale = removePluginEntry(doc, LEGACY_PACKAGE_NAME);
|
|
891
|
-
|
|
970
|
+
removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
|
|
971
|
+
if (res === "removed" || resV2 === "removed" || stale === "removed") {
|
|
892
972
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
893
973
|
console.log(` - ${file} (memory-plugin entries removed)`);
|
|
894
974
|
return "removed";
|
package/dist/mcp.js
CHANGED
|
@@ -85,9 +85,10 @@ memory_resolve, memory_pr_status — always call them by these full names.
|
|
|
85
85
|
docs, save it as type "reference" pointing at the doc instead of copying it.
|
|
86
86
|
Long-form notes are fine ONLY when the user explicitly asks to save one.
|
|
87
87
|
- Before asking the user about past decisions, conventions, or preferences they
|
|
88
|
-
may have told you before, call memory_search first —
|
|
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,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
|
|
3
|
+
import { makeTools } from "./tools/memory.js";
|
|
4
|
+
/** Register the five memory tools on a v2 host. */
|
|
5
|
+
async function registerTools(ctx, state) {
|
|
6
|
+
const tools = makeTools(() => state.scope, state.cfg);
|
|
7
|
+
await ctx.tool.transform((editor) => {
|
|
8
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
9
|
+
editor.add({
|
|
10
|
+
name,
|
|
11
|
+
description: t.description,
|
|
12
|
+
input: z.toJSONSchema(z.object(t.args)),
|
|
13
|
+
options: { codemode: false },
|
|
14
|
+
execute: async (input) => {
|
|
15
|
+
const r = await t.execute(input, undefined);
|
|
16
|
+
const content = typeof r === "string" ? r : (r.output ?? JSON.stringify(r));
|
|
17
|
+
return { content };
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export const v2Plugin = {
|
|
24
|
+
id: PLUGIN_ID,
|
|
25
|
+
async setup(ctx) {
|
|
26
|
+
const root = ctx.location?.directory ?? ctx.location?.project?.directory ?? process.cwd();
|
|
27
|
+
const state = bootstrapPlugin(root);
|
|
28
|
+
const registrations = [];
|
|
29
|
+
await registerTools(ctx, state);
|
|
30
|
+
registrations.push(await ctx.session.hook("prompt", (ev) => {
|
|
31
|
+
captureFromText(ev.prompt?.text ?? "", state);
|
|
32
|
+
}));
|
|
33
|
+
registrations.push(await ctx.session.hook("context", (ev) => {
|
|
34
|
+
injectOnce(ev.sessionID ?? "", state, (block) => {
|
|
35
|
+
ev.system.push({ type: "text", text: block });
|
|
36
|
+
});
|
|
37
|
+
}));
|
|
38
|
+
return () => {
|
|
39
|
+
for (const r of registrations)
|
|
40
|
+
void r.dispose?.();
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "./scope.js";
|
|
3
|
+
import { db } from "./store/db.js";
|
|
4
|
+
import { syncScope, upsertFromFile } from "./store/sync.js";
|
|
5
|
+
import { scopeHasFiles } from "./store/migrate.js";
|
|
6
|
+
import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
|
|
7
|
+
import { buildContextBlock } from "./retrieve/inject.js";
|
|
8
|
+
import { detectKeywords } from "./capture/keywords.js";
|
|
9
|
+
import { findDuplicates } from "./store/lifecycle.js";
|
|
10
|
+
import { redact } from "./redact.js";
|
|
11
|
+
/**
|
|
12
|
+
* Host-agnostic plugin core (D60). Both opencode host generations adapt to
|
|
13
|
+
* this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
|
|
14
|
+
* (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
|
|
15
|
+
* hook shapes — stays in the adapters; everything memory-shaped lives here.
|
|
16
|
+
*/
|
|
17
|
+
export const PLUGIN_ID = "open-memex";
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the project scope for a host working directory and bring the
|
|
20
|
+
* index up to date. Never throws: a broken store must not keep the host
|
|
21
|
+
* from starting — failures land in the host's log instead.
|
|
22
|
+
*/
|
|
23
|
+
export function bootstrapPlugin(root) {
|
|
24
|
+
const cfg = loadConfig();
|
|
25
|
+
const scope = resolveProjectScope(root);
|
|
26
|
+
try {
|
|
27
|
+
db();
|
|
28
|
+
syncScope(scope.key);
|
|
29
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
30
|
+
if (cfg.logLevel === "debug") {
|
|
31
|
+
console.log(`[open-memex] loaded. scope=${scope.key}`);
|
|
32
|
+
}
|
|
33
|
+
// If a git remote was added *after* memories were first captured, the
|
|
34
|
+
// scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
|
|
35
|
+
// disk and tell the user how to migrate. We do NOT auto-migrate: two
|
|
36
|
+
// different repos at the same cwd would collide.
|
|
37
|
+
const cwdScope = resolveCwdScope(root);
|
|
38
|
+
if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
|
|
39
|
+
console.warn(`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
|
|
40
|
+
`Current project scope is "${scope.key}". ` +
|
|
41
|
+
`To move them: npm run cli -- migrate --from ${cwdScope.key}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error("[open-memex] init failed:", err);
|
|
46
|
+
}
|
|
47
|
+
return { cfg, scope, injectedSessions: new Set() };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Keyword capture (§ capture): scan one user message for "remember …"
|
|
51
|
+
* triggers and save each hit. Shared by the v1 `chat.message` hook and
|
|
52
|
+
* the v2 session `prompt` hook — both hand us the message text.
|
|
53
|
+
*/
|
|
54
|
+
export function captureFromText(text, state) {
|
|
55
|
+
const { cfg, scope } = state;
|
|
56
|
+
if (!cfg.keywordCaptureEnabled || !text)
|
|
57
|
+
return;
|
|
58
|
+
const hits = detectKeywords(text, cfg);
|
|
59
|
+
for (const h of hits) {
|
|
60
|
+
const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
|
|
61
|
+
// Secrets are masked (first 4 chars kept) and the capture proceeds;
|
|
62
|
+
// skip only when nothing usable remains.
|
|
63
|
+
if (content.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
if (hadSecret && cfg.logLevel === "debug") {
|
|
66
|
+
console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
|
|
67
|
+
}
|
|
68
|
+
// Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
|
|
69
|
+
const target = h.personal ? PERSONAL_SCOPE : scope;
|
|
70
|
+
// Dedup (§3.4): skip exact duplicates captured before.
|
|
71
|
+
if (findDuplicates(target.key, content).exact)
|
|
72
|
+
continue;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const rfc = msToRfc3339(now);
|
|
75
|
+
const fm = {
|
|
76
|
+
id: ulid(),
|
|
77
|
+
schema_version: 2,
|
|
78
|
+
scope_key: target.key,
|
|
79
|
+
scope: target.kind === "project" ? "project" : "personal",
|
|
80
|
+
visibility: target.kind === "project" ? "internal" : "private",
|
|
81
|
+
project_name: target.projectName,
|
|
82
|
+
type: "fact",
|
|
83
|
+
role: "knowledge",
|
|
84
|
+
importance: "normal",
|
|
85
|
+
status: "active",
|
|
86
|
+
tags: ["keyword"],
|
|
87
|
+
source: "keyword",
|
|
88
|
+
created_at: rfc,
|
|
89
|
+
updated_at: rfc,
|
|
90
|
+
supersedes: null,
|
|
91
|
+
superseded_by: null,
|
|
92
|
+
review_state: "draft",
|
|
93
|
+
proposed_by: null,
|
|
94
|
+
approved_by: null,
|
|
95
|
+
derived_from: null,
|
|
96
|
+
review_note: null,
|
|
97
|
+
review_history: [],
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
const { filePath } = writeMemoryFile(fm, content);
|
|
101
|
+
const mf = readMemoryFile(filePath);
|
|
102
|
+
if (mf)
|
|
103
|
+
upsertFromFile(mf);
|
|
104
|
+
// Capture feedback: always visible (not debug-only) — the user said
|
|
105
|
+
// "记住…", they should see that it landed. The opencode plugin API
|
|
106
|
+
// offers no toast channel, so the plugin log is the feedback surface.
|
|
107
|
+
const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
|
|
108
|
+
console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error("[open-memex] keyword capture failed:", err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* First-turn context injection: exactly once per session. The host adapter
|
|
117
|
+
* supplies `push` (v1: output.system.push; v2: the session context hook's
|
|
118
|
+
* system array). Returns true when a block was pushed.
|
|
119
|
+
*/
|
|
120
|
+
export function injectOnce(sessionID, state, push) {
|
|
121
|
+
const { cfg, scope } = state;
|
|
122
|
+
if (!cfg.injectOnFirstTurn)
|
|
123
|
+
return false;
|
|
124
|
+
if (state.injectedSessions.has(sessionID))
|
|
125
|
+
return false;
|
|
126
|
+
state.injectedSessions.add(sessionID);
|
|
127
|
+
try {
|
|
128
|
+
const block = buildContextBlock(scope, cfg);
|
|
129
|
+
if (block) {
|
|
130
|
+
push(block);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error("[open-memex] context injection failed:", err);
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
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);
|