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

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.
Files changed (66) hide show
  1. package/AGENTS.md +23 -5
  2. package/CHANGELOG.md +88 -0
  3. package/README.md +46 -15
  4. package/README.zh-CN.md +27 -14
  5. package/dist/.build-stamp +1 -0
  6. package/dist/capture/keywords.js +41 -8
  7. package/dist/cli.js +259 -40
  8. package/dist/config.js +102 -18
  9. package/dist/distill-agents.js +9 -1
  10. package/dist/doctor.js +37 -47
  11. package/dist/export.js +12 -2
  12. package/dist/index.js +4 -2
  13. package/dist/init.js +23 -6
  14. package/dist/inventory-html.js +150 -0
  15. package/dist/inventory.js +176 -0
  16. package/dist/mcp.js +21 -5
  17. package/dist/paths.js +34 -2
  18. package/dist/retrieve/display.js +161 -0
  19. package/dist/retrieve/inject.js +1 -1
  20. package/dist/retrieve/search.js +44 -5
  21. package/dist/review.js +9 -3
  22. package/dist/scope.js +17 -0
  23. package/dist/store/db.js +11 -9
  24. package/dist/store/lifecycle.js +20 -4
  25. package/dist/store/markdown.js +53 -7
  26. package/dist/store/migrate.js +14 -3
  27. package/dist/store/sync.js +5 -3
  28. package/dist/store/v2migrate.js +2 -2
  29. package/dist/submit.js +13 -5
  30. package/dist/tools/memory.js +28 -0
  31. package/dist/tools/ops.js +127 -16
  32. package/docs/CURATOR.md +7 -3
  33. package/docs/V2-DESIGN.md +299 -32
  34. package/docs/memory-visibility-design.md +130 -0
  35. package/package.json +2 -2
  36. package/scripts/build-stamp.mjs +63 -0
  37. package/scripts/smoke-mcp.ts +47 -1
  38. package/scripts/smoke-pure.ts +227 -3
  39. package/scripts/test-full.ts +435 -5
  40. package/skills/open-memex/SKILL.md +53 -1
  41. package/src/capture/keywords.ts +56 -6
  42. package/src/cli.ts +282 -39
  43. package/src/config.ts +104 -17
  44. package/src/distill-agents.ts +19 -0
  45. package/src/doctor.ts +41 -42
  46. package/src/export.ts +14 -2
  47. package/src/index.ts +4 -2
  48. package/src/init.ts +25 -6
  49. package/src/inventory-html.ts +191 -0
  50. package/src/inventory.ts +242 -0
  51. package/src/mcp.ts +21 -5
  52. package/src/paths.ts +35 -1
  53. package/src/retrieve/display.ts +174 -0
  54. package/src/retrieve/inject.ts +1 -1
  55. package/src/retrieve/search.ts +63 -6
  56. package/src/review.ts +9 -2
  57. package/src/scope.ts +18 -0
  58. package/src/store/db.ts +11 -9
  59. package/src/store/lifecycle.ts +24 -3
  60. package/src/store/markdown.ts +60 -7
  61. package/src/store/migrate.ts +15 -2
  62. package/src/store/sync.ts +6 -3
  63. package/src/store/v2migrate.ts +2 -1
  64. package/src/submit.ts +12 -4
  65. package/src/tools/memory.ts +28 -0
  66. package/src/tools/ops.ts +147 -17
package/dist/doctor.js CHANGED
@@ -7,17 +7,25 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.js";
11
- import { paths } from "./paths.js";
10
+ import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.js";
11
+ import { paths, homeOverride } from "./paths.js";
12
12
  import { resolveCwdScope } from "./scope.js";
13
13
  import { db, backendName } from "./store/db.js";
14
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.js";
15
+ // D64: the MCP server exposes eleven tools; checking only the five the
16
+ // opencode plugin carries let a half-registered server pass as healthy.
15
17
  const EXPECTED_TOOLS = [
16
18
  "memory_add",
17
19
  "memory_search",
18
20
  "memory_list",
19
21
  "memory_supersede",
20
22
  "memory_forget",
23
+ "memory_status",
24
+ "memory_submit",
25
+ "memory_propose",
26
+ "memory_promote",
27
+ "memory_resolve",
28
+ "memory_pr_status",
21
29
  ];
22
30
  function nodeCheck() {
23
31
  const [major, minor] = process.versions.node.split(".").map(Number);
@@ -54,7 +62,13 @@ function storageCheck() {
54
62
  try {
55
63
  const p = paths();
56
64
  fs.accessSync(p.memories, fs.constants.W_OK);
57
- return { name: "storage", ok: true, detail: `${p.root} (writable)` };
65
+ // D62: when the root came from the legacy env var, say so (still ok —
66
+ // the fallback is supported, doctor just names the current one).
67
+ const via = homeOverride()?.via;
68
+ const note = via === "MY_O_MEMORY_HOME"
69
+ ? " (via legacy MY_O_MEMORY_HOME — OPEN_MEMEX_HOME is the current name)"
70
+ : "";
71
+ return { name: "storage", ok: true, detail: `${p.root} (writable)${note}` };
58
72
  }
59
73
  catch (err) {
60
74
  return { name: "storage", ok: false, detail: String(err) };
@@ -401,7 +415,8 @@ function mcpCheck() {
401
415
  });
402
416
  }
403
417
  /** Parse JSON tolerating line/block comments plus trailing commas
404
- * (VS Code's settings.json is JSONC). Health-check grade, not a full parser. */
418
+ * (VS Code's settings.json is JSONC). Uses config.ts's string-aware
419
+ * stripper — one parser, not two (P0 review, 2026-10-03). */
405
420
  function parseLenientJson(raw) {
406
421
  try {
407
422
  return JSON.parse(raw);
@@ -409,47 +424,7 @@ function parseLenientJson(raw) {
409
424
  catch {
410
425
  /* fall through to comment stripping */
411
426
  }
412
- let out = "";
413
- let i = 0;
414
- let inStr = false;
415
- let esc = false;
416
- while (i < raw.length) {
417
- const c = raw[i];
418
- const n = raw[i + 1];
419
- if (inStr) {
420
- out += c;
421
- if (esc)
422
- esc = false;
423
- else if (c === "\\")
424
- esc = true;
425
- else if (c === '"')
426
- inStr = false;
427
- i++;
428
- continue;
429
- }
430
- if (c === '"') {
431
- inStr = true;
432
- out += c;
433
- i++;
434
- continue;
435
- }
436
- if (c === "/" && n === "/") {
437
- while (i < raw.length && raw[i] !== "\n")
438
- i++;
439
- continue;
440
- }
441
- if (c === "/" && n === "*") {
442
- i += 2;
443
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
444
- i++;
445
- i += 2;
446
- continue;
447
- }
448
- out += c;
449
- i++;
450
- }
451
- out = out.replace(/,\s*([}\]])/g, "$1");
452
- return JSON.parse(out);
427
+ return JSON.parse(stripJsonComments(raw));
453
428
  }
454
429
  function vscodeSettingsPath() {
455
430
  const home = os.homedir();
@@ -559,13 +534,28 @@ export async function runDoctor() {
559
534
  function legacyCheck() {
560
535
  const name = "legacy my-o-memory";
561
536
  const found = [];
562
- const envHome = process.env.MY_O_MEMORY_HOME;
537
+ // D62: the override may arrive via either env var name; check the value
538
+ // in effect, not just the legacy one.
539
+ const envHome = homeOverride()?.dir;
563
540
  if (envHome && envHome.includes(LEGACY_PACKAGE_NAME))
564
- found.push(`MY_O_MEMORY_HOME points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
541
+ found.push(`memory home points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
565
542
  const root = paths().root;
566
543
  const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
567
544
  if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory())
568
545
  found.push(`legacy data dir ${legacyDir} — merge it with \`open-memex migrate --to-v2\``);
546
+ // D64: v1 stored personal memories under the scope dir "user"; v2 reads
547
+ // only "personal". Files left there are invisible to every read path —
548
+ // an upgrader silently sees an empty personal scope. Surface them here;
549
+ // the migration itself stays explicit (no silent moves).
550
+ try {
551
+ const userDir = path.join(root, "memories", "user");
552
+ if (fs.existsSync(userDir) &&
553
+ fs.readdirSync(userDir).some((f) => f.endsWith(".md")))
554
+ found.push("v1 personal memories under " + userDir + " are invisible to v2 reads — convert them with `open-memex migrate --to-v2` (it moves them to the personal scope; preview with --dry-run)");
555
+ }
556
+ catch {
557
+ /* unreadable — not this check's problem */
558
+ }
569
559
  for (const f of [opencodeGlobalConfigPath(), userMcpConfigPath("vscode"), userMcpConfigPath("cursor")]) {
570
560
  try {
571
561
  if (fs.existsSync(f) && fs.readFileSync(f, "utf8").includes(LEGACY_PACKAGE_NAME))
package/dist/export.js CHANGED
@@ -117,10 +117,20 @@ export function importBundle(bundlePath, opts) {
117
117
  if (manifest.format !== EXPORT_FORMAT) {
118
118
  fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
119
119
  }
120
- const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
120
+ const result = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] };
121
121
  const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
122
+ // The manifest comes from whoever sent the bundle: entry.file must
123
+ // stay inside the staging dir (P0 review, 2026-10-03 — "../" entries
124
+ // otherwise read arbitrary .md files into the store). realpath also
125
+ // catches symlinks planted by the tarball.
126
+ const stageRoot = fs.realpathSync(stage);
122
127
  for (const entry of manifest.memories ?? []) {
123
- const src = path.join(stage, entry.file);
128
+ const joined = path.resolve(path.join(stage, entry.file));
129
+ const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined;
130
+ if (!src.startsWith(stageRoot + path.sep)) {
131
+ result.rejectedPaths.push(entry.file);
132
+ continue;
133
+ }
124
134
  const mf = readMemoryFile(src);
125
135
  if (!mf)
126
136
  continue;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
2
+ import { pickScopeRoot } from "./scope.js";
2
3
  import { makeTools } from "./tools/memory.js";
3
4
  import { v2Plugin } from "./opencode-v2.js";
4
5
  /**
@@ -12,8 +13,9 @@ import { v2Plugin } from "./opencode-v2.js";
12
13
  * type-only (D58), and v2's decode is structural.
13
14
  */
14
15
  const server = async ({ worktree, directory }) => {
15
- const roots = worktree || directory || process.cwd();
16
- const state = bootstrapPlugin(roots);
16
+ // v1's `worktree` is the filesystem root for non-git folders — never seed a
17
+ // scope on it (D71). `pickScopeRoot` falls back to the opened `directory`.
18
+ const state = bootstrapPlugin(pickScopeRoot(worktree, directory));
17
19
  const tools = makeTools(() => state.scope, state.cfg);
18
20
  return {
19
21
  tool: tools,
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. At session start, call \`memory_status\` once to check. When
60
- drafts are waiting, summarize them (one line each) and ask the user which
61
- ones to sync into the repo; sync NOTHING the user did not name. If you
62
- already asked about these drafts this session, don't ask again. When the
63
- server reports none waiting, do nothing.
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
@@ -286,7 +287,17 @@ function opencodePluginDir() {
286
287
  * loads open-memex in every project with no per-project init.
287
288
  */
288
289
  export function opencodeGlobalConfigPath(home = os.homedir()) {
289
- return path.join(opencodeConfigDir(home, process.env.XDG_CONFIG_HOME), "opencode.json");
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");
290
301
  }
291
302
  /**
292
303
  * Pure merge of the open-memex plugin URL into a parsed opencode config doc.
@@ -470,6 +481,7 @@ function writeVisualStudioMcpJson(root, force) {
470
481
  });
471
482
  return null;
472
483
  }
484
+ doc = parsed;
473
485
  }
474
486
  const section = (doc["servers"] ??= {});
475
487
  // F30: drop the pre-rename server entry — it points at the OLD data dir.
@@ -805,6 +817,11 @@ export async function initProject(opts) {
805
817
  const injectOnFirstTurn = await askBool("Inject relevant memories when a session starts?", DEFAULT_CONFIG.injectOnFirstTurn);
806
818
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
807
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;
808
825
  if (Object.keys(patch).length > 0) {
809
826
  const file = saveConfig(patch);
810
827
  console.log(` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`);
@@ -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 &amp; 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 — try a few keyword
89
- variants (including the user's own language) when the first search comes up
90
- empty.
88
+ may have told you before, call memory_search first — search well: break the
89
+ question into its concepts and try 2–3 phrasings per concept (synonyms, the
90
+ user's other language, shorter keyword forms), and check the other scope too,
91
+ before concluding nothing is stored.
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
- const instructions = n > 0
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,
package/dist/paths.js CHANGED
@@ -2,9 +2,23 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import { execFileSync } from "node:child_process";
5
- function dataRoot() {
5
+ /**
6
+ * D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
7
+ * name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
8
+ * fallback so existing setups and scripts keep working. New name wins
9
+ * when both are set.
10
+ */
11
+ export function homeOverride() {
12
+ if (process.env.OPEN_MEMEX_HOME)
13
+ return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
6
14
  if (process.env.MY_O_MEMORY_HOME)
7
- return path.resolve(process.env.MY_O_MEMORY_HOME);
15
+ return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
16
+ return null;
17
+ }
18
+ function dataRoot() {
19
+ const override = homeOverride();
20
+ if (override)
21
+ return override.dir;
8
22
  if (process.platform === "win32") {
9
23
  const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
10
24
  return path.join(appData, "open-memex");
@@ -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
  }