open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
177 lines (176 loc) • 7.53 kB
JavaScript
/**
* `open-memex inventory` (D68): what the store remembers, in the open.
* One data layer, three renderers (text / json / html). The HTML
* renderer lives in inventory-html.ts; this module owns the data and
* the two renderers an agent or a terminal consumes directly.
*
* Design rules (3-C v2) enforced here:
* - Default scope is personal + the current project — "can't see it"
* is the disease this surface treats.
* - Nothing truncates silently: every section states its totals, and
* hidden history (superseded versions, retracted, archived) is
* counted and pointed at, never just omitted.
* - Drafts that have not reached the repo (the outbox) are their own
* section, consistent with what memory_status reports.
* - The report is read-only. Deleting stays a confirmed conversation.
*/
import { db } from "./store/db.js";
import { PERSONAL_SCOPE } from "./scope.js";
import { formatInventoryLine } from "./retrieve/display.js";
const BASE_COLS = `id, scope_key, project_name, type, status, review_state,
source, created_at, updated_at, content, file_path`;
/**
* Load the inventory for the given scopes. `projectScopeKey` separates the
* outbox drafts (they belong to that scope's draft rows).
*
* D70: `hidden: "entries"` fetches the hidden rows themselves (HTML audit
* view); the default keeps the cheap COUNT query, because pulling every
* superseded body for a text report nobody reads is pure waste.
*/
export function loadInventory(scopeKeys, opts = {}) {
const inList = scopeKeys.map(() => "?").join(",");
const current = db()
.prepare(`SELECT ${BASE_COLS} FROM memories
WHERE scope_key IN (${inList}) AND status IN ('active', 'deprecated')
ORDER BY scope_key, updated_at DESC`)
.all(...scopeKeys);
const hiddenEntries = opts.hidden === "entries"
? db()
.prepare(`SELECT ${BASE_COLS}, superseded_by FROM memories
WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
ORDER BY updated_at DESC`)
.all(...scopeKeys)
: [];
const hidden = { superseded: 0, retracted: 0, archived: 0 };
if (hiddenEntries.length > 0) {
for (const r of hiddenEntries) {
if (r.status === "superseded")
hidden.superseded++;
if (r.status === "retracted")
hidden.retracted++;
if (r.status === "archived")
hidden.archived++;
}
}
else {
const counted = db()
.prepare(`SELECT status, COUNT(*) AS n FROM memories
WHERE scope_key IN (${inList}) AND status IN ('superseded', 'retracted', 'archived')
GROUP BY status`)
.all(...scopeKeys);
for (const r of counted) {
if (r.status === "superseded")
hidden.superseded = r.n;
if (r.status === "retracted")
hidden.retracted = r.n;
if (r.status === "archived")
hidden.archived = r.n;
}
}
const outbox = current.filter((r) => r.scope_key !== PERSONAL_SCOPE.key && r.review_state === "draft");
const outboxIds = new Set(outbox.map((r) => r.id));
const byScope = new Map();
for (const r of current) {
if (outboxIds.has(r.id))
continue;
const arr = byScope.get(r.scope_key) ?? [];
arr.push(r);
byScope.set(r.scope_key, arr);
}
// Personal first, then project scopes by key for a stable order.
// D70: every requested scope appears, including one with nothing in it —
// "this project has no memories" is part of the answer, and dropping the
// section made the report look like the project was never looked at.
const orderedKeys = [
...scopeKeys.filter((k) => k === PERSONAL_SCOPE.key),
...scopeKeys.filter((k) => k !== PERSONAL_SCOPE.key).sort(),
];
const scopes = orderedKeys.map((k) => {
const rows = byScope.get(k) ?? [];
return {
scopeKey: k,
projectName: rows[0]?.project_name || k,
rows,
};
});
return {
generatedAt: new Date().toISOString(),
scopes,
outbox,
hidden,
hiddenEntries,
total: current.length,
};
}
function hiddenLine(data) {
const { superseded, retracted, archived } = data.hidden;
if (superseded + retracted + archived === 0)
return "";
return `Not shown: ${superseded} superseded version(s), ${retracted} retracted, ${archived} archived — inspect with \`open-memex list --include all\`.`;
}
function entriesText(rows, startAt = 0) {
if (rows.length === 0)
return "(nothing remembered in this scope yet)";
return rows
.map((r, i) => formatInventoryLine(startAt + i, { ...r, snippet: r.content }, PERSONAL_SCOPE.key))
.join("\n");
}
/** Plain-text rendering: diffable in any terminal or CI log. */
export function renderInventoryText(data) {
const out = [];
out.push(`What OpenMemex remembers — ${data.generatedAt}`);
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).`);
// D70: one running counter across every section, so a number quoted back
// ("delete #3") names exactly one entry in this report.
let seq = 0;
for (const s of data.scopes) {
const title = s.scopeKey === PERSONAL_SCOPE.key
? "About you — personal (never leaves this machine)"
: `Project: ${s.projectName}`;
out.push("", `## ${title} (${s.rows.length})`, entriesText(s.rows, seq));
seq += s.rows.length;
}
if (data.outbox.length > 0) {
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));
seq += data.outbox.length;
}
const hidden = hiddenLine(data);
if (hidden)
out.push("", hidden);
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).");
return out.join("\n") + "\n";
}
/**
* JSON rendering: same data, for agents. Mirrors the export field names.
*
* D70: format `/2` drops the absolute `file` path. This artifact is meant
* to be handed to an agent — i.e. pasted into a cloud conversation — and it
* carried the user's home directory and name with it. The id locates the
* file (`open-memex where` prints the data root).
*/
export function renderInventoryJson(data) {
const entry = (r) => ({
id: r.id,
scope: r.scope_key,
project: r.project_name,
type: r.type,
status: r.status,
review_state: r.review_state,
source: r.source,
created_at: new Date(r.created_at).toISOString(),
updated_at: new Date(r.updated_at).toISOString(),
content: r.content,
});
return (JSON.stringify({
format: "open-memex-inventory/2",
generated_at: data.generatedAt,
total: data.total,
scopes: data.scopes.map((s) => ({
scope: s.scopeKey,
project: s.projectName,
memories: s.rows.map(entry),
})),
outbox: data.outbox.map(entry),
hidden: data.hidden,
}, null, 2) + "\n");
}