open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
192 lines (181 loc) • 8.16 kB
text/typescript
/**
* 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.ts";
import {
escapeHtml,
relativeTime,
reviewStateLabel,
sourceLabel,
} from "./retrieve/display.ts";
import type { InventoryData, InventoryRow } from "./inventory.ts";
const FOLD_THRESHOLD = 50;
function entryHtml(row: InventoryRow, now: number): string {
const meta: string[] = [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: InventoryRow, now: number): string {
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: string,
blurb: string,
entries: string,
count: number,
fold: boolean,
open: boolean,
): string {
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: InventoryData): string {
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: string[] = [];
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 & 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 `
<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: string): string {
return path.join(dataRoot, "inventory.html");
}