UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

151 lines (150 loc) • 7.75 kB
/** * HTML renderer for the inventory (D68 follow-up): a single local file * a user opens in any browser to read what the store remembers. It * renders the SAME data layer as the text/JSON formats * (`src/inventory.ts`) — one source, three renderings, no drift. * * Design rules (3-C v2) enforced here: * - Every byte of memory content is HTML-escaped. Memory text can come * from an imported bundle or a pasted web page; under file:// an * unescaped <script> would execute in the user's own browser. * - The filter box is plain show/hide over server-rendered entries * (no innerHTML, no network, no external resources). * - History (superseded versions, retracted, archived) is a folded * section with supersede-chain pointers — auditable, never mixed * into "what is remembered now". At scale (>50 current entries) the * scope sections fold too. * - Read-only: no delete buttons, no server callback. Deletion stays a * confirmed conversation; the page says so, with the id to quote. */ import path from "node:path"; import { PERSONAL_SCOPE } from "./scope.js"; import { escapeHtml, relativeTime, reviewStateLabel, sourceLabel, } from "./retrieve/display.js"; const FOLD_THRESHOLD = 50; function entryHtml(row, now) { const meta = [escapeHtml(sourceLabel(row.source))]; const when = relativeTime(row.created_at, now); if (when) meta.push(escapeHtml(when)); if (row.scope_key !== PERSONAL_SCOPE.key) { meta.push(escapeHtml(reviewStateLabel(row.review_state))); if (row.review_state !== "draft") { meta.push(row.file_path.replace(/\\/g, "/").includes("/.ai/open-memex/") ? "file is in the repo" : "file is not in the repo yet"); } } if (row.status !== "active") meta.push(escapeHtml(`marked ${row.status}`)); return ` <article class="entry"> <p class="body">${escapeHtml(row.content.trim())}</p> <p class="meta">${meta.join(" · ")} · <span class="id">id ${escapeHtml(row.id)}</span></p> </article>`; } function hiddenEntryHtml(row, now) { const when = relativeTime(row.created_at, now); const chain = row.superseded_by ? ` · replaced by <span class="id">${escapeHtml(row.superseded_by)}</span>` : ""; return ` <article class="entry hidden-entry"> <p class="body">${escapeHtml(row.content.trim())}</p> <p class="meta">${escapeHtml(row.status)}${when ? ` · saved ${escapeHtml(when)}` : ""}${chain} · <span class="id">id ${escapeHtml(row.id)}</span></p> </article>`; } function sectionHtml(title, blurb, entries, count, fold, open) { if (fold) { return ` <details${open ? " open" : ""}> <summary>${escapeHtml(title)} <span class="count">${count}</span></summary> <p class="blurb">${escapeHtml(blurb)}</p> ${entries} </details>`; } return ` <section> <h2>${escapeHtml(title)} <span class="count">${count}</span></h2> <p class="blurb">${escapeHtml(blurb)}</p> ${entries || ' <p class="empty">(nothing here yet)</p>'} </section>`; } export function renderInventoryHtml(data) { const now = Date.now(); const fold = data.total > FOLD_THRESHOLD; const personalCount = data.scopes .filter((s) => s.scopeKey === PERSONAL_SCOPE.key) .reduce((n, s) => n + s.rows.length, 0); const parts = []; data.scopes.forEach((s, i) => { const isPersonal = s.scopeKey === PERSONAL_SCOPE.key; parts.push(sectionHtml(isPersonal ? "About you — personal" : `Project: ${s.projectName}`, isPersonal ? "These describe you, apply in every project, and never leave this machine." : "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)); }); if (data.outbox.length > 0) { 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)); } if (data.hiddenEntries.length > 0) { const h = data.hiddenEntries.map((r) => hiddenEntryHtml(r, now)).join("\n"); parts.push(` <details> <summary>Replaced &amp; hidden history <span class="count">${data.hiddenEntries.length}</span></summary> <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> ${h} </details>`); } return `<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <!-- D70: the page claims it makes no network requests - enforce it. Inline style/script are the only allowances; anything else (a stray <img src>, a remote font) is blocked by the browser, not just by our promise. --> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"> <title>What OpenMemex remembers</title> <style> body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; padding: 0 1rem; color: #1a1a1a; background: #fdfdfb; } h1 { font-size: 1.4rem; margin-bottom: 0.25rem; } h2, summary { font-size: 1.1rem; font-weight: 600; } summary { cursor: pointer; margin: 1.2rem 0 0.4rem; } .sub { color: #666; font-size: 0.9rem; margin-top: 0; } .count { color: #888; font-weight: normal; font-size: 0.85em; } .blurb { color: #555; font-size: 0.9rem; } .entry { border-top: 1px solid #e5e2d9; padding: 0.75rem 0; } .hidden-entry { opacity: 0.75; } .body { white-space: pre-wrap; margin: 0 0 0.35rem; } .meta { color: #666; font-size: 0.82rem; margin: 0; } .id { font-family: ui-monospace, monospace; font-size: 0.78rem; } .empty { color: #888; } .howto { background: #f4f1e8; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.9rem; } input[type="search"] { width: 100%; padding: 0.5rem; font-size: 1rem; margin: 1rem 0; box-sizing: border-box; } .hidden { display: none; } </style> </head> <body> <h1>What OpenMemex remembers about you</h1> <p class="sub">Generated ${escapeHtml(data.generatedAt)} · ${data.total} memories (${personalCount} personal, ${data.total - personalCount} project incl. outbox). Read-only report.</p> <input type="search" id="filter" placeholder="Filter memories…" aria-label="Filter memories"> <div class="howto"> <strong>Checking my work:</strong> every entry shows where it came from. “I inferred this” means I wrote it from context, not from your words — those are the ones to check. 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. A memory that lives in a repo is only truly gone once the deletion is committed. </div> ${parts.join("\n")} <script> var input = document.getElementById('filter'); input.addEventListener('input', function () { var q = input.value.trim().toLowerCase(); var entries = document.querySelectorAll('.entry'); for (var i = 0; i < entries.length; i++) { var el = entries[i]; el.classList.toggle('hidden', q !== '' && el.textContent.toLowerCase().indexOf(q) === -1); } }); </script> </body> </html> `; } /** Default report location: the data dir, never a working tree. */ export function defaultHtmlOutPath(dataRoot) { return path.join(dataRoot, "inventory.html"); }