open-memex 0.7.0-alpha.9 → 0.7.0

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/src/cli.ts CHANGED
@@ -9,7 +9,8 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
9
9
  import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
10
10
  import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
11
11
  import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
12
- import { search, list, hitStateLabel } from "./retrieve/search.ts";
12
+ import { search, list, countList, hitStateLabel } from "./retrieve/search.ts";
13
+ import { formatInventoryLine, truncationNote } from "./retrieve/display.ts";
13
14
  import {
14
15
  writeMemoryFile,
15
16
  readMemoryFile,
@@ -19,7 +20,7 @@ import {
19
20
  type Frontmatter,
20
21
  } from "./store/markdown.ts";
21
22
  import { loadConfig, configSource } from "./config.ts";
22
- import { paths, projectRoot } from "./paths.ts";
23
+ import { paths, projectRoot, isInRepoMemoryFile } from "./paths.ts";
23
24
  import { redact } from "./redact.ts";
24
25
  import { resolveMcpCommand } from "./init.ts";
25
26
  import fs from "node:fs";
@@ -27,10 +28,22 @@ import path from "node:path";
27
28
  import { execFileSync } from "node:child_process";
28
29
  import { fileURLToPath } from "node:url";
29
30
 
30
- /** True when `target` sits inside a git working tree (D68 guard). */
31
+ /**
32
+ * True when `target` sits inside a git working tree (D68 guard).
33
+ *
34
+ * D70: resolve the nearest EXISTING ancestor first. `git -C` on a
35
+ * directory that does not exist throws, and the old code turned that
36
+ * into "not in a worktree" — a fail-open guard on the one path where
37
+ * personal memories would ride a commit.
38
+ */
31
39
  function isInsideWorkTree(target: string): boolean {
32
40
  try {
33
- const dir = path.dirname(path.resolve(target));
41
+ let dir = path.dirname(path.resolve(target));
42
+ while (!fs.existsSync(dir)) {
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
34
47
  const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
35
48
  stdio: ["ignore", "pipe", "ignore"],
36
49
  encoding: "utf8",
@@ -41,6 +54,31 @@ function isInsideWorkTree(target: string): boolean {
41
54
  }
42
55
  }
43
56
 
57
+ /**
58
+ * D70: the one place that decides where a report may be written, for
59
+ * every `inventory` format. Returns an error message to print, or null
60
+ * when the path is safe. Kept single so the text/json/html paths cannot
61
+ * drift apart (they already had two copies, D68).
62
+ */
63
+ function inventoryWriteRefusal(
64
+ target: string,
65
+ includesPersonal: boolean,
66
+ allowPersonal: boolean,
67
+ ): string | null {
68
+ // Personal content must not ride a commit (the design's hardest rule).
69
+ if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
70
+ return (
71
+ `inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
72
+ `Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`
73
+ );
74
+ }
75
+ const parent = path.dirname(path.resolve(target));
76
+ if (!fs.existsSync(parent)) {
77
+ return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
78
+ }
79
+ return null;
80
+ }
81
+
44
82
  /** Per-command help, printed by `open-memex <command> --help`.
45
83
  AI assistants discover the CLI through --help, so every command needs one. */
46
84
  const COMMAND_HELP: Record<string, string> = {
@@ -51,17 +89,21 @@ Usage: open-memex where
51
89
  Example:
52
90
  open-memex where`,
53
91
 
54
- list: `List memories in a scope, newest first.
92
+ list: `List memories in a scope, newest first, as a numbered inventory.
55
93
 
56
94
  Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
57
95
 
58
96
  Flags:
59
97
  --scope project (default), personal, or both
60
98
  --type filter by memory type
61
- --limit max results
99
+ --limit max results (default 20, max 100)
62
100
  --include active (default: what is currently remembered) or all (audit
63
101
  view: superseded versions, retracted and archived included)
64
102
 
103
+ A cut list says how many entries it left out. Numbers are unique within one
104
+ listing, so "the third one" names exactly one memory - but they are only
105
+ valid for the listing you just ran.
106
+
65
107
  Examples:
66
108
  open-memex list
67
109
  open-memex list --scope personal --limit 20`,
@@ -118,7 +160,8 @@ Example:
118
160
 
119
161
  forget: `Delete a memory by id. With --soft, hide it instead: the memory
120
162
  is retracted (out of lists and search, file kept). Retraction is one-way —
121
- save the content as a new memory to bring it back.
163
+ save the content as a new memory to bring it back. If the memory lives in
164
+ the repo, either action is only local until you commit and push it.
122
165
 
123
166
  Usage: open-memex forget <id> [--soft]
124
167
 
@@ -179,16 +222,18 @@ Example:
179
222
  inventory: `Show everything remembered — personal + this project — in the
180
223
  open: what each memory says, where it came from, and whether it has reached
181
224
  the repo yet. Read-only; deleting stays a confirmed conversation with your
182
- agent. Formats: text (default, diffable) and json (for agents).
225
+ agent. Formats: text (default, diffable), json (for agents), html (a local
226
+ page for your browser; default writes <data dir>/inventory.html).
183
227
 
184
- Usage: open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
228
+ Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
185
229
 
186
- Default scope: both. Without --out the report goes to stdout. A report that
230
+ Default scope: both. Without --out, text/json go to stdout. A report that
187
231
  includes personal memories is refused inside a git working tree unless
188
232
  --allow-personal is passed — personal content must not ride a commit.
189
233
 
190
234
  Example:
191
- open-memex inventory --format json --out ~/inventory.json`,
235
+ open-memex inventory --format json --out ~/inventory.json
236
+ open-memex inventory --format html`,
192
237
 
193
238
  pull: `Pull shared project memories from the git remote: fetch + fast-forward
194
239
  only. Never auto-merges — a diverged branch fails with a clear message and is
@@ -414,7 +459,7 @@ Usage:
414
459
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
415
460
  open-memex resolve [id-or-path]
416
461
  open-memex sync-status
417
- open-memex inventory [--format text|json] [--scope project|personal|both] [--out <path>] [--allow-personal]
462
+ open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
418
463
  open-memex pull
419
464
  open-memex push
420
465
  open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
@@ -910,24 +955,41 @@ async function main() {
910
955
  // D66: --scope both, on par with the memory_list tool — prints each
911
956
  // scope's newest under its own header.
912
957
  const scopes =
913
- flags.scope === "both" ? [project, PERSONAL_SCOPE] : [resolveCliScope(flags, project)];
958
+ flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
959
+ const include = flags.include === "all" ? "all" : "active";
960
+ // D70: the CLI renders the SAME structured inventory the agent sees,
961
+ // numbering runs across the whole listing, and a cut list says how
962
+ // many entries it dropped — `open-memex list` is the surface a
963
+ // sceptical user runs first, so it may not look complete when it isn't.
964
+ let seq = 0;
914
965
  for (const s of scopes) {
915
966
  syncScope(s.key, "cli");
916
967
  const hits = list(s.key, {
917
968
  type: flags.type,
918
969
  limit: flags.limit ? Number(flags.limit) : undefined,
919
- include: flags.include === "all" ? "all" : "active",
970
+ include,
920
971
  });
921
- if (scopes.length > 1) console.log(`## ${s.kind} (${s.key})`);
972
+ const total = countList(s.key, { type: flags.type, include });
973
+ if (scopes.length > 1) {
974
+ console.log(
975
+ s.key === PERSONAL_SCOPE.key
976
+ ? "## About you (personal — stays on this machine, applies everywhere)"
977
+ : `## This project (${s.projectName || s.key})`,
978
+ );
979
+ }
922
980
  if (hits.length === 0) {
923
- console.log(`(no memories in ${s.key})`);
981
+ console.log(
982
+ scopes.length > 1
983
+ ? "(nothing remembered in this scope yet)"
984
+ : `(no memories in ${s.key})`,
985
+ );
924
986
  continue;
925
987
  }
926
988
  for (const h of hits) {
927
- const dep = h.status !== "active" ? ` [${h.status}]` : "";
928
- const rev = hitStateLabel(h);
929
- console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
989
+ console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
930
990
  }
991
+ const note = truncationNote(hits.length, total);
992
+ if (note) console.log(note);
931
993
  }
932
994
  return;
933
995
  }
@@ -1110,10 +1172,20 @@ async function main() {
1110
1172
  // D68: --soft hides (retracts) instead of deleting — same action as
1111
1173
  // the memory_forget tool's soft flag. One-way (D64).
1112
1174
  if (rest.includes("--soft")) {
1175
+ const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
1176
+ | { file_path: string }
1177
+ | undefined;
1113
1178
  try {
1114
1179
  const mf = setStatus(id, "retracted");
1115
1180
  upsertFromFile(mf);
1116
1181
  console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
1182
+ // D70: same honesty as the hard delete — a retraction of an in-repo
1183
+ // memory is a local working-tree edit until it is committed and pushed.
1184
+ if (target && isInRepoMemoryFile(target.file_path)) {
1185
+ console.log(
1186
+ `note: that memory lives in the repo (.ai/open-memex/) — the retraction is only local until you commit and push it; until then the team still sees it.`,
1187
+ );
1188
+ }
1117
1189
  } catch (e) {
1118
1190
  console.error(`hide failed: ${(e as Error).message}`);
1119
1191
  process.exit(1);
@@ -1260,8 +1332,8 @@ function positionalArgs(argv: string[]): string[] {
1260
1332
  syncScope(PERSONAL_SCOPE.key, "cli");
1261
1333
  const flags = parseFlags(rest);
1262
1334
  const format = flags.format ?? "text";
1263
- if (format !== "text" && format !== "json") {
1264
- console.error(`inventory: unknown --format '${format}' (text|json; html arrives separately)`);
1335
+ if (format !== "text" && format !== "json" && format !== "html") {
1336
+ console.error(`inventory: unknown --format '${format}' (text|json|html)`);
1265
1337
  process.exit(2);
1266
1338
  }
1267
1339
  const scopeKeys =
@@ -1271,26 +1343,38 @@ function positionalArgs(argv: string[]): string[] {
1271
1343
  ? [project.key]
1272
1344
  : [PERSONAL_SCOPE.key, project.key];
1273
1345
  const outPath = flags["out"] ?? flags["output"];
1274
- // D68/3-C v2: the report contains personal content. Writing it into
1275
- // a git working tree would ride the next commit off this machine —
1276
- // refuse unless the user explicitly accepts that.
1277
- if (
1278
- outPath &&
1279
- scopeKeys.includes(PERSONAL_SCOPE.key) &&
1280
- flags["allow-personal"] !== "true" &&
1281
- isInsideWorkTree(outPath)
1282
- ) {
1283
- console.error(
1284
- `inventory: ${outPath} is inside a git working tree and this report includes personal memories. ` +
1285
- `Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`,
1286
- );
1287
- process.exit(2);
1288
- }
1346
+ const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
1347
+ const allowPersonal = flags["allow-personal"] === "true";
1289
1348
  try {
1290
1349
  const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.ts");
1350
+ if (format === "html") {
1351
+ const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.ts");
1352
+ const target = outPath ?? defaultHtmlOutPath(paths().root);
1353
+ // D70: one guard for every format (this path had its own copy).
1354
+ const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
1355
+ if (refusal) {
1356
+ console.error(refusal);
1357
+ process.exit(2);
1358
+ }
1359
+ // D70: only the audit surface needs the hidden rows themselves;
1360
+ // text/json report counts, so they must not pay to load them.
1361
+ const data = loadInventory(scopeKeys, { hidden: "entries" });
1362
+ fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
1363
+ console.log(`inventory: ${data.total} memories → ${target}`);
1364
+ console.log(`open it in any browser; the report is read-only.`);
1365
+ return;
1366
+ }
1291
1367
  const data = loadInventory(scopeKeys);
1292
1368
  const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
1293
1369
  if (outPath) {
1370
+ // D68/3-C v2: the report contains personal content. Writing it into
1371
+ // a git working tree would ride the next commit off this machine —
1372
+ // refuse unless the user explicitly accepts that.
1373
+ const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
1374
+ if (refusal) {
1375
+ console.error(refusal);
1376
+ process.exit(2);
1377
+ }
1294
1378
  fs.writeFileSync(outPath, rendered, "utf8");
1295
1379
  console.log(`inventory: ${data.total} memories → ${outPath}`);
1296
1380
  } else {
package/src/index.ts CHANGED
@@ -3,8 +3,10 @@ import {
3
3
  PLUGIN_ID,
4
4
  bootstrapPlugin,
5
5
  captureFromText,
6
+ captureHandoff,
6
7
  injectOnce,
7
8
  } from "./plugin-core.ts";
9
+ import { pickScopeRoot } from "./scope.ts";
8
10
  import { makeTools } from "./tools/memory.ts";
9
11
  import { v2Plugin } from "./opencode-v2.ts";
10
12
 
@@ -19,26 +21,32 @@ import { v2Plugin } from "./opencode-v2.ts";
19
21
  * type-only (D58), and v2's decode is structural.
20
22
  */
21
23
  const server: Plugin = async ({ worktree, directory }) => {
22
- const roots = worktree || directory || process.cwd();
23
- const state = bootstrapPlugin(roots);
24
+ // v1's `worktree` is the filesystem root for non-git folders — never seed a
25
+ // scope on it (D71). `pickScopeRoot` falls back to the opened `directory`.
26
+ const state = bootstrapPlugin(pickScopeRoot(worktree, directory));
24
27
  const tools = makeTools(() => state.scope, state.cfg);
25
28
 
26
29
  return {
27
30
  tool: tools,
28
31
 
29
- async "chat.message"(_input, output) {
32
+ async "chat.message"(input, output) {
30
33
  const parts = (output.parts ?? []) as Array<{ type: string; text?: string }>;
31
34
  const text = parts
32
35
  .map((p) => (p?.type === "text" ? p.text ?? "" : ""))
33
36
  .filter(Boolean)
34
37
  .join("\n");
35
- captureFromText(text, state);
38
+ captureFromText(text, state, input.sessionID ?? "");
36
39
  },
37
40
 
38
41
  async "experimental.chat.system.transform"(input, output) {
39
- injectOnce(input.sessionID ?? "", state, (block) => {
42
+ const sessionID = input.sessionID ?? "";
43
+ injectOnce(sessionID, state, (block) => {
40
44
  output.system.push(block);
41
45
  });
46
+ // D73: runs on every request, so the note lands on the very turn whose
47
+ // message the hook just captured. Delivered once per capture.
48
+ const handoff = captureHandoff(sessionID, state);
49
+ if (handoff) output.system.push(handoff);
42
50
  },
43
51
  };
44
52
  };
@@ -0,0 +1,191 @@
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
+
21
+ import path from "node:path";
22
+
23
+ import { PERSONAL_SCOPE } from "./scope.ts";
24
+ import {
25
+ escapeHtml,
26
+ relativeTime,
27
+ reviewStateLabel,
28
+ sourceLabel,
29
+ } from "./retrieve/display.ts";
30
+ import type { InventoryData, InventoryRow } from "./inventory.ts";
31
+
32
+ const FOLD_THRESHOLD = 50;
33
+
34
+ function entryHtml(row: InventoryRow, now: number): string {
35
+ const meta: string[] = [escapeHtml(sourceLabel(row.source))];
36
+ const when = relativeTime(row.created_at, now);
37
+ if (when) meta.push(escapeHtml(when));
38
+ if (row.scope_key !== PERSONAL_SCOPE.key) {
39
+ meta.push(escapeHtml(reviewStateLabel(row.review_state)));
40
+ if (row.review_state !== "draft") {
41
+ meta.push(
42
+ row.file_path.replace(/\\/g, "/").includes("/.ai/open-memex/")
43
+ ? "file is in the repo"
44
+ : "file is not in the repo yet",
45
+ );
46
+ }
47
+ }
48
+ if (row.status !== "active") meta.push(escapeHtml(`marked ${row.status}`));
49
+ return ` <article class="entry">
50
+ <p class="body">${escapeHtml(row.content.trim())}</p>
51
+ <p class="meta">${meta.join(" · ")} · <span class="id">id ${escapeHtml(row.id)}</span></p>
52
+ </article>`;
53
+ }
54
+
55
+ function hiddenEntryHtml(row: InventoryRow, now: number): string {
56
+ const when = relativeTime(row.created_at, now);
57
+ const chain = row.superseded_by
58
+ ? ` · replaced by <span class="id">${escapeHtml(row.superseded_by)}</span>`
59
+ : "";
60
+ return ` <article class="entry hidden-entry">
61
+ <p class="body">${escapeHtml(row.content.trim())}</p>
62
+ <p class="meta">${escapeHtml(row.status)}${when ? ` · saved ${escapeHtml(when)}` : ""}${chain} · <span class="id">id ${escapeHtml(row.id)}</span></p>
63
+ </article>`;
64
+ }
65
+
66
+ function sectionHtml(
67
+ title: string,
68
+ blurb: string,
69
+ entries: string,
70
+ count: number,
71
+ fold: boolean,
72
+ open: boolean,
73
+ ): string {
74
+ if (fold) {
75
+ return ` <details${open ? " open" : ""}>
76
+ <summary>${escapeHtml(title)} <span class="count">${count}</span></summary>
77
+ <p class="blurb">${escapeHtml(blurb)}</p>
78
+ ${entries}
79
+ </details>`;
80
+ }
81
+ return ` <section>
82
+ <h2>${escapeHtml(title)} <span class="count">${count}</span></h2>
83
+ <p class="blurb">${escapeHtml(blurb)}</p>
84
+ ${entries || ' <p class="empty">(nothing here yet)</p>'}
85
+ </section>`;
86
+ }
87
+
88
+ export function renderInventoryHtml(data: InventoryData): string {
89
+ const now = Date.now();
90
+ const fold = data.total > FOLD_THRESHOLD;
91
+ const personalCount = data.scopes
92
+ .filter((s) => s.scopeKey === PERSONAL_SCOPE.key)
93
+ .reduce((n, s) => n + s.rows.length, 0);
94
+
95
+ const parts: string[] = [];
96
+ data.scopes.forEach((s, i) => {
97
+ const isPersonal = s.scopeKey === PERSONAL_SCOPE.key;
98
+ parts.push(
99
+ sectionHtml(
100
+ isPersonal ? "About you — personal" : `Project: ${s.projectName}`,
101
+ isPersonal
102
+ ? "These describe you, apply in every project, and never leave this machine."
103
+ : "Project memories. Each entry says whether it is still only on this machine (the outbox) or has reached the repo.",
104
+ s.rows.map((r) => entryHtml(r, now)).join("\n"),
105
+ s.rows.length,
106
+ fold,
107
+ isPersonal || i === 0,
108
+ ),
109
+ );
110
+ });
111
+ if (data.outbox.length > 0) {
112
+ parts.push(
113
+ sectionHtml(
114
+ "Not in the repo yet — outbox drafts",
115
+ "These drafts exist only on this machine. They reach the repo through review (submit → promote), never automatically.",
116
+ data.outbox.map((r) => entryHtml(r, now)).join("\n"),
117
+ data.outbox.length,
118
+ fold,
119
+ false,
120
+ ),
121
+ );
122
+ }
123
+ if (data.hiddenEntries.length > 0) {
124
+ const h = data.hiddenEntries.map((r) => hiddenEntryHtml(r, now)).join("\n");
125
+ parts.push(` <details>
126
+ <summary>Replaced &amp; hidden history <span class="count">${data.hiddenEntries.length}</span></summary>
127
+ <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>
128
+ ${h}
129
+ </details>`);
130
+ }
131
+
132
+ return `<!doctype html>
133
+ <html lang="en">
134
+ <head>
135
+ <meta charset="utf-8">
136
+ <meta name="viewport" content="width=device-width, initial-scale=1">
137
+ <!-- D70: the page claims it makes no network requests - enforce it. Inline
138
+ style/script are the only allowances; anything else (a stray <img
139
+ src>, a remote font) is blocked by the browser, not just by our promise. -->
140
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
141
+ <title>What OpenMemex remembers</title>
142
+ <style>
143
+ body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; padding: 0 1rem; color: #1a1a1a; background: #fdfdfb; }
144
+ h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
145
+ h2, summary { font-size: 1.1rem; font-weight: 600; }
146
+ summary { cursor: pointer; margin: 1.2rem 0 0.4rem; }
147
+ .sub { color: #666; font-size: 0.9rem; margin-top: 0; }
148
+ .count { color: #888; font-weight: normal; font-size: 0.85em; }
149
+ .blurb { color: #555; font-size: 0.9rem; }
150
+ .entry { border-top: 1px solid #e5e2d9; padding: 0.75rem 0; }
151
+ .hidden-entry { opacity: 0.75; }
152
+ .body { white-space: pre-wrap; margin: 0 0 0.35rem; }
153
+ .meta { color: #666; font-size: 0.82rem; margin: 0; }
154
+ .id { font-family: ui-monospace, monospace; font-size: 0.78rem; }
155
+ .empty { color: #888; }
156
+ .howto { background: #f4f1e8; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.9rem; }
157
+ input[type="search"] { width: 100%; padding: 0.5rem; font-size: 1rem; margin: 1rem 0; box-sizing: border-box; }
158
+ .hidden { display: none; }
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <h1>What OpenMemex remembers about you</h1>
163
+ <p class="sub">Generated ${escapeHtml(data.generatedAt)} · ${data.total} memories (${personalCount} personal, ${data.total - personalCount} project incl. outbox). Read-only report.</p>
164
+ <input type="search" id="filter" placeholder="Filter memories…" aria-label="Filter memories">
165
+ <div class="howto">
166
+ <strong>Checking my work:</strong> every entry shows where it came from.
167
+ “I inferred this” means I wrote it from context, not from your words — those are the ones to check.
168
+ 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.
169
+ A memory that lives in a repo is only truly gone once the deletion is committed.
170
+ </div>
171
+ ${parts.join("\n")}
172
+ <script>
173
+ var input = document.getElementById('filter');
174
+ input.addEventListener('input', function () {
175
+ var q = input.value.trim().toLowerCase();
176
+ var entries = document.querySelectorAll('.entry');
177
+ for (var i = 0; i < entries.length; i++) {
178
+ var el = entries[i];
179
+ el.classList.toggle('hidden', q !== '' && el.textContent.toLowerCase().indexOf(q) === -1);
180
+ }
181
+ });
182
+ </script>
183
+ </body>
184
+ </html>
185
+ `;
186
+ }
187
+
188
+ /** Default report location: the data dir, never a working tree. */
189
+ export function defaultHtmlOutPath(dataRoot: string): string {
190
+ return path.join(dataRoot, "inventory.html");
191
+ }