open-memex 0.6.2 → 0.7.0-alpha.11
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 +25 -7
- package/CHANGELOG.md +103 -1
- package/README.md +54 -16
- package/README.zh-CN.md +30 -15
- package/dist/.build-stamp +1 -0
- package/dist/capture/keywords.js +41 -8
- package/dist/cli.js +259 -40
- package/dist/config.js +102 -18
- package/dist/distill-agents.js +9 -1
- package/dist/doctor.js +180 -79
- package/dist/export.js +12 -2
- package/dist/index.js +20 -112
- package/dist/init.js +103 -11
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +21 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/paths.js +34 -2
- package/dist/plugin-core.js +138 -0
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/inject.js +1 -1
- package/dist/retrieve/search.js +44 -5
- package/dist/review.js +9 -3
- package/dist/store/db.js +11 -9
- package/dist/store/lifecycle.js +20 -4
- package/dist/store/markdown.js +53 -7
- package/dist/store/migrate.js +14 -3
- package/dist/store/sync.js +5 -3
- package/dist/store/v2migrate.js +2 -2
- package/dist/submit.js +13 -5
- package/dist/tools/memory.js +28 -0
- package/dist/tools/ops.js +127 -16
- package/docs/CURATOR.md +7 -3
- package/docs/V2-DESIGN.md +335 -32
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +63 -0
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +237 -3
- package/scripts/test-full.ts +362 -5
- package/skills/open-memex/SKILL.md +53 -1
- package/src/capture/keywords.ts +56 -6
- package/src/cli.ts +282 -39
- package/src/config.ts +104 -17
- package/src/distill-agents.ts +19 -0
- package/src/doctor.ts +181 -71
- package/src/export.ts +14 -2
- package/src/index.ts +25 -112
- package/src/init.ts +108 -11
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +21 -5
- package/src/opencode-v2.ts +119 -0
- package/src/paths.ts +35 -1
- package/src/plugin-core.ts +152 -0
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/inject.ts +1 -1
- package/src/retrieve/search.ts +63 -6
- package/src/review.ts +9 -2
- package/src/store/db.ts +11 -9
- package/src/store/lifecycle.ts +24 -3
- package/src/store/markdown.ts +60 -7
- package/src/store/migrate.ts +15 -2
- package/src/store/sync.ts +6 -3
- package/src/store/v2migrate.ts +2 -1
- package/src/submit.ts +12 -4
- package/src/tools/memory.ts +28 -0
- package/src/tools/ops.ts +147 -17
package/dist/init.js
CHANGED
|
@@ -56,11 +56,12 @@ You have a local memory MCP server (\`open-memex\`). Its tools are
|
|
|
56
56
|
these full names.
|
|
57
57
|
|
|
58
58
|
- The server tells you when project outbox drafts are waiting for review — in
|
|
59
|
-
tool results.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
tool results. When it does (or when the user asks), call \`memory_status\`
|
|
60
|
+
to see them; don't call it blindly at session start. When drafts are
|
|
61
|
+
waiting, summarize them (one line each) and ask the user which ones to
|
|
62
|
+
sync into the repo; sync NOTHING the user did not name. If you already
|
|
63
|
+
asked about these drafts this session, don't ask again. When the server
|
|
64
|
+
reports none waiting, do nothing.
|
|
64
65
|
- BE PROACTIVE about facts the user states directly: when the user shares a
|
|
65
66
|
decision, preference, project convention, or fix-and-cause worth remembering
|
|
66
67
|
across sessions, call \`memory_add\` without being asked. Keep each memory to
|
|
@@ -271,13 +272,32 @@ function opencodePluginUrl() {
|
|
|
271
272
|
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
272
273
|
return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
|
|
273
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* D60: the same plugin directory, in the form OpenCode 2 wants. v2 rejects
|
|
277
|
+
* file entries in `plugins` ("configured plugin path must be a directory")
|
|
278
|
+
* and loads `<dir>/index.ts` instead.
|
|
279
|
+
*/
|
|
280
|
+
function opencodePluginDir() {
|
|
281
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
282
|
+
return path.join(pkgRoot, "src");
|
|
283
|
+
}
|
|
274
284
|
/**
|
|
275
285
|
* D46: opencode's user-level config. opencode reads `~/.config/opencode/`
|
|
276
286
|
* (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
|
|
277
287
|
* loads open-memex in every project with no per-project init.
|
|
278
288
|
*/
|
|
279
289
|
export function opencodeGlobalConfigPath(home = os.homedir()) {
|
|
280
|
-
|
|
290
|
+
const dir = opencodeConfigDir(home, process.env.XDG_CONFIG_HOME);
|
|
291
|
+
// D64: init writes into whichever of .json/.jsonc already exists
|
|
292
|
+
// (writeOpencodeGlobalPlugin), so readers must resolve the same file.
|
|
293
|
+
// Hardcoding .json made uninstall report "nothing to remove" on
|
|
294
|
+
// .jsonc-only setups and left the plugin entry behind.
|
|
295
|
+
for (const name of ["opencode.jsonc", "opencode.json"]) {
|
|
296
|
+
const candidate = path.join(dir, name);
|
|
297
|
+
if (fs.existsSync(candidate))
|
|
298
|
+
return candidate;
|
|
299
|
+
}
|
|
300
|
+
return path.join(dir, "opencode.json");
|
|
281
301
|
}
|
|
282
302
|
/**
|
|
283
303
|
* Pure merge of the open-memex plugin URL into a parsed opencode config doc.
|
|
@@ -297,6 +317,63 @@ export function mergePluginEntry(doc, url, force) {
|
|
|
297
317
|
list.push(url);
|
|
298
318
|
return "added";
|
|
299
319
|
}
|
|
320
|
+
/** The package path an OpenCode 2 `plugins` entry refers to, if any. */
|
|
321
|
+
function v2EntryPackage(entry) {
|
|
322
|
+
if (typeof entry === "string")
|
|
323
|
+
return entry;
|
|
324
|
+
if (entry !== null && typeof entry === "object" && "package" in entry) {
|
|
325
|
+
const p = entry.package;
|
|
326
|
+
return typeof p === "string" ? p : null;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* D60: pure merge of the open-memex directory into the `plugins` array
|
|
332
|
+
* (the OpenCode 2 spelling). An existing open-memex entry (any form)
|
|
333
|
+
* counts as present; with force, stale open-memex entries are replaced
|
|
334
|
+
* by `dir`. Same "added"/"kept" contract as mergePluginEntry.
|
|
335
|
+
*/
|
|
336
|
+
export function mergeV2PluginEntry(doc, dir, force) {
|
|
337
|
+
let plugins = doc["plugins"];
|
|
338
|
+
if (!Array.isArray(plugins)) {
|
|
339
|
+
plugins = [];
|
|
340
|
+
doc["plugins"] = plugins;
|
|
341
|
+
}
|
|
342
|
+
const list = plugins;
|
|
343
|
+
const ours = (e) => (v2EntryPackage(e) ?? "").includes("open-memex");
|
|
344
|
+
if (!force && list.some(ours))
|
|
345
|
+
return "kept";
|
|
346
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
347
|
+
const p = v2EntryPackage(list[i]);
|
|
348
|
+
if (p === dir) {
|
|
349
|
+
if (!force)
|
|
350
|
+
return "kept";
|
|
351
|
+
list.splice(i, 1);
|
|
352
|
+
}
|
|
353
|
+
else if (force && ours(list[i])) {
|
|
354
|
+
list.splice(i, 1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
list.push(dir);
|
|
358
|
+
return "added";
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Pure removal of open-memex entries from the `plugins` array (OpenCode 2
|
|
362
|
+
* spelling — strings and `{package}` objects). Drops the key when empty.
|
|
363
|
+
*/
|
|
364
|
+
export function removeV2PluginEntry(doc, match) {
|
|
365
|
+
const plugins = doc["plugins"];
|
|
366
|
+
if (!Array.isArray(plugins))
|
|
367
|
+
return "absent";
|
|
368
|
+
const kept = plugins.filter((p) => !(v2EntryPackage(p) ?? "").includes(match));
|
|
369
|
+
if (kept.length === plugins.length)
|
|
370
|
+
return "absent";
|
|
371
|
+
if (kept.length === 0)
|
|
372
|
+
delete doc["plugins"];
|
|
373
|
+
else
|
|
374
|
+
doc["plugins"] = kept;
|
|
375
|
+
return "removed";
|
|
376
|
+
}
|
|
300
377
|
/**
|
|
301
378
|
* D46: wire the native plugin at the opencode user level — the one-time
|
|
302
379
|
* global setup. Merges into the existing config when it parses as JSON;
|
|
@@ -309,6 +386,7 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
309
386
|
.map((f) => path.join(dir, f))
|
|
310
387
|
.find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
|
|
311
388
|
const url = opencodePluginUrl();
|
|
389
|
+
const pluginDir = opencodePluginDir();
|
|
312
390
|
let doc = {};
|
|
313
391
|
if (fs.existsSync(file)) {
|
|
314
392
|
try {
|
|
@@ -316,7 +394,8 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
316
394
|
}
|
|
317
395
|
catch {
|
|
318
396
|
console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
|
|
319
|
-
console.log(` To enable open-memex everywhere, add "plugin": ["${url}"]
|
|
397
|
+
console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] (opencode 1)`);
|
|
398
|
+
console.log(` and "plugins": ["${pluginDir}"] (opencode 2) to it.`);
|
|
320
399
|
console.log(` Also remove any "${LEGACY_PACKAGE_NAME}" entries from "plugin" (stale — package renamed).`);
|
|
321
400
|
return null;
|
|
322
401
|
}
|
|
@@ -325,8 +404,12 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
325
404
|
// alongside the new one — it writes to the OLD data dir, silently splitting
|
|
326
405
|
// memories in two. Drop it wherever init touches the plugin list.
|
|
327
406
|
const staleRemoved = removePluginEntry(doc, LEGACY_PACKAGE_NAME) === "removed";
|
|
407
|
+
removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
|
|
328
408
|
const merged = mergePluginEntry(doc, url, force);
|
|
329
|
-
|
|
409
|
+
// D60: write both key generations — opencode 1 reads `plugin`, opencode 2
|
|
410
|
+
// reads `plugins`; each host ignores the other's key.
|
|
411
|
+
const mergedV2 = mergeV2PluginEntry(doc, pluginDir, force);
|
|
412
|
+
if (merged === "kept" && mergedV2 === "kept" && !staleRemoved) {
|
|
330
413
|
console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
|
|
331
414
|
return file;
|
|
332
415
|
}
|
|
@@ -334,7 +417,7 @@ function writeOpencodeGlobalPlugin(force) {
|
|
|
334
417
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
335
418
|
if (staleRemoved)
|
|
336
419
|
console.log(` - ${file} (stale my-o-memory plugin entry removed — package renamed to open-memex)`);
|
|
337
|
-
if (merged === "added")
|
|
420
|
+
if (merged === "added" || mergedV2 === "added")
|
|
338
421
|
console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
|
|
339
422
|
return file;
|
|
340
423
|
}
|
|
@@ -398,6 +481,7 @@ function writeVisualStudioMcpJson(root, force) {
|
|
|
398
481
|
});
|
|
399
482
|
return null;
|
|
400
483
|
}
|
|
484
|
+
doc = parsed;
|
|
401
485
|
}
|
|
402
486
|
const section = (doc["servers"] ??= {});
|
|
403
487
|
// F30: drop the pre-rename server entry — it points at the OLD data dir.
|
|
@@ -733,6 +817,11 @@ export async function initProject(opts) {
|
|
|
733
817
|
const injectOnFirstTurn = await askBool("Inject relevant memories when a session starts?", DEFAULT_CONFIG.injectOnFirstTurn);
|
|
734
818
|
if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
|
|
735
819
|
patch.injectOnFirstTurn = injectOnFirstTurn;
|
|
820
|
+
// D61: capture-time aliases — agents attach alternate phrasings to each
|
|
821
|
+
// memory so differently-worded questions still find it.
|
|
822
|
+
const captureAliases = await askBool("Attach alternate phrasings (aliases) to memories so reworded questions still find them?", DEFAULT_CONFIG.captureAliases);
|
|
823
|
+
if (captureAliases !== DEFAULT_CONFIG.captureAliases)
|
|
824
|
+
patch.captureAliases = captureAliases;
|
|
736
825
|
if (Object.keys(patch).length > 0) {
|
|
737
826
|
const file = saveConfig(patch);
|
|
738
827
|
console.log(` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`);
|
|
@@ -882,13 +971,16 @@ function removeOpencodePluginFile(file) {
|
|
|
882
971
|
const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
|
|
883
972
|
if (doc === null) {
|
|
884
973
|
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
885
|
-
console.error(` Delete the open-memex URL from the "plugin" array.`);
|
|
974
|
+
console.error(` Delete the open-memex URL from the "plugin" array and the open-memex path from "plugins".`);
|
|
886
975
|
return null;
|
|
887
976
|
}
|
|
888
977
|
const res = removePluginEntry(doc, "open-memex");
|
|
978
|
+
// D60: the v2 `plugins` entry goes too (strings and {package} objects).
|
|
979
|
+
const resV2 = removeV2PluginEntry(doc, "open-memex");
|
|
889
980
|
// F30: also sweep the pre-rename leftover — see removeServerEntryFile.
|
|
890
981
|
const stale = removePluginEntry(doc, LEGACY_PACKAGE_NAME);
|
|
891
|
-
|
|
982
|
+
removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
|
|
983
|
+
if (res === "removed" || resV2 === "removed" || stale === "removed") {
|
|
892
984
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
893
985
|
console.log(` - ${file} (memory-plugin entries removed)`);
|
|
894
986
|
return "removed";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML renderer for the inventory (D68 follow-up): a single local file
|
|
3
|
+
* a user opens in any browser to read what the store remembers. It
|
|
4
|
+
* renders the SAME data layer as the text/JSON formats
|
|
5
|
+
* (`src/inventory.ts`) — one source, three renderings, no drift.
|
|
6
|
+
*
|
|
7
|
+
* Design rules (3-C v2) enforced here:
|
|
8
|
+
* - Every byte of memory content is HTML-escaped. Memory text can come
|
|
9
|
+
* from an imported bundle or a pasted web page; under file:// an
|
|
10
|
+
* unescaped <script> would execute in the user's own browser.
|
|
11
|
+
* - The filter box is plain show/hide over server-rendered entries
|
|
12
|
+
* (no innerHTML, no network, no external resources).
|
|
13
|
+
* - History (superseded versions, retracted, archived) is a folded
|
|
14
|
+
* section with supersede-chain pointers — auditable, never mixed
|
|
15
|
+
* into "what is remembered now". At scale (>50 current entries) the
|
|
16
|
+
* scope sections fold too.
|
|
17
|
+
* - Read-only: no delete buttons, no server callback. Deletion stays a
|
|
18
|
+
* confirmed conversation; the page says so, with the id to quote.
|
|
19
|
+
*/
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { PERSONAL_SCOPE } from "./scope.js";
|
|
22
|
+
import { escapeHtml, relativeTime, reviewStateLabel, sourceLabel, } from "./retrieve/display.js";
|
|
23
|
+
const FOLD_THRESHOLD = 50;
|
|
24
|
+
function entryHtml(row, now) {
|
|
25
|
+
const meta = [escapeHtml(sourceLabel(row.source))];
|
|
26
|
+
const when = relativeTime(row.created_at, now);
|
|
27
|
+
if (when)
|
|
28
|
+
meta.push(escapeHtml(when));
|
|
29
|
+
if (row.scope_key !== PERSONAL_SCOPE.key) {
|
|
30
|
+
meta.push(escapeHtml(reviewStateLabel(row.review_state)));
|
|
31
|
+
if (row.review_state !== "draft") {
|
|
32
|
+
meta.push(row.file_path.replace(/\\/g, "/").includes("/.ai/open-memex/")
|
|
33
|
+
? "file is in the repo"
|
|
34
|
+
: "file is not in the repo yet");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (row.status !== "active")
|
|
38
|
+
meta.push(escapeHtml(`marked ${row.status}`));
|
|
39
|
+
return ` <article class="entry">
|
|
40
|
+
<p class="body">${escapeHtml(row.content.trim())}</p>
|
|
41
|
+
<p class="meta">${meta.join(" · ")} · <span class="id">id ${escapeHtml(row.id)}</span></p>
|
|
42
|
+
</article>`;
|
|
43
|
+
}
|
|
44
|
+
function hiddenEntryHtml(row, now) {
|
|
45
|
+
const when = relativeTime(row.created_at, now);
|
|
46
|
+
const chain = row.superseded_by
|
|
47
|
+
? ` · replaced by <span class="id">${escapeHtml(row.superseded_by)}</span>`
|
|
48
|
+
: "";
|
|
49
|
+
return ` <article class="entry hidden-entry">
|
|
50
|
+
<p class="body">${escapeHtml(row.content.trim())}</p>
|
|
51
|
+
<p class="meta">${escapeHtml(row.status)}${when ? ` · saved ${escapeHtml(when)}` : ""}${chain} · <span class="id">id ${escapeHtml(row.id)}</span></p>
|
|
52
|
+
</article>`;
|
|
53
|
+
}
|
|
54
|
+
function sectionHtml(title, blurb, entries, count, fold, open) {
|
|
55
|
+
if (fold) {
|
|
56
|
+
return ` <details${open ? " open" : ""}>
|
|
57
|
+
<summary>${escapeHtml(title)} <span class="count">${count}</span></summary>
|
|
58
|
+
<p class="blurb">${escapeHtml(blurb)}</p>
|
|
59
|
+
${entries}
|
|
60
|
+
</details>`;
|
|
61
|
+
}
|
|
62
|
+
return ` <section>
|
|
63
|
+
<h2>${escapeHtml(title)} <span class="count">${count}</span></h2>
|
|
64
|
+
<p class="blurb">${escapeHtml(blurb)}</p>
|
|
65
|
+
${entries || ' <p class="empty">(nothing here yet)</p>'}
|
|
66
|
+
</section>`;
|
|
67
|
+
}
|
|
68
|
+
export function renderInventoryHtml(data) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const fold = data.total > FOLD_THRESHOLD;
|
|
71
|
+
const personalCount = data.scopes
|
|
72
|
+
.filter((s) => s.scopeKey === PERSONAL_SCOPE.key)
|
|
73
|
+
.reduce((n, s) => n + s.rows.length, 0);
|
|
74
|
+
const parts = [];
|
|
75
|
+
data.scopes.forEach((s, i) => {
|
|
76
|
+
const isPersonal = s.scopeKey === PERSONAL_SCOPE.key;
|
|
77
|
+
parts.push(sectionHtml(isPersonal ? "About you — personal" : `Project: ${s.projectName}`, isPersonal
|
|
78
|
+
? "These describe you, apply in every project, and never leave this machine."
|
|
79
|
+
: "Project memories. Each entry says whether it is still only on this machine (the outbox) or has reached the repo.", s.rows.map((r) => entryHtml(r, now)).join("\n"), s.rows.length, fold, isPersonal || i === 0));
|
|
80
|
+
});
|
|
81
|
+
if (data.outbox.length > 0) {
|
|
82
|
+
parts.push(sectionHtml("Not in the repo yet — outbox drafts", "These drafts exist only on this machine. They reach the repo through review (submit → promote), never automatically.", data.outbox.map((r) => entryHtml(r, now)).join("\n"), data.outbox.length, fold, false));
|
|
83
|
+
}
|
|
84
|
+
if (data.hiddenEntries.length > 0) {
|
|
85
|
+
const h = data.hiddenEntries.map((r) => hiddenEntryHtml(r, now)).join("\n");
|
|
86
|
+
parts.push(` <details>
|
|
87
|
+
<summary>Replaced & hidden history <span class="count">${data.hiddenEntries.length}</span></summary>
|
|
88
|
+
<p class="blurb">Older versions and withdrawn memories. Kept so you can audit how a fact changed; none of this is used for recall.</p>
|
|
89
|
+
${h}
|
|
90
|
+
</details>`);
|
|
91
|
+
}
|
|
92
|
+
return `<!doctype html>
|
|
93
|
+
<html lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8">
|
|
96
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
97
|
+
<!-- D70: the page claims it makes no network requests - enforce it. Inline
|
|
98
|
+
style/script are the only allowances; anything else (a stray <img
|
|
99
|
+
src>, a remote font) is blocked by the browser, not just by our promise. -->
|
|
100
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
|
101
|
+
<title>What OpenMemex remembers</title>
|
|
102
|
+
<style>
|
|
103
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; padding: 0 1rem; color: #1a1a1a; background: #fdfdfb; }
|
|
104
|
+
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
|
|
105
|
+
h2, summary { font-size: 1.1rem; font-weight: 600; }
|
|
106
|
+
summary { cursor: pointer; margin: 1.2rem 0 0.4rem; }
|
|
107
|
+
.sub { color: #666; font-size: 0.9rem; margin-top: 0; }
|
|
108
|
+
.count { color: #888; font-weight: normal; font-size: 0.85em; }
|
|
109
|
+
.blurb { color: #555; font-size: 0.9rem; }
|
|
110
|
+
.entry { border-top: 1px solid #e5e2d9; padding: 0.75rem 0; }
|
|
111
|
+
.hidden-entry { opacity: 0.75; }
|
|
112
|
+
.body { white-space: pre-wrap; margin: 0 0 0.35rem; }
|
|
113
|
+
.meta { color: #666; font-size: 0.82rem; margin: 0; }
|
|
114
|
+
.id { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
|
115
|
+
.empty { color: #888; }
|
|
116
|
+
.howto { background: #f4f1e8; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.9rem; }
|
|
117
|
+
input[type="search"] { width: 100%; padding: 0.5rem; font-size: 1rem; margin: 1rem 0; box-sizing: border-box; }
|
|
118
|
+
.hidden { display: none; }
|
|
119
|
+
</style>
|
|
120
|
+
</head>
|
|
121
|
+
<body>
|
|
122
|
+
<h1>What OpenMemex remembers about you</h1>
|
|
123
|
+
<p class="sub">Generated ${escapeHtml(data.generatedAt)} · ${data.total} memories (${personalCount} personal, ${data.total - personalCount} project incl. outbox). Read-only report.</p>
|
|
124
|
+
<input type="search" id="filter" placeholder="Filter memories…" aria-label="Filter memories">
|
|
125
|
+
<div class="howto">
|
|
126
|
+
<strong>Checking my work:</strong> every entry shows where it came from.
|
|
127
|
+
“I inferred this” means I wrote it from context, not from your words — those are the ones to check.
|
|
128
|
+
To remove one, tell your agent <em>“forget the memory about …”</em> (quote the id if you like) and it will read the entry back before deleting it — deletion is permanent, there is no undo.
|
|
129
|
+
A memory that lives in a repo is only truly gone once the deletion is committed.
|
|
130
|
+
</div>
|
|
131
|
+
${parts.join("\n")}
|
|
132
|
+
<script>
|
|
133
|
+
var input = document.getElementById('filter');
|
|
134
|
+
input.addEventListener('input', function () {
|
|
135
|
+
var q = input.value.trim().toLowerCase();
|
|
136
|
+
var entries = document.querySelectorAll('.entry');
|
|
137
|
+
for (var i = 0; i < entries.length; i++) {
|
|
138
|
+
var el = entries[i];
|
|
139
|
+
el.classList.toggle('hidden', q !== '' && el.textContent.toLowerCase().indexOf(q) === -1);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
</script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
/** Default report location: the data dir, never a working tree. */
|
|
148
|
+
export function defaultHtmlOutPath(dataRoot) {
|
|
149
|
+
return path.join(dataRoot, "inventory.html");
|
|
150
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `open-memex inventory` (D68): what the store remembers, in the open.
|
|
3
|
+
* One data layer, three renderers (text / json / html). The HTML
|
|
4
|
+
* renderer lives in inventory-html.ts; this module owns the data and
|
|
5
|
+
* the two renderers an agent or a terminal consumes directly.
|
|
6
|
+
*
|
|
7
|
+
* Design rules (3-C v2) enforced here:
|
|
8
|
+
* - Default scope is personal + the current project — "can't see it"
|
|
9
|
+
* is the disease this surface treats.
|
|
10
|
+
* - Nothing truncates silently: every section states its totals, and
|
|
11
|
+
* hidden history (superseded versions, retracted, archived) is
|
|
12
|
+
* counted and pointed at, never just omitted.
|
|
13
|
+
* - Drafts that have not reached the repo (the outbox) are their own
|
|
14
|
+
* section, consistent with what memory_status reports.
|
|
15
|
+
* - The report is read-only. Deleting stays a confirmed conversation.
|
|
16
|
+
*/
|
|
17
|
+
import { db } from "./store/db.js";
|
|
18
|
+
import { PERSONAL_SCOPE } from "./scope.js";
|
|
19
|
+
import { formatInventoryLine } from "./retrieve/display.js";
|
|
20
|
+
const BASE_COLS = `id, scope_key, project_name, type, status, review_state,
|
|
21
|
+
source, created_at, updated_at, content, file_path`;
|
|
22
|
+
/**
|
|
23
|
+
* Load the inventory for the given scopes. `projectScopeKey` separates the
|
|
24
|
+
* outbox drafts (they belong to that scope's draft rows).
|
|
25
|
+
*
|
|
26
|
+
* D70: `hidden: "entries"` fetches the hidden rows themselves (HTML audit
|
|
27
|
+
* view); the default keeps the cheap COUNT query, because pulling every
|
|
28
|
+
* superseded body for a text report nobody reads is pure waste.
|
|
29
|
+
*/
|
|
30
|
+
export function loadInventory(scopeKeys, opts = {}) {
|
|
31
|
+
const inList = scopeKeys.map(() => "?").join(",");
|
|
32
|
+
const current = db()
|
|
33
|
+
.prepare(`SELECT ${BASE_COLS} FROM memories
|
|
34
|
+
WHERE scope_key IN (${inList}) AND status IN ('active', 'deprecated')
|
|
35
|
+
ORDER BY scope_key, updated_at DESC`)
|
|
36
|
+
.all(...scopeKeys);
|
|
37
|
+
const hiddenEntries = opts.hidden === "entries"
|
|
38
|
+
? db()
|
|
39
|
+
.prepare(`SELECT ${BASE_COLS}, superseded_by FROM memories
|
|
40
|
+
WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
|
|
41
|
+
ORDER BY updated_at DESC`)
|
|
42
|
+
.all(...scopeKeys)
|
|
43
|
+
: [];
|
|
44
|
+
const hidden = { superseded: 0, retracted: 0, archived: 0 };
|
|
45
|
+
if (hiddenEntries.length > 0) {
|
|
46
|
+
for (const r of hiddenEntries) {
|
|
47
|
+
if (r.status === "superseded")
|
|
48
|
+
hidden.superseded++;
|
|
49
|
+
if (r.status === "retracted")
|
|
50
|
+
hidden.retracted++;
|
|
51
|
+
if (r.status === "archived")
|
|
52
|
+
hidden.archived++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const counted = db()
|
|
57
|
+
.prepare(`SELECT status, COUNT(*) AS n FROM memories
|
|
58
|
+
WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
|
|
59
|
+
GROUP BY status`)
|
|
60
|
+
.all(...scopeKeys);
|
|
61
|
+
for (const r of counted) {
|
|
62
|
+
if (r.status === "superseded")
|
|
63
|
+
hidden.superseded = r.n;
|
|
64
|
+
if (r.status === "retracted")
|
|
65
|
+
hidden.retracted = r.n;
|
|
66
|
+
if (r.status === "archived")
|
|
67
|
+
hidden.archived = r.n;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const outbox = current.filter((r) => r.scope_key !== PERSONAL_SCOPE.key && r.review_state === "draft");
|
|
71
|
+
const outboxIds = new Set(outbox.map((r) => r.id));
|
|
72
|
+
const byScope = new Map();
|
|
73
|
+
for (const r of current) {
|
|
74
|
+
if (outboxIds.has(r.id))
|
|
75
|
+
continue;
|
|
76
|
+
const arr = byScope.get(r.scope_key) ?? [];
|
|
77
|
+
arr.push(r);
|
|
78
|
+
byScope.set(r.scope_key, arr);
|
|
79
|
+
}
|
|
80
|
+
// Personal first, then project scopes by key for a stable order.
|
|
81
|
+
// D70: every requested scope appears, including one with nothing in it —
|
|
82
|
+
// "this project has no memories" is part of the answer, and dropping the
|
|
83
|
+
// section made the report look like the project was never looked at.
|
|
84
|
+
const orderedKeys = [
|
|
85
|
+
...scopeKeys.filter((k) => k === PERSONAL_SCOPE.key),
|
|
86
|
+
...scopeKeys.filter((k) => k !== PERSONAL_SCOPE.key).sort(),
|
|
87
|
+
];
|
|
88
|
+
const scopes = orderedKeys.map((k) => {
|
|
89
|
+
const rows = byScope.get(k) ?? [];
|
|
90
|
+
return {
|
|
91
|
+
scopeKey: k,
|
|
92
|
+
projectName: rows[0]?.project_name || k,
|
|
93
|
+
rows,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
generatedAt: new Date().toISOString(),
|
|
98
|
+
scopes,
|
|
99
|
+
outbox,
|
|
100
|
+
hidden,
|
|
101
|
+
hiddenEntries,
|
|
102
|
+
total: current.length,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function hiddenLine(data) {
|
|
106
|
+
const { superseded, retracted, archived } = data.hidden;
|
|
107
|
+
if (superseded + retracted + archived === 0)
|
|
108
|
+
return "";
|
|
109
|
+
return `Not shown: ${superseded} superseded version(s), ${retracted} retracted, ${archived} archived — inspect with \`open-memex list --include all\`.`;
|
|
110
|
+
}
|
|
111
|
+
function entriesText(rows, startAt = 0) {
|
|
112
|
+
if (rows.length === 0)
|
|
113
|
+
return "(nothing remembered in this scope yet)";
|
|
114
|
+
return rows
|
|
115
|
+
.map((r, i) => formatInventoryLine(startAt + i, { ...r, snippet: r.content }, PERSONAL_SCOPE.key))
|
|
116
|
+
.join("\n");
|
|
117
|
+
}
|
|
118
|
+
/** Plain-text rendering: diffable in any terminal or CI log. */
|
|
119
|
+
export function renderInventoryText(data) {
|
|
120
|
+
const out = [];
|
|
121
|
+
out.push(`What OpenMemex remembers — ${data.generatedAt}`);
|
|
122
|
+
out.push(`${data.total} memories (${data.scopes.reduce((n, s) => n + (s.scopeKey === PERSONAL_SCOPE.key ? s.rows.length : 0), 0)} personal, ${data.outbox.length} in the outbox).`);
|
|
123
|
+
// D70: one running counter across every section, so a number quoted back
|
|
124
|
+
// ("delete #3") names exactly one entry in this report.
|
|
125
|
+
let seq = 0;
|
|
126
|
+
for (const s of data.scopes) {
|
|
127
|
+
const title = s.scopeKey === PERSONAL_SCOPE.key
|
|
128
|
+
? "About you — personal (never leaves this machine)"
|
|
129
|
+
: `Project: ${s.projectName}`;
|
|
130
|
+
out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows, seq));
|
|
131
|
+
seq += s.rows.length;
|
|
132
|
+
}
|
|
133
|
+
if (data.outbox.length > 0) {
|
|
134
|
+
out.push("", `## Not in the repo yet — outbox drafts (${data.outbox.length})`, "These exist only on this machine. They reach the repo through review (submit → promote), never automatically.", entriesText(data.outbox, seq));
|
|
135
|
+
seq += data.outbox.length;
|
|
136
|
+
}
|
|
137
|
+
const hidden = hiddenLine(data);
|
|
138
|
+
if (hidden)
|
|
139
|
+
out.push("", hidden);
|
|
140
|
+
out.push("", "Read-only report. To remove a memory, tell your agent to forget it — it will read the entry back before deleting (deletion is permanent).");
|
|
141
|
+
return out.join("\n") + "\n";
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* JSON rendering: same data, for agents. Mirrors the export field names.
|
|
145
|
+
*
|
|
146
|
+
* D70: format `/2` drops the absolute `file` path. This artifact is meant
|
|
147
|
+
* to be handed to an agent — i.e. pasted into a cloud conversation — and it
|
|
148
|
+
* carried the user's home directory and name with it. The id locates the
|
|
149
|
+
* file (`open-memex where` prints the data root).
|
|
150
|
+
*/
|
|
151
|
+
export function renderInventoryJson(data) {
|
|
152
|
+
const entry = (r) => ({
|
|
153
|
+
id: r.id,
|
|
154
|
+
scope: r.scope_key,
|
|
155
|
+
project: r.project_name,
|
|
156
|
+
type: r.type,
|
|
157
|
+
status: r.status,
|
|
158
|
+
review_state: r.review_state,
|
|
159
|
+
source: r.source,
|
|
160
|
+
created_at: new Date(r.created_at).toISOString(),
|
|
161
|
+
updated_at: new Date(r.updated_at).toISOString(),
|
|
162
|
+
content: r.content,
|
|
163
|
+
});
|
|
164
|
+
return (JSON.stringify({
|
|
165
|
+
format: "open-memex-inventory/2",
|
|
166
|
+
generated_at: data.generatedAt,
|
|
167
|
+
total: data.total,
|
|
168
|
+
scopes: data.scopes.map((s) => ({
|
|
169
|
+
scope: s.scopeKey,
|
|
170
|
+
project: s.projectName,
|
|
171
|
+
memories: s.rows.map(entry),
|
|
172
|
+
})),
|
|
173
|
+
outbox: data.outbox.map(entry),
|
|
174
|
+
hidden: data.hidden,
|
|
175
|
+
}, null, 2) + "\n");
|
|
176
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -85,9 +85,19 @@ 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.
|
|
92
|
+
- When the user asks what you remember (about them, or in this project),
|
|
93
|
+
call memory_list with scope=both and present it conversationally — turn
|
|
94
|
+
the structured fields into plain words (source=user → "you told me
|
|
95
|
+
this"; source=inference → "I inferred this, check me"), and don't read
|
|
96
|
+
raw ids aloud. If they ask to remove or hide an entry, re-run
|
|
97
|
+
memory_list first (numbers from an older listing may have shifted),
|
|
98
|
+
read the candidate back in full, and get a confirmation before calling
|
|
99
|
+
memory_forget: deletion is permanent. If they hesitate, offer soft=true
|
|
100
|
+
(hide it instead — still one-way, but the file survives).
|
|
91
101
|
- Memories default to this project's scope; use the personal scope for facts
|
|
92
102
|
about the user that hold across all projects. When a saved fact becomes
|
|
93
103
|
outdated, call memory_supersede (find the old memory's id with memory_search
|
|
@@ -141,9 +151,15 @@ export async function runMcpServer() {
|
|
|
141
151
|
// D53: session-start outbox state, pushed. The stdio server starts fresh per
|
|
142
152
|
// session, so construction-time state ≈ session-start state.
|
|
143
153
|
const n = outboxDraftCount(scope.key);
|
|
144
|
-
|
|
154
|
+
// D61: the alias guidance rides the handshake only when the install
|
|
155
|
+
// enables capture aliases — otherwise agents would attach aliases the
|
|
156
|
+
// server then drops.
|
|
157
|
+
const aliasGuidance = cfg.captureAliases
|
|
158
|
+
? "\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."
|
|
159
|
+
: "";
|
|
160
|
+
const instructions = (n > 0
|
|
145
161
|
? `${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;
|
|
162
|
+
: SERVER_INSTRUCTIONS) + aliasGuidance;
|
|
147
163
|
const server = new McpServer({ name: "open-memex", version: SERVER_VERSION }, { instructions });
|
|
148
164
|
server.registerTool("memory_add", {
|
|
149
165
|
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
|
+
};
|
package/dist/paths.js
CHANGED
|
@@ -2,9 +2,23 @@ import path from "node:path";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
|
|
7
|
+
* name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
|
|
8
|
+
* fallback so existing setups and scripts keep working. New name wins
|
|
9
|
+
* when both are set.
|
|
10
|
+
*/
|
|
11
|
+
export function homeOverride() {
|
|
12
|
+
if (process.env.OPEN_MEMEX_HOME)
|
|
13
|
+
return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
|
|
6
14
|
if (process.env.MY_O_MEMORY_HOME)
|
|
7
|
-
return path.resolve(process.env.MY_O_MEMORY_HOME);
|
|
15
|
+
return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
function dataRoot() {
|
|
19
|
+
const override = homeOverride();
|
|
20
|
+
if (override)
|
|
21
|
+
return override.dir;
|
|
8
22
|
if (process.platform === "win32") {
|
|
9
23
|
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
10
24
|
return path.join(appData, "open-memex");
|
|
@@ -70,6 +84,24 @@ export function inRepoMemoriesDir(root, memoryDir) {
|
|
|
70
84
|
return dir;
|
|
71
85
|
}
|
|
72
86
|
/** Same as `inRepoMemoriesDir` but never creates the directory. */
|
|
87
|
+
/**
|
|
88
|
+
* True when a memory file lives inside the user's git working tree (the
|
|
89
|
+
* in-repo `.ai/open-memex/` copy) rather than in the local data root.
|
|
90
|
+
*
|
|
91
|
+
* D70: both destructive/corrective surfaces need this — the hard delete
|
|
92
|
+
* already said "commit the deletion so the team sees it" (D64), and the
|
|
93
|
+
* soft hide has to say the same, because a retraction that never gets
|
|
94
|
+
* committed leaves the memory standing on every teammate's clone.
|
|
95
|
+
*/
|
|
96
|
+
export function isInRepoMemoryFile(filePath) {
|
|
97
|
+
if (!filePath)
|
|
98
|
+
return false;
|
|
99
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
100
|
+
return normalized.includes("/.ai/open-memex/") && !normalized.startsWith(root());
|
|
101
|
+
}
|
|
102
|
+
function root() {
|
|
103
|
+
return paths().root.replace(/\\/g, "/");
|
|
104
|
+
}
|
|
73
105
|
export function inRepoMemoriesDirPath(root, memoryDir) {
|
|
74
106
|
return path.join(root, memoryDir);
|
|
75
107
|
}
|