@staix/vault-mem 0.2.3 → 0.3.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/setup.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
@@ -14,7 +14,9 @@ const MAX_HOST_CONFIG = 4 * 1024 * 1024;
14
14
  export interface McpEntry { command: string; args: string[] }
15
15
  interface HostFile { target: SetupTarget; file: string; format: "json" | "toml"; key: string }
16
16
  export interface SetupAction { target: SetupTarget; file: string; change: "add" | "replace" | "unchanged" | "restore" | "remove" | "refused" | "none"; reason?: string }
17
- export interface SetupReport { mode: SetupMode; applied: boolean; entry: McpEntry; actions: SetupAction[]; notes: string[] }
17
+ export interface ConsoleAction { file: string; module: string; change: "add" | "replace" | "unchanged" | "remove" | "none" | "refused"; reason?: string }
18
+ export interface SetupReport { mode: SetupMode; applied: boolean; entry: McpEntry; root?: InstallRoot; actions: SetupAction[]; console?: ConsoleAction; notes: string[] }
19
+ export interface InstallRoot { path: string; version: string }
18
20
  interface Receipt { version: 1; target: SetupTarget; file: string; existed: boolean; keyExisted?: boolean; prior: unknown; priorToml?: string[]; installed: McpEntry }
19
21
 
20
22
  function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<SetupTarget, HostFile> {
@@ -36,12 +38,27 @@ function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<SetupTarget, HostFil
36
38
  agy: { target: "agy", file: join(resolve(cwd), ".agents", "mcp_config.json"), format: "json", key: "mcpServers" },
37
39
  };
38
40
  }
39
- function readText(file: string): string | null {
40
- let stat;
41
- try { stat = lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
42
- if (!stat.isFile()) throw new Error(`${file}: expected a plain file (links are refused)`);
43
- if (stat.size > MAX_HOST_CONFIG) throw new Error(`${file}: exceeds 4 MiB`);
44
- return readFileSync(file, "utf8");
41
+ const MAX_PACKAGE_JSON = 64 * 1024;
42
+ /** Bounded plain-file read on one descriptor: links, FIFOs and other non-regular files are refused, never read. */
43
+ function readText(file: string, max = MAX_HOST_CONFIG): string | null {
44
+ let fd: number;
45
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
46
+ catch (error) {
47
+ const code = (error as NodeJS.ErrnoException).code;
48
+ if (code === "ENOENT") return null;
49
+ if (code === "ELOOP") throw new Error(`${file}: expected a plain file (links are refused)`);
50
+ throw error;
51
+ }
52
+ try {
53
+ const stat = fstatSync(fd), limit = max >= 1024 * 1024 ? `${max / 1024 / 1024} MiB` : `${max / 1024} KiB`;
54
+ if (!stat.isFile()) throw new Error(`${file}: expected a plain file (links are refused)`);
55
+ if (stat.size > max) throw new Error(`${file}: exceeds ${limit}`);
56
+ const buffer = Buffer.alloc(max + 1);
57
+ let size = 0, n: number;
58
+ while (size < buffer.length && (n = readSync(fd, buffer, size, buffer.length - size, null)) > 0) size += n;
59
+ if (size > max) throw new Error(`${file}: exceeds ${limit}`);
60
+ return buffer.subarray(0, size).toString("utf8");
61
+ } finally { closeSync(fd); }
45
62
  }
46
63
  function atomicWrite(file: string, text: string | null): void {
47
64
  if (text === null) { unlinkSync(file); return; }
@@ -154,23 +171,44 @@ export function stableRuntime(execPath: string = process.execPath, onPath: strin
154
171
  if (!onPath || !isAbsolute(onPath)) return execPath;
155
172
  try { return realpathSync(onPath) === realpathSync(execPath) ? onPath : execPath; } catch { return execPath; }
156
173
  }
157
- export function defaultEntry(): McpEntry {
158
- return { command: stableRuntime(), args: ["--smol", fileURLToPath(new URL("../bin/vmem.js", import.meta.url)), "mcp"] };
174
+ /** The root of the running install: the directory that holds `bin/`, `src/` and `package.json`. */
175
+ const OWN_ROOT = fileURLToPath(new URL("..", import.meta.url));
176
+ export function defaultEntry(root: string = OWN_ROOT): McpEntry {
177
+ return { command: stableRuntime(), args: ["--smol", join(root, "bin", "vmem.js"), "mcp"] };
178
+ }
179
+ /**
180
+ * A vault-mem install root to register, such as `<install>/releases/<version>/`. It is resolved to its real
181
+ * path, so a `current` link registers the release it names and an upgrade beside it never changes what running
182
+ * hosts load.
183
+ */
184
+ export function installRoot(root: string): InstallRoot {
185
+ if (!isAbsolute(root)) throw new Error("setup: --root must be an absolute path");
186
+ let path: string, text: string | null, pkg: { name?: unknown; version?: unknown };
187
+ try { path = realpathSync(root); text = readText(join(path, "package.json"), MAX_PACKAGE_JSON); }
188
+ catch (error) { throw new Error(`setup: ${root}: package.json is not readable (${error instanceof Error ? error.message : "read failed"})`); }
189
+ if (text === null) throw new Error(`setup: ${root} has no package.json`);
190
+ try { pkg = JSON.parse(text); } catch { throw new Error(`setup: ${root}: package.json is not valid JSON`); }
191
+ if (!pkg || typeof pkg !== "object") throw new Error(`setup: ${root} is not a vault-mem install root`);
192
+ if (pkg.name !== "@staix/vault-mem" || typeof pkg.version !== "string") throw new Error(`setup: ${root} is not a vault-mem install root`);
193
+ try { if (!statSync(join(path, "bin", "vmem.js")).isFile()) throw new Error(); }
194
+ catch { throw new Error(`setup: ${root} has no bin/vmem.js`); }
195
+ return { path, version: pkg.version };
159
196
  }
160
197
  /**
161
198
  * Plan, apply or undo vmem MCP registration. Dry-run writes nothing. Apply is idempotent, edits only
162
199
  * the `vmem` server entry and records the prior value. Undo restores that prior value only while the
163
200
  * entry is still the one setup installed; a later user edit is left alone and reported as refused.
164
201
  */
165
- export function runSetup(target: string, mode: SetupMode, options: { home: string; env?: NodeJS.ProcessEnv; cwd?: string; entry?: McpEntry }): SetupReport {
166
- const env = options.env ?? process.env, entry = options.entry ?? defaultEntry();
202
+ export function runSetup(target: string, mode: SetupMode, options: { home: string; env?: NodeJS.ProcessEnv; cwd?: string; entry?: McpEntry; root?: string }): SetupReport {
203
+ const env = options.env ?? process.env, root = options.root === undefined ? undefined : installRoot(options.root);
204
+ const entry = options.entry ?? defaultEntry(root?.path);
167
205
  if (target !== "all" && !(SETUP_TARGETS as readonly string[]).includes(target)) throw new Error(`setup: unknown target ${target}; use ${SETUP_TARGETS.join("|")}|all`);
168
206
  if (!isAbsolute(entry.command) || entry.args.some(arg => arg.includes("\0"))) throw new Error("setup: MCP command must be absolute");
169
207
  const table = hosts(env, options.cwd ?? process.cwd());
170
208
  const targets = target === "all" ? SETUP_TARGETS.filter(t => t !== "agy") : [target as SetupTarget];
171
- const report: SetupReport = { mode, applied: false, entry, actions: [], notes: [
209
+ const report: SetupReport = { mode, applied: false, entry, ...(root ? { root } : {}), actions: [], notes: [
172
210
  "Registers the stdio server `vmem mcp`; restart the host session to load it.",
173
- "No console plugin manifest is written: vmem ships no console plugin yet.",
211
+ "Writes the acon console plugin manifest ($AGENTHUB_HOME/console/plugins/vmem.json); re-run setup after upgrading vmem.",
174
212
  ] };
175
213
  if (target === "all") report.notes.push("`all` covers user-level hosts; run `vmem setup agy` inside a workspace for Antigravity.");
176
214
  for (const name of targets) {
@@ -214,6 +252,47 @@ export function runSetup(target: string, mode: SetupMode, options: { home: strin
214
252
  action.change = "refused"; action.reason = error instanceof Error ? error.message : "setup failed";
215
253
  }
216
254
  }
217
- report.applied = mode !== "dry-run" && report.actions.some(a => !["none", "unchanged", "refused"].includes(a.change));
255
+ report.console = consoleManifest(mode, env, root?.path);
256
+ report.applied = mode !== "dry-run" && [...report.actions, report.console].some(a => !["none", "unchanged", "refused"].includes(a.change));
218
257
  return report;
219
258
  }
259
+
260
+ /** The acon plugin manifest: `{api, tool, module, sha256}` in the family home, dir 0700 and file 0600 as acon requires. */
261
+ export function consoleManifest(mode: SetupMode, env: NodeJS.ProcessEnv = process.env, root: string = OWN_ROOT): ConsoleAction {
262
+ const family = env.AGENTHUB_HOME?.trim() ? resolve(env.AGENTHUB_HOME) : join(env.HOME?.trim() || homedir(), ".agenthub");
263
+ const dir = join(family, "console", "plugins"), file = join(dir, "vmem.json");
264
+ const module = join(root, "src", "ui", "plugin.ts");
265
+ const action: ConsoleAction = { file, module, change: "none" };
266
+ try {
267
+ const next = { api: 1, tool: "vmem", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") };
268
+ // Apply acon's own loader rules (agent-console scanPlugins): the directory and manifest must be real, owned by you
269
+ // and not writable by others. Kinds and owners are refused here; modes are fixed on apply.
270
+ for (const [path, kind] of [[dir, "directory"], [file, "file"]] as const) {
271
+ let info;
272
+ try { info = lstatSync(path); } catch { continue; }
273
+ if (kind === "directory" ? !info.isDirectory() : !info.isFile()) throw new Error(`${path}: acon needs a real ${kind}, not a link or other entry`);
274
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw new Error(`${path}: owned by another user; acon refuses it`);
275
+ }
276
+ const text = readText(file);
277
+ const current = text === null ? undefined : JSON.parse(text) as Record<string, unknown>;
278
+ if (mode === "undo") {
279
+ if (!current) { action.reason = "no manifest"; return action; }
280
+ if (current.module !== module) { action.change = "refused"; action.reason = "manifest points at another vmem install; left as is"; return action; }
281
+ unlinkSync(file); action.change = "remove"; return action;
282
+ }
283
+ const loose = (path: string, want: number) => { try { return (lstatSync(path).mode & 0o777) !== want; } catch { return false; } };
284
+ const modesOk = !loose(dir, 0o700) && !loose(file, 0o600);
285
+ if (isDeepStrictEqual(current, next) && modesOk) { action.change = "unchanged"; return action; }
286
+ action.change = current ? "replace" : "add";
287
+ if (!modesOk) action.reason = "permissions: plugin directory 0700, manifest 0600";
288
+ if (mode === "apply") {
289
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
290
+ chmodSync(dir, 0o700);
291
+ atomicWrite(file, JSON.stringify(next, null, 2) + "\n");
292
+ chmodSync(file, 0o600);
293
+ }
294
+ } catch (error) {
295
+ action.change = "refused"; action.reason = error instanceof Error ? error.message : "manifest write failed";
296
+ }
297
+ return action;
298
+ }
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import { lstatSync, readdirSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -183,6 +184,7 @@ export function communityToCandidate(cid: number, members: readonly string[], gr
183
184
  let daySpan = 1, uniqueDays = 1;
184
185
  if (times.length >= 2) {
185
186
  daySpan = Math.max(1, Math.floor((times.at(-1)! - times[0]!) / DAY));
187
+ // Python `.date()` on an aware datetime keeps its own offset, so amem/vault `...Z` times count UTC days, as here.
186
188
  uniqueDays = new Set(times.map(t => new Date(t).toISOString().slice(0, 10))).size;
187
189
  }
188
190
  const membership = new Set(members), counts = new Map<string, number>();
@@ -446,7 +448,7 @@ export async function mineSkills(home: string, options: MineOptions): Promise<Mi
446
448
  const amem = await readAmemForMining({ env: options.env, since, ...(options.project ? { project: options.project } : {}), piiPatterns: local.config.piiPatterns ?? [] });
447
449
  return mineRecords({
448
450
  vault: options.vault, days, since, projects: amem.projects, records: [...amem.records, ...local.records], vocab, registry, skills, minSize, minNovelty, piiPatterns: local.config.piiPatterns ?? [],
449
- today: now.toISOString().slice(0, 10), dropped: { policy: amem.screened, unreadable: amem.unreadable + local.unreadable, truncatedCapture: amem.truncatedCapture },
451
+ today: localDate(now), dropped: { policy: amem.screened, unreadable: amem.unreadable + local.unreadable, truncatedCapture: amem.truncatedCapture },
450
452
  });
451
453
  }
452
454
 
package/src/tidy.ts CHANGED
@@ -4,6 +4,7 @@ import { hostname } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import type { VaultSpec, VmemConfig } from "./config.ts";
7
+ import { unenforceablePii } from "./config.ts";
7
8
  import { emitFrontmatter, parseFrontmatter } from "./frontmatter.ts";
8
9
  import { extractLinks, normalizeTarget } from "./links.ts";
9
10
  import { isDenied } from "./platform.ts";
@@ -335,6 +336,8 @@ export function tidyVault(home: string, request: TidyRequest, options: { env?: N
335
336
  const config = loadRegistry(home), vault = config.vaults.find(v => v.id === request.vault);
336
337
  if (!vault) return { ...result, issues: [issue("V04", "unknown vault id")] };
337
338
  if (vault.write === "none" || (request.channel === "mcp" && vault.write !== "mcp")) return { ...result, issues: [issue("POLICY", "vault write policy refuses this channel")] };
339
+ const unenforceable = unenforceablePii(vault);
340
+ if (unenforceable) return { ...result, issues: [issue("PII_POLICY", unenforceable)] };
338
341
  const root = realpathSync(vault.path);
339
342
  if (root !== vault.path || isDenied(root, config.denyPaths)) return { ...result, issues: [issue("V06", "vault path denied or changed")] };
340
343
  // The LINT event must land in the vault this run read and wrote, even if the registry is changed meanwhile.
@@ -0,0 +1,16 @@
1
+ .vmem { display: flex; flex-direction: column; gap: var(--acon-space-3); word-break: keep-all; }
2
+ .vmem-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); }
3
+ .vmem-inline { display: inline-flex; gap: var(--acon-space-2); }
4
+ .vmem-input { min-height: 32px; min-width: 0; padding: 0 10px; border: 1px solid var(--acon-line-2); border-radius: var(--acon-radius-2); background: var(--acon-surface); color: var(--acon-text); font: inherit; }
5
+ .vmem-bar input[type="search"] { flex: 1; max-width: 480px; }
6
+ .vmem-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); }
7
+ .vmem-head h2 { margin: 0; font-size: 17px; }
8
+ .vmem-sub { margin: 0; color: var(--acon-text-2); }
9
+ .vmem-body { margin: var(--acon-space-2) 0 0; padding: var(--acon-space-3); max-height: 60vh; overflow: auto; white-space: pre-wrap; word-break: break-word; background: var(--acon-surface); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); font: 13px/1.5 var(--acon-font-mono); }
10
+ .vmem-mono { margin: 0; color: var(--acon-text-2); font: 12px var(--acon-font-mono); word-break: break-all; }
11
+ .vmem details > summary { cursor: pointer; font-weight: 600; padding: var(--acon-space-1) 0; }
12
+ @media (max-width: 600px) { .vmem-inline { width: 100%; } .vmem-inline .vmem-input { flex: 1; } }
13
+ .vmem-form { display: flex; flex-direction: column; gap: var(--acon-space-2); max-width: 560px; }
14
+ .vmem-form h2 { margin: 0; font-size: 15px; }
15
+ .vmem-form .btn { align-self: flex-start; }
16
+ .vmem-check { display: inline-flex; align-items: center; gap: 4px; color: var(--acon-text-2); }
@@ -0,0 +1,137 @@
1
+ // vmem console panels (acon api 1). Runs under the shell's strict CSP: data is rendered as text only.
2
+ const h = (tag, props = {}, ...kids) => {
3
+ const el = document.createElement(tag);
4
+ for (const [k, v] of Object.entries(props)) {
5
+ if (v === undefined || v === null || v === false) continue;
6
+ if (k === "text") el.textContent = String(v);
7
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
8
+ else el.setAttribute(k, v === true ? "" : String(v));
9
+ }
10
+ el.append(...kids.flat().filter((k) => k !== null && k !== undefined && k !== false));
11
+ return el;
12
+ };
13
+ const empty = (title, hint) => h("div", { class: "empty" }, h("strong", { text: title }), hint ? h("span", { text: hint }) : null);
14
+ const err = (text) => h("p", { class: "notice", role: "alert", text });
15
+ const rows = (items, row) => (items.length ? h("ul", { class: "rows" }, items.map(row)) : null);
16
+ const row = (title, sub, onOpen, ...verbs) => h("li", { class: "row" },
17
+ h("button", { type: "button", class: "row-main", onclick: onOpen },
18
+ h("span", { class: "row-text" }, h("span", { class: "row-title", text: title }), sub ? h("span", { class: "row-sub", text: sub }) : null)),
19
+ verbs.length ? h("span", { class: "row-verbs" }, verbs) : null);
20
+ const btn = (label, onclick, cls = "btn") => h("button", { type: "button", class: cls, onclick, text: label });
21
+ const chip = (text, tone) => h("span", { class: `chip${tone ? ` ${tone}` : ""}`, text });
22
+
23
+ export default (host) => {
24
+ const root = h("div", { class: "vmem" });
25
+ host.mount.append(root);
26
+ let panel = "overview", params = {};
27
+ const go = (p, extra = {}) => host.navigate(host.tool, p, { ...(params.vault ? { vault: params.vault } : {}), ...extra });
28
+ const openNote = (path) => go("note", { path });
29
+
30
+ const searchForm = (q) => {
31
+ const input = h("input", { type: "search", name: "q", value: q ?? "", placeholder: "Search notes", "aria-label": "Search notes", class: "vmem-input" });
32
+ return h("form", { class: "vmem-bar", role: "search", onsubmit: (e) => { e.preventDefault(); go("notes", input.value.trim() ? { q: input.value.trim() } : {}); } },
33
+ input, h("button", { type: "submit", class: "btn", text: "Search" }));
34
+ };
35
+
36
+ const views = {
37
+ overview(s) {
38
+ const vaults = s.status?.vaults ?? [];
39
+ if (!vaults.length) return [empty("No vault registered", "Run `vmem vaults add <id> <path>` to register one.")];
40
+ return [
41
+ searchForm(""),
42
+ h("div", { class: "cards" },
43
+ h("section", { class: "card" }, h("h2", { text: "Vaults" }),
44
+ rows(vaults, (v) => row(v.id, `${v.kind} · write ${v.write}${v.health && v.health !== "ok" ? ` · ${v.health}` : ""}`, () => go("overview", { vault: v.id }), v.id === s.vault ? chip("shown", "accent") : null))),
45
+ h("section", { class: "card" }, h("h2", { text: `${s.noteCount ?? 0}${s.noteCountCapped ? "+" : ""} notes · maps` }),
46
+ rows((s.mocs ?? []).slice(0, 8), (m) => row(m.title || m.path, `${m.count} links`, () => openNote(m.path))) ?? empty("No maps yet")),
47
+ h("section", { class: "card" }, h("h2", { text: "Recent log" }),
48
+ rows(s.log ?? [], (r) => row(r.raw ?? `${r.type} ${r.source}${r.dest ? ` → ${r.dest}` : ""}`, r.raw ? "" : `${r.at} · ${r.project}${r.agent ? " · agent" : ""}`, () => go("log"))) ?? empty("Log is empty"))),
49
+ ];
50
+ },
51
+ notes(s) {
52
+ const list = s.notes ?? [];
53
+ return [searchForm(s.q), s.error ? err(s.error) : null,
54
+ rows(list, (n) => row(n.title || n.path, [n.type, n.domain, n.description].filter(Boolean).join(" · "), () => openNote(n.path)))
55
+ ?? empty(s.q ? "No notes match" : "No notes", s.q ? "Try fewer words; terms are combined with AND." : null)];
56
+ },
57
+ note(s) {
58
+ if (s.error) return [err(s.error)];
59
+ const n = s.note;
60
+ if (!n) return [empty("No note selected", "Open a note from Search or the palette.")];
61
+ const fm = n.frontmatter ?? {};
62
+ const title = fm.title || n.path;
63
+ const moveInput = h("input", { type: "text", name: "to", class: "vmem-input", "aria-label": "New stem", placeholder: "new-stem" });
64
+ const orgs = s.orgVaults ?? [];
65
+ const orgSelect = orgs.length ? h("select", { name: "org", class: "vmem-input", "aria-label": "Org vault" }, orgs.map((o) => h("option", { value: o, text: o }))) : null;
66
+ const out = s.links?.out ?? [], back = s.links?.in ?? [], dead = s.links?.dead ?? [];
67
+ return [
68
+ h("header", { class: "vmem-head" }, chip(fm.type || "note"), h("h2", { text: title }), fm.status ? chip(fm.status, fm.status === "superseded" ? "warn" : null) : null),
69
+ fm.description ? h("p", { class: "vmem-sub", text: fm.description }) : null,
70
+ h("div", { class: "vmem-bar" },
71
+ btn("Delete…", () => host.action({ action: "delete", path: n.path, ...(params.vault ? { vault: params.vault } : {}) }), "btn danger"),
72
+ h("form", { class: "vmem-inline", onsubmit: (e) => { e.preventDefault(); if (moveInput.value.trim()) host.action({ action: "move", from: n.path, to: moveInput.value.trim(), ...(params.vault ? { vault: params.vault } : {}) }); } },
73
+ moveInput, h("button", { type: "submit", class: "btn", text: "Move…" })),
74
+ orgSelect ? (() => {
75
+ const topics = h("input", { type: "text", name: "orgTopics", class: "vmem-input", "aria-label": "Org topics (comma separated, optional)", placeholder: "org topics (optional)" });
76
+ const again = h("input", { type: "checkbox", name: "again", "aria-label": "Promote again to another org" });
77
+ return h("form", { class: "vmem-inline", onsubmit: (e) => { e.preventDefault(); host.action({ action: "promote", path: n.path, to: orgSelect.value, ...(topics.value.trim() ? { topics: topics.value.trim() } : {}), ...(again.checked ? { again: true } : {}) }); } },
78
+ orgSelect, topics, h("label", { class: "vmem-check" }, again, h("span", { text: "again" })), h("button", { type: "submit", class: "btn", text: "Promote to org…" }));
79
+ })() : null),
80
+ h("details", { open: true }, h("summary", { text: "Body" }), h("pre", { class: "vmem-body", text: n.body ?? "" }), n.truncated ? err("Truncated; open the file for the rest.") : null),
81
+ h("details", {}, h("summary", { text: `Links (${out.length} out, ${back.length} in${dead.length ? `, ${dead.length} dead` : ""})` }),
82
+ rows([...out.map((l) => ({ path: l.resolved, label: `→ ${l.resolved}` })), ...back.map((l) => ({ path: l.source, label: `← ${l.source}` })), ...dead.map((l) => ({ label: `✕ ${l.target}` }))],
83
+ (l) => row(l.label, "", () => l.path && openNote(l.path))) ?? empty("No links")),
84
+ h("p", { class: "vmem-mono", text: `${n.path} · sha256 ${String(n.sha256 ?? "").slice(0, 12)}` }),
85
+ ];
86
+ },
87
+ promote(s) {
88
+ const projects = s.projects ?? [];
89
+ const all = projects.flatMap((p) => p.candidates);
90
+ const picked = all.find((c) => c.id === s.selected);
91
+ const field = (name, label, required) => h("input", { type: "text", name, class: "vmem-input", "aria-label": label, placeholder: label, required });
92
+ const form = picked ? (() => {
93
+ const domain = field("domain", "domain (required)", true), topics = field("topics", "topics, comma separated (required)", true), type = field("type", "type (optional)"), description = field("description", "description (optional)"), stem = field("stem", "file stem (needed when the title has no ASCII letters)");
94
+ return h("form", { class: "card vmem-form", onsubmit: (e) => {
95
+ e.preventDefault();
96
+ const meta = Object.fromEntries([["domain", domain], ["topics", topics], ["type", type], ["description", description], ["stem", stem]].filter(([, el]) => el.value.trim()).map(([k, el]) => [k, el.value.trim()]));
97
+ host.action({ action: "promote-from-amem", id: picked.id, project: picked.project, ...meta, ...(params.vault ? { vault: params.vault } : {}) });
98
+ } }, h("h2", { text: `Promote: ${picked.title || picked.kind}` }), domain, topics, type, description, stem, h("button", { type: "submit", class: "btn primary", text: "Preview promotion…" }));
99
+ })() : null;
100
+ return [s.unavailable ? err(s.unavailable) : null, form,
101
+ rows(all, (c) => row(c.title || c.kind, `${c.kind} · ${c.project} · ${new Date(c.at).toLocaleString()}`, () => go("promote", { id: c.id, project: c.project }),
102
+ s.selected === c.id ? chip("selected", "accent") : btn("Select", () => go("promote", { id: c.id, project: c.project }))))
103
+ ?? empty("No promotion candidates", "Durable agent memories appear here when agent-mem marks them for the vault.")];
104
+ },
105
+ lint(s) {
106
+ const f = s.findings ?? [];
107
+ return [h("p", { class: "vmem-sub", text: s.summary ?? "" }),
108
+ rows(f, (x) => row(x.message, `${x.code}${x.path ? ` · ${x.path}` : ""}`, () => x.path && openNote(x.path), chip(x.severity, x.severity === "error" ? "err" : "warn")))
109
+ ?? empty("No lint findings", "The vault passes every check.")];
110
+ },
111
+ log(s) {
112
+ const r = s.rows ?? [];
113
+ return [s.error ? err(s.error) : null,
114
+ rows(r, (x) => row(x.raw ?? `${x.type} ${x.source}${x.dest ? ` → ${x.dest}` : ""}`, x.raw ? "unparsed line" : `${x.at} · ${x.project} · ${x.note}${x.agent ? " · agent" : ""}`,
115
+ () => { const p = x.dest ?? x.source; if (typeof p === "string" && p.startsWith("notes/")) openNote(p); })) ?? empty("Log is empty")];
116
+ },
117
+ };
118
+
119
+ let last = "";
120
+ host.onSnapshot((snap) => {
121
+ const sig = JSON.stringify([panel, snap]);
122
+ if (sig === last) return; // unchanged poll: keep focus and scroll
123
+ last = sig;
124
+ // A poll re-renders the view: carry every typed value and the focus over by field name.
125
+ // Rule: every form control in this file has a stable name, or its state is lost on the next poll.
126
+ const values = new Map([...root.querySelectorAll("input[name], select[name]")].map((el) => [el.name, el.type === "checkbox" ? el.checked : el.value]));
127
+ const focused = root.contains(document.activeElement) ? document.activeElement.getAttribute("name") : null;
128
+ root.replaceChildren(...(views[panel]?.(snap) ?? [empty("Unknown panel")]).filter(Boolean));
129
+ for (const el of root.querySelectorAll("input[name], select[name]")) if (values.has(el.name)) el[el.type === "checkbox" ? "checked" : "value"] = values.get(el.name);
130
+ if (focused) root.querySelector(`[name="${focused}"]`)?.focus();
131
+ });
132
+ return {
133
+ show(id, p = {}) { panel = id; params = p; last = ""; root.replaceChildren(); },
134
+ hide() {},
135
+ destroy() { root.remove(); },
136
+ };
137
+ };