@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/CHANGELOG.md +19 -0
- package/README.md +21 -8
- package/package.json +5 -3
- package/src/amem/seam.ts +5 -3
- package/src/analysis-input.ts +2 -1
- package/src/cli.ts +22 -8
- package/src/commands-table.ts +2 -1
- package/src/config.ts +56 -19
- package/src/local-date.ts +9 -0
- package/src/log-format.ts +6 -4
- package/src/org-promote.ts +2 -1
- package/src/promote.ts +2 -1
- package/src/registry.ts +4 -1
- package/src/setup.ts +94 -15
- package/src/skills-mine.ts +3 -1
- package/src/tidy.ts +3 -0
- package/src/ui/panel.css +16 -0
- package/src/ui/panel.js +137 -0
- package/src/ui/plugin.ts +292 -0
- package/src/vault-index.ts +9 -4
- package/src/vault-log.ts +3 -0
- package/src/write-validation.ts +1 -1
- package/src/writer.ts +3 -0
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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,
|
|
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
|
-
"
|
|
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.
|
|
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
|
+
}
|
package/src/skills-mine.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/ui/panel.css
ADDED
|
@@ -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); }
|
package/src/ui/panel.js
ADDED
|
@@ -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
|
+
};
|