@staix/vault-mem 0.2.3 → 0.2.4

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.2.4] - 2026-11-01
6
+
7
+ - Report names (`graph-report-YYMMDD.md`, `lint-report-YYMMDD.md`), graph trend rows, L11 staleness, `promoted_at` (agent-mem and org promotion) and the skill-candidate report date now use the local calendar date, the same clock as the log lines, instead of UTC. A run early in the morning in a timezone ahead of UTC is no longer dated the previous day. `options.today` and `now` overrides still apply. Skill mining's `unique_days` keeps the UTC day of each record's `...Z` timestamp, as Python's `.date()` does.
8
+ - Console plugin for `acon` (agent-console api 1), exported as `@staix/vault-mem/ui-plugin`: Overview, Search, Note, Promote, Lint and Log panels, inbox items for promotion candidates and lint alerts, palette search, and two-step `delete`, `move`, `promote` and `promote-from-amem` behind the console's confirm dialog.
9
+ - `vmem setup` writes, reports and undoes the console plugin manifest `$AGENTHUB_HOME/console/plugins/vmem.json`.
10
+
3
11
  ## [0.2.3] - 2026-10-31
4
12
 
5
13
  - Graph communities use a deterministic, dependency-free Leiden (modularity, resolution 1.0) instead of label propagation, which collapsed most of a real vault into one community. Every community is connected, ids are numbered by size then smallest member, and `vault-graph.json` and the report Overview record the partitioner, resolution and modularity. The vault graph, the workspace graph and skill mining share it.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An Obsidian-compatible knowledge vault tool for agents and people. Requires Bun >=1.4.2. The full check (`bun run check`) needs Bun, git and tar; it includes a package dry-run and clean installed CLI/MCP smoke. Node/npm are used by the separate release workflow, not required for the ordinary Bun gate. CI runs on Ubuntu and macOS for pull requests and for pushes to `main`.
4
4
 
5
- Implemented: configuration, vault registration, lossless frontmatter parsing/emission, wiki-link resolution and derived SQLite FTS5 indexing with bounded read commands. Validated note writes, the append-only log, the `vmem mcp` stdio server, reversible `vmem setup` host registration and promotion from agent-mem (`promote-from-amem`) are also implemented below. Lint/graph analysis, promotion to org vaults (`vmem promote`) and explicit `vmem sync` are implemented below. The `acon` console remains planned under issues [#1](https://github.com/STAIxBWLB/vault-mem/issues/1) and [#2](https://github.com/STAIxBWLB/vault-mem/issues/2). Do not use this increment to replace an existing vault writer.
5
+ Implemented: configuration, vault registration, lossless frontmatter parsing/emission, wiki-link resolution and derived SQLite FTS5 indexing with bounded read commands. Validated note writes, the append-only log, the `vmem mcp` stdio server, reversible `vmem setup` host registration and promotion from agent-mem (`promote-from-amem`) are also implemented below. Lint/graph analysis, promotion to org vaults (`vmem promote`) and explicit `vmem sync` are implemented below. The vmem plugin for the `acon` console ([agent-console](https://github.com/STAIxBWLB/agent-console)) is described under "Console plugin"; vault-mem ships no console bin. Do not use this increment to replace an existing vault writer.
6
6
 
7
7
  ```sh
8
8
  bun install
@@ -187,7 +187,7 @@ vmem setup all --undo
187
187
 
188
188
  Setup registers one server entry named `vmem` (`<bun> --smol <package>/bin/vmem.js mcp`) by editing the host's own MCP configuration: `~/.claude.json` (`mcpServers`; `$CLAUDE_CONFIG_DIR/.claude.json` when set), `$CODEX_HOME/config.toml` (`[mcp_servers.vmem]`), `$KIMI_CODE_HOME/mcp.json`, `$PI_CODING_AGENT_DIR/mcp.json`, `$QWEN_HOME/settings.json` (all `mcpServers`), `$JCODE_HOME/mcp.json` (`servers`) and the workspace's `.agents/mcp_config.json` for Antigravity. Defaults are `~/.codex`, `~/.kimi-code`, `~/.pi/agent`, `~/.qwen` and `~/.jcode`; overrides must be absolute. It does not call host CLIs and does not need a TTY. Run it while the host is closed: Claude Code in particular rewrites `.claude.json` while it runs and can overwrite a concurrent change.
189
189
 
190
- Apply is idempotent and touches only the `vmem` entry: other servers and settings are preserved (TOML is edited textually, so comments and other tables stay byte-identical; JSON is re-serialized with its indentation). Configuration files must be plain strict JSON or TOML; links, JSONC and unparsable files are refused per host without writing, and the command exits 1. Apply records the prior `vmem` value in a receipt, `$VMEM_HOME/setup/<host>-<hash of the config path>.json`, so separate host profiles (for example two `CODEX_HOME`s) sharing one `VMEM_HOME` keep separate receipts. The receipt is written before the host file; if the host write fails, the receipt is rolled back. In TOML, every `[mcp_servers.vmem]` and `[mcp_servers.vmem.*]` table belongs to the entry, even when other tables sit between them. Undo restores that prior value (or removes the entry, and the file if setup created it and nothing else was added) only while the entry is still the one setup installed; an entry edited later is left alone and reported as `refused`. No console plugin manifest is written because vmem ships no console plugin yet.
190
+ Apply is idempotent and touches only the `vmem` entry: other servers and settings are preserved (TOML is edited textually, so comments and other tables stay byte-identical; JSON is re-serialized with its indentation). Configuration files must be plain strict JSON or TOML; links, JSONC and unparsable files are refused per host without writing, and the command exits 1. Apply records the prior `vmem` value in a receipt, `$VMEM_HOME/setup/<host>-<hash of the config path>.json`, so separate host profiles (for example two `CODEX_HOME`s) sharing one `VMEM_HOME` keep separate receipts. The receipt is written before the host file; if the host write fails, the receipt is rolled back. In TOML, every `[mcp_servers.vmem]` and `[mcp_servers.vmem.*]` table belongs to the entry, even when other tables sit between them. Undo restores that prior value (or removes the entry, and the file if setup created it and nothing else was added) only while the entry is still the one setup installed; an entry edited later is left alone and reported as `refused`. Every mode also handles the console plugin manifest (see "Console plugin"): apply writes it, undo removes it while it still points at this install.
191
191
 
192
192
  `vmem completion bash|zsh|fish` prints a completion script generated from the same command table as `--help`.
193
193
 
@@ -209,6 +209,16 @@ vmem promote-from-amem personal mem:<id> --link-to existing-note # link an exis
209
209
  - If the final link fails, the note stays, the result reports `LINK` and `link-only notes/<stem>.md`, and the entry stays a candidate. A rerun finds the note through `promoted_from` and offers link-only, never a second note or a second LEARN event.
210
210
  - `vmem_promote_from_amem` is the same two steps over MCP and needs a vault with `write: mcp`.
211
211
 
212
+ ## Console plugin
213
+
214
+ `vmem setup <target> --apply` also writes `$AGENTHUB_HOME/console/plugins/vmem.json` (default `~/.agenthub`; directory 0700, file 0600) with the absolute path and sha256 of `src/ui/plugin.ts`. `acon ui` then shows a Vault area. Re-run setup after upgrading vmem: acon refuses a module whose hash changed.
215
+
216
+ - Panels: Overview (vaults, maps, recent log), Search, Note (body, links, actions), Promote (agent-mem candidates), Lint (typed findings linked to notes) and Log (parsed rows).
217
+ - Inbox: promotion candidates (group `candidate`), lint errors and a stale graph report (group `alert`). The palette searches notes.
218
+ - Actions: `delete`, `move`, `promote` (to an org vault) and `promote-from-amem` reuse the two-step MCP tools. Each first answers a preview with the planned changes; the write happens only after the console's confirm dialog, and only while the fingerprint still matches.
219
+ - The provider calls the `vmem mcp` handlers in process, so the console has exactly the MCP rights: writes need a vault with `write: mcp`, and log lines carry the agent attribution.
220
+ - The module is exported as `@staix/vault-mem/ui-plugin`. Lint results are cached for 60 seconds per vault.
221
+
212
222
  ## Lint and graph analysis
213
223
 
214
224
  `vmem lint <vault>` checks L01/L02/L03/L09/L10/L11/L12 and L13 (lowercase ASCII kebab-case filenames). `--note notes/example.md` limits note findings. Section names and `SUMMARY errors=N warnings=N` follow the Python reference. `--write` writes a screened lint report and LINT event; `--dry-run` previews without persistence. Findings may identify paths, link targets and malformed log prefixes. Note bodies are never included in analysis results.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/vault-mem",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Validated Obsidian-compatible knowledge vaults for agents and people",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,11 +15,13 @@
15
15
  "@staix/agent-mem": "0.11.0"
16
16
  },
17
17
  "devDependencies": {
18
+ "@staix/agent-console": "github:STAIxBWLB/agent-console#bd7234f",
18
19
  "@types/bun": "^1.3.0",
19
20
  "typescript": "^5.8.0"
20
21
  },
21
22
  "exports": {
22
- ".": "./src/index.ts"
23
+ ".": "./src/index.ts",
24
+ "./ui-plugin": "./src/ui/plugin.ts"
23
25
  },
24
26
  "bin": {
25
27
  "vmem": "bin/vmem.js"
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { MAX_REPORT_BYTES } from "./write-validation.ts";
2
3
  import { parseFrontmatter } from "./frontmatter.ts";
3
4
  import { sourceWorkspaceRoot, missingSourceWarnings, sourceReferences } from "./source.ts";
@@ -35,7 +36,7 @@ export function analysisInput(home: string, id: string, options: { today?: strin
35
36
  if (!vault) throw new Error("analysis: unknown vault id");
36
37
  if (options.expectedVault && JSON.stringify(options.expectedVault) !== JSON.stringify(vault)) throw new Error("analysis: startup vault binding changed; restart MCP");
37
38
  const identity = lstatSync(vault.path);
38
- const today = options.today ?? new Date().toISOString().slice(0,10);
39
+ const today = options.today ?? localDate();
39
40
  if (!/^\d{4}-\d{2}-\d{2}$/.test(today) || !Number.isFinite(Date.parse(today)) || new Date(today).toISOString().slice(0,10) !== today) throw new Error("analysis: invalid date");
40
41
  permitted(vault.path, config);
41
42
  // Check denied paths before opening the index or reading note sources.
package/src/cli.ts CHANGED
@@ -33,7 +33,7 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
33
33
  if (positional.length !== 1 || flags.some(f => !["--apply", "--undo", "--json"].includes(f)) || new Set(flags).size !== flags.length || (flags.includes("--apply") && flags.includes("--undo"))) throw new Error("setup: requires one target and at most one of --apply/--undo");
34
34
  const mode: SetupMode = flags.includes("--apply") ? "apply" : flags.includes("--undo") ? "undo" : "dry-run";
35
35
  const report = runSetup(positional[0]!, mode, { home: resolveHome(env).home, env });
36
- return { text: JSON.stringify(report, null, flags.includes("--json") ? undefined : 2), code: report.actions.some(a => a.change === "refused") ? 1 : 0 };
36
+ return { text: JSON.stringify(report, null, flags.includes("--json") ? undefined : 2), code: [...report.actions, report.console].some(a => a?.change === "refused") ? 1 : 0 };
37
37
  }
38
38
  if (args[0] === "completion") {
39
39
  if (args.length !== 2) throw new Error("completion: requires bash, zsh or fish");
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The local calendar date as YYYY-MM-DD, from the same local clock the vault log lines use (`log-format.ts`).
3
+ * Every user-facing calendar date (report names, trend rows, L11 staleness, `promoted_at`, skill reports) comes from here,
4
+ * so an early-morning run in a timezone ahead of UTC is not dated the previous day.
5
+ */
6
+ export function localDate(now: Date = new Date()): string {
7
+ const pad = (value: number, size = 2) => String(value).padStart(size, "0");
8
+ return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
9
+ }
package/src/log-format.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { screenContent } from "./write-validation.ts";
2
3
  import { parsePii } from "./config.ts";
3
4
  function validPii(value: unknown): boolean { try { parsePii(value); return true; } catch { return false; } }
@@ -49,7 +50,7 @@ export function formatLogEvent(event: LogEvent, context: LogContext): { line?: s
49
50
  return { issues: [error("L09", "invalid log timestamp")] };
50
51
  }
51
52
  const pad = (value: number, size = 2) => String(value).padStart(size, "0");
52
- const timestamp = `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
53
+ const timestamp = `${localDate(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
53
54
  const env = context.env ?? process.env;
54
55
  const agent = context.channel === "mcp" || AGENT_MARKERS.some(marker => typeof env[marker] === "string" && env[marker]!.length > 0);
55
56
  const line = `${timestamp} ${event.type} ${event.project} ${event.source}${event.dest === undefined ? "" : ` → ${event.dest}`} — ${event.note}${agent ? " (vmem:agent)" : ""}\n`;
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -225,7 +226,7 @@ export function promoteToOrg(home: string, request: OrgPromoteRequest, options:
225
226
  const transformed = dropUnresolvedLinks(parsed.body, value => targetNotes.resolved.has(linkKey(value)));
226
227
  // Body Topics section follows the target topics.
227
228
  const body = replaceTopicsSection(transformed.body, topics);
228
- const day = (options.now ?? new Date()).toISOString().slice(0, 10);
229
+ const day = localDate(options.now ?? new Date());
229
230
  const frontmatter: Record<string, unknown> = { topics, promoted_from: result.source, promoted_at: day, promoted_to: undefined };
230
231
  // Wiki-valued fields that point outside the target vault are dropped as well.
231
232
  for (const key of ["project", "superseded_by", "supersedes", "source_ref"]) {
package/src/promote.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import type { VaultSpec } from "./config.ts";
3
4
  import { emitFrontmatter, parseFrontmatter } from "./frontmatter.ts";
@@ -63,7 +64,7 @@ export function draftNote(entry: AmemCandidate, meta: PromoteMetadata, now = new
63
64
  const topics = (meta.topics ?? []).map(value => value.startsWith("[[") ? value : `[[${value}]]`);
64
65
  const frontmatter: Record<string, unknown> = {
65
66
  title, description: meta.description ?? firstSentence(entry.text), type: meta.type ?? (entry.kind === "decision" ? "decision" : entry.kind === "fail" ? "method" : "insight"),
66
- ...(meta.domain ? { domain: meta.domain } : {}), topics, promoted_from: `mem:${entry.id}`, promoted_at: now.toISOString().slice(0, 10),
67
+ ...(meta.domain ? { domain: meta.domain } : {}), topics, promoted_from: `mem:${entry.id}`, promoted_at: localDate(now),
67
68
  };
68
69
  const hints = entry.about.length ? `${entry.about.slice(0, 20).map(node => `- ${node.replace(/[\r\n[\]]/g, " ")}`).join("\n")}\n` : "";
69
70
  const body = meta.body ?? `# ${title}\n\n${entry.text.trim()}\n\n## Relevant Notes\n\n${hints}\n## Topics\n\n${topics.map(topic => `- ${topic}`).join("\n")}\n`;
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, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, 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,8 @@ 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; actions: SetupAction[]; console?: ConsoleAction; notes: string[] }
18
19
  interface Receipt { version: 1; target: SetupTarget; file: string; existed: boolean; keyExisted?: boolean; prior: unknown; priorToml?: string[]; installed: McpEntry }
19
20
 
20
21
  function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<SetupTarget, HostFile> {
@@ -170,7 +171,7 @@ export function runSetup(target: string, mode: SetupMode, options: { home: strin
170
171
  const targets = target === "all" ? SETUP_TARGETS.filter(t => t !== "agy") : [target as SetupTarget];
171
172
  const report: SetupReport = { mode, applied: false, entry, actions: [], notes: [
172
173
  "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.",
174
+ "Writes the acon console plugin manifest ($AGENTHUB_HOME/console/plugins/vmem.json); re-run setup after upgrading vmem.",
174
175
  ] };
175
176
  if (target === "all") report.notes.push("`all` covers user-level hosts; run `vmem setup agy` inside a workspace for Antigravity.");
176
177
  for (const name of targets) {
@@ -214,6 +215,47 @@ export function runSetup(target: string, mode: SetupMode, options: { home: strin
214
215
  action.change = "refused"; action.reason = error instanceof Error ? error.message : "setup failed";
215
216
  }
216
217
  }
217
- report.applied = mode !== "dry-run" && report.actions.some(a => !["none", "unchanged", "refused"].includes(a.change));
218
+ report.console = consoleManifest(mode, env);
219
+ report.applied = mode !== "dry-run" && [...report.actions, report.console].some(a => !["none", "unchanged", "refused"].includes(a.change));
218
220
  return report;
219
221
  }
222
+
223
+ /** The acon plugin manifest: `{api, tool, module, sha256}` in the family home, dir 0700 and file 0600 as acon requires. */
224
+ export function consoleManifest(mode: SetupMode, env: NodeJS.ProcessEnv = process.env): ConsoleAction {
225
+ const family = env.AGENTHUB_HOME?.trim() ? resolve(env.AGENTHUB_HOME) : join(env.HOME?.trim() || homedir(), ".agenthub");
226
+ const dir = join(family, "console", "plugins"), file = join(dir, "vmem.json");
227
+ const module = fileURLToPath(new URL("./ui/plugin.ts", import.meta.url));
228
+ const action: ConsoleAction = { file, module, change: "none" };
229
+ try {
230
+ const next = { api: 1, tool: "vmem", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") };
231
+ // Apply acon's own loader rules (agent-console scanPlugins): the directory and manifest must be real, owned by you
232
+ // and not writable by others. Kinds and owners are refused here; modes are fixed on apply.
233
+ for (const [path, kind] of [[dir, "directory"], [file, "file"]] as const) {
234
+ let info;
235
+ try { info = lstatSync(path); } catch { continue; }
236
+ if (kind === "directory" ? !info.isDirectory() : !info.isFile()) throw new Error(`${path}: acon needs a real ${kind}, not a link or other entry`);
237
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw new Error(`${path}: owned by another user; acon refuses it`);
238
+ }
239
+ const text = readText(file);
240
+ const current = text === null ? undefined : JSON.parse(text) as Record<string, unknown>;
241
+ if (mode === "undo") {
242
+ if (!current) { action.reason = "no manifest"; return action; }
243
+ if (current.module !== module) { action.change = "refused"; action.reason = "manifest points at another vmem install; left as is"; return action; }
244
+ unlinkSync(file); action.change = "remove"; return action;
245
+ }
246
+ const loose = (path: string, want: number) => { try { return (lstatSync(path).mode & 0o777) !== want; } catch { return false; } };
247
+ const modesOk = !loose(dir, 0o700) && !loose(file, 0o600);
248
+ if (isDeepStrictEqual(current, next) && modesOk) { action.change = "unchanged"; return action; }
249
+ action.change = current ? "replace" : "add";
250
+ if (!modesOk) action.reason = "permissions: plugin directory 0700, manifest 0600";
251
+ if (mode === "apply") {
252
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
253
+ chmodSync(dir, 0o700);
254
+ atomicWrite(file, JSON.stringify(next, null, 2) + "\n");
255
+ chmodSync(file, 0o600);
256
+ }
257
+ } catch (error) {
258
+ action.change = "refused"; action.reason = error instanceof Error ? error.message : "manifest write failed";
259
+ }
260
+ return action;
261
+ }
@@ -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
 
@@ -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
+ };
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Console plugin (api 1) for acon: STAIxBWLB/agent-console contract, UX per vault-mem#48.
3
+ * The provider reuses the `vmem mcp` handlers in process, so the console acts with exactly the MCP rights and checks.
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import type { ActionResult, Change, ConsolePlugin, ConsoleProvider, InboxItem } from "@staix/agent-console/contract";
7
+ import { analysisInput } from "../analysis-input.ts";
8
+ import { resolveHome } from "../home.ts";
9
+ import { createVaultMcp } from "../mcp-vault.ts";
10
+ import { validArguments } from "../mcp.ts";
11
+ import { listPromotionCandidates } from "../promote.ts";
12
+ import { loadRegistry } from "../registry.ts";
13
+ import { lintVault } from "../vault-lint.ts";
14
+ import type { LintCode } from "../analysis-types.ts";
15
+
16
+ const asset = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8");
17
+ const VERSION = (JSON.parse(asset("../../package.json")) as { version: string }).version;
18
+ const LINT_ERRORS: LintCode[] = ["L01", "L02", "L13"];
19
+ const CACHE_MS = 60_000;
20
+
21
+ type Args = Record<string, unknown>;
22
+ const str = (v: unknown): string | undefined => typeof v === "string" && v.length > 0 ? v : undefined;
23
+ const rec = (v: unknown): Record<string, unknown> => (v && typeof v === "object" ? v as Record<string, unknown> : {});
24
+ /**
25
+ * The console's answer to a confirmed write. An applied write is done, together with any later step that failed
26
+ * (a log line, the agent-mem link, SYNC lines). A successful no-op (already linked, nothing to change, a retried
27
+ * confirmation) is success too, never a refusal. Anything else is refused with its first issue.
28
+ */
29
+ export function confirmedOutcome(done: Args, verb: string): ActionResult {
30
+ if (done.applied === true) {
31
+ const missing = followUpProblems(done);
32
+ return { ok: true, message: missing.length ? `${verb}: applied, but ${missing.join("; ")}` : `${verb}: done` };
33
+ }
34
+ const issues = Array.isArray(done.issues) ? done.issues as { severity?: string }[] : [];
35
+ if (done.ok === true && !issues.some(i => i.severity !== "warning")) return { ok: true, message: `${verb}: already done, nothing changed` };
36
+ return { ok: false, error: firstIssue(done) };
37
+ }
38
+ const firstIssue = (r: Args): string => {
39
+ const issues = Array.isArray(r.issues) ? r.issues as { message?: string; severity?: string }[] : [];
40
+ return (issues.find(i => i.severity !== "warning") ?? issues[0])?.message ?? "refused";
41
+ };
42
+
43
+ /** `- 2026-10-31 09:12 LEARN proj src → dest — note (vmem:agent)` as a row; unparsable lines keep their text. */
44
+ export function parseLogLine(line: string): { at: string; type: string; project: string; source: string; dest?: string; note: string; agent: boolean } | { raw: string } {
45
+ const m = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}) (\S+) (\S+) (.+?)(?: → (.+?))? — (.*?)( \(vmem:agent\))?$/.exec(line.replace(/\n$/, ""));
46
+ if (!m) return { raw: line };
47
+ return { at: m[1]!, type: m[2]!, project: m[3]!, source: m[4]!, ...(m[5] ? { dest: m[5] } : {}), note: m[6]!, agent: m[7] !== undefined };
48
+ }
49
+
50
+ /** Lint strings carry the note path in the first backtick span; this turns them into typed findings. */
51
+ export function typedFindings(findings: Record<string, string[]>): { code: string; severity: "error" | "warning"; path?: string; message: string }[] {
52
+ return Object.entries(findings).flatMap(([code, list]) => list.map(message => {
53
+ const path = /`(notes\/[^`]+\.md)`/.exec(message)?.[1];
54
+ return { code, severity: LINT_ERRORS.includes(code as LintCode) ? "error" as const : "warning" as const, ...(path ? { path } : {}), message };
55
+ }));
56
+ }
57
+
58
+ /** Failed steps after an applied write: the log receipt(s) of a write, a promotion or an org promotion, and the result's own issues. */
59
+ export function followUpProblems(r: Args): string[] {
60
+ const out: string[] = [];
61
+ const failed = (x: unknown) => { const l = rec(x); if (l.ok === false) out.push(firstIssue(l)); };
62
+ const logs = rec(r.logs);
63
+ for (const receipt of [r.log, rec(r.note).log, logs.source, logs.target, r.provenance]) failed(receipt);
64
+ if (r.ok === false) out.push(firstIssue(r));
65
+ return [...new Set(out)];
66
+ }
67
+
68
+ /** Planned file changes of a vmem preview, as the shell's "What will change" list. */
69
+ function changesOf(receipt: Args): Change[] {
70
+ const list = Array.isArray(receipt.changes) ? receipt.changes as { path: string; beforeSha: string | null; sha256: string | null }[] : [];
71
+ return list.map(c => ({ op: c.beforeSha === null ? "create" : c.sha256 === null ? "delete" : "modify", target: c.path }));
72
+ }
73
+
74
+ export const plugin: ConsolePlugin = {
75
+ manifest: {
76
+ api: 1, tool: "vmem", version: VERSION, title: "Vault", icon: "vault", pollMs: 15_000,
77
+ panels: [
78
+ { id: "overview", title: "Overview", order: 1 },
79
+ { id: "notes", title: "Search", order: 2 },
80
+ { id: "note", title: "Note", order: 3 },
81
+ { id: "promote", title: "Promote", order: 4 },
82
+ { id: "lint", title: "Lint", order: 5 },
83
+ { id: "log", title: "Log", order: 6 },
84
+ ],
85
+ capabilities: { actions: ["delete", "move", "promote", "promote-from-amem"], confirm: true, inbox: true, search: true },
86
+ },
87
+ async open(ctx): Promise<ConsoleProvider> {
88
+ const home = resolveHome().home;
89
+ const mcp = createVaultMcp(home);
90
+ const call = async (name: string, args: Args): Promise<Args> => rec(await mcp.handlers[name]!(args));
91
+ // The shell's tabs navigate without params, so the vault chosen on one panel is kept for the others.
92
+ // ponytail: one choice per provider; fine for H0's single person, per session once H1 serves several.
93
+ let chosen: string | undefined;
94
+ // An explicit vault must exist (undefined otherwise, like MCP's own check); only an omitted one falls back.
95
+ const vaultOf = (args: Args) => {
96
+ const asked = str(args.vault);
97
+ if (asked) { const spec = mcp.vaults.find(v => v.id === asked); if (spec) chosen = spec.id; return spec; }
98
+ return mcp.vaults.find(v => v.id === (chosen ?? mcp.defaultVault));
99
+ };
100
+ // The vault argument for an MCP call: an explicit id goes through unchanged so the handler refuses an unknown one.
101
+ const vaultArg = (args: Args) => str(args.vault) ?? vaultOf(args)?.id;
102
+ const pass = (args: Args) => { vaultOf(args); const v = vaultArg(args); return v ? { vault: v } : {}; };
103
+ /**
104
+ * Every option the MCP tool declares reaches it: the console never hand-picks arguments, so a supported
105
+ * operation cannot be lost on the way. Form text for array options is split on commas; the result is checked
106
+ * against the tool's own schema before anything runs.
107
+ */
108
+ const forward = (tool: string, args: Args, fixed: Args = {}): Args | string => {
109
+ const schema = mcp.tools.find(t => t.name === tool)?.inputSchema as { properties?: Record<string, { type?: unknown }> } | undefined;
110
+ const out: Args = {};
111
+ for (const [key, spec] of Object.entries(schema?.properties ?? {})) {
112
+ if (key === "confirm" || key === "fingerprint") continue;
113
+ let value = key in fixed ? fixed[key] : key === "vault" ? vaultArg(args) : args[key];
114
+ if (value === undefined || value === "") continue;
115
+ const types = Array.isArray(spec.type) ? spec.type : [spec.type];
116
+ if (typeof value === "string" && types.includes("array") && !types.includes("string")) value = value.split(",").map(v => v.trim()).filter(Boolean);
117
+ if (value === "true" && types.includes("boolean")) value = true;
118
+ out[key] = value;
119
+ }
120
+ return validArguments(out, schema ?? { type: "object" }) ? out : `invalid input for ${tool}`;
121
+ };
122
+
123
+ // ponytail: lint reads every note, so inbox polls reuse one result per vault for 60 s; invalidate on writes if it lags.
124
+ const lintCache = new Map<string, { at: number; report: ReturnType<typeof lintVault> }>();
125
+ const lint = (id: string) => {
126
+ const hit = lintCache.get(id);
127
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.report;
128
+ const spec = mcp.vaults.find(v => v.id === id)!;
129
+ const report = lintVault(analysisInput(home, id, { expectedVault: spec }).snapshot);
130
+ lintCache.set(id, { at: Date.now(), report });
131
+ return report;
132
+ };
133
+ const projectOf = new Map<string, string>();
134
+ const candidates = async () => {
135
+ try {
136
+ const { projects } = await listPromotionCandidates({ allProjects: true, limit: 50, piiPatterns: loadRegistry(home).piiPatterns });
137
+ for (const p of projects) for (const c of p.candidates) projectOf.set(c.id, c.project);
138
+ return { projects };
139
+ }
140
+ catch (error) { ctx.log(`vmem: promotion candidates unavailable: ${error instanceof Error ? error.message : String(error)}`); return { projects: [], unavailable: "agent-mem candidates are unavailable; see System" }; }
141
+ };
142
+
143
+ // The shell keys snooze and oldest-first order on `at`, so an item keeps the time it was first seen.
144
+ const firstSeen = new Map<string, string>();
145
+ const since = (id: string) => { const at = firstSeen.get(id) ?? new Date().toISOString(); firstSeen.set(id, at); return at; };
146
+ async function inbox(): Promise<InboxItem[]> {
147
+ const items: InboxItem[] = [];
148
+ for (const p of (await candidates()).projects) for (const c of p.candidates) items.push({
149
+ id: `mem:${c.id}`, group: "candidate", kind: "promotion", title: c.title ?? c.kind, detail: `${c.kind} memory to promote into a vault note`,
150
+ project: c.project, at: new Date(c.at).toISOString(),
151
+ // Promotion needs a domain and topics, so the item opens the Promote panel instead of offering a one-key verb.
152
+ verbs: [],
153
+ link: { panel: "promote", params: { id: c.id, project: c.project } },
154
+ });
155
+ for (const v of mcp.vaults.filter(x => x.kind === "personal")) {
156
+ let report;
157
+ try { report = lint(v.id); } catch (error) { ctx.log(`vmem: lint ${v.id} failed: ${error instanceof Error ? error.message : String(error)}`); continue; }
158
+ if (report.errors > 0) items.push({ id: `lint:${v.id}`, group: "alert", kind: "lint", title: `${report.errors} lint errors in ${v.id}`, detail: report.summary, at: since(`lint:${v.id}`),
159
+ chip: { text: `${report.errors} errors`, tone: "err" }, verbs: [], link: { panel: "lint", params: { vault: v.id } } });
160
+ const stale = report.findings.L11 ?? [];
161
+ if (stale.length) items.push({ id: `stale:${v.id}`, group: "alert", kind: "lint", title: `Graph report is stale in ${v.id}`, detail: stale[0]!, at: since(`stale:${v.id}`),
162
+ chip: { text: "stale", tone: "warn" }, verbs: [], link: { panel: "lint", params: { vault: v.id } } });
163
+ }
164
+ for (const id of firstSeen.keys()) if (!items.some(i => i.id === id)) firstSeen.delete(id);
165
+ return items;
166
+ }
167
+
168
+ /** One two-step operation: preview, and with the console's confirmation recompute and write only on the same fingerprint. */
169
+ async function twoStep(tool: string, args: Args | string, confirmed: boolean, fingerprint: string | undefined, describe: (preview: Args) => { verb: string; preview: string[]; changes: Change[]; warnings: string[] }): Promise<ActionResult> {
170
+ if (typeof args === "string") return { ok: false, error: args };
171
+ const preview = await call(tool, args);
172
+ if (preview.refused || preview.ok === false || typeof preview.fingerprint !== "string") return { ok: false, error: firstIssue(preview) };
173
+ if (!confirmed || fingerprint !== preview.fingerprint) {
174
+ const d = describe(preview);
175
+ return { ok: false, needsConfirm: true, fingerprint: preview.fingerprint, verb: d.verb, preview: d.preview, changes: d.changes, ...(d.warnings.length ? { warnings: d.warnings } : {}) };
176
+ }
177
+ const done = await call(tool, { ...args, confirm: true, fingerprint });
178
+ if (done.applied === true) lintCache.clear();
179
+ return confirmedOutcome(done, describe(preview).verb);
180
+ }
181
+
182
+ return {
183
+ assets: { js: asset("./panel.js"), css: asset("./panel.css") },
184
+ async snapshot(input) {
185
+ const args = input as Args;
186
+ switch (input.panel) {
187
+ case "inbox": return { items: await inbox() };
188
+ case "search": {
189
+ const q = str(args.q);
190
+ if (!q) return { hits: [], total: 0 };
191
+ const limit = Math.min(10, Math.max(1, Number(args.limit) || 10));
192
+ const r = await call("vmem_search", { query: q, limit, ...pass(args) });
193
+ const hits = (Array.isArray(r.hits) ? r.hits as { path: string; title: string; description: string }[] : []).map(h => ({
194
+ id: h.path, kind: "note", title: h.title, detail: h.description, link: { panel: "note", params: { path: h.path } } }));
195
+ return { hits, total: hits.length };
196
+ }
197
+ case "overview": {
198
+ const status = await call("vmem_status", {});
199
+ const vault = vaultOf(args);
200
+ if (!vault) return { status, vault: null };
201
+ const [notes, mocs, log] = await Promise.all([call("vmem_list", { vault: vault.id, limit: 500 }), call("vmem_mocs", { vault: vault.id }), call("vmem_log_tail", { vault: vault.id, n: 8 })]);
202
+ const count = Array.isArray(notes.notes) ? notes.notes.length : 0;
203
+ return { status, vault: vault.id, noteCount: count, noteCountCapped: count >= 500, mocs: mocs.mocs ?? [], log: (Array.isArray(log.lines) ? log.lines as string[] : []).map(parseLogLine) };
204
+ }
205
+ case "notes": {
206
+ const q = str(args.q);
207
+ const r = q ? await call("vmem_search", { query: q, limit: 50, ...pass(args) }) : await call("vmem_list", { limit: 100, ...pass(args) });
208
+ return { q: q ?? "", notes: r.hits ?? r.notes ?? [], ...(r.refused ? { error: firstIssue(r) } : {}) };
209
+ }
210
+ case "note": {
211
+ const path = str(args.path);
212
+ if (!path) return { note: null };
213
+ const note = await call("vmem_read", { path, ...pass(args) });
214
+ if (note.refused) return { note: null, error: firstIssue(note) };
215
+ const links = await call("vmem_links", { path, ...pass(args) });
216
+ // vmem_promote always reads from the default personal vault, so offer it only there.
217
+ const promotable = vaultOf(args)?.id === mcp.defaultVault && vaultOf(args)?.kind === "personal";
218
+ return { note, links, orgVaults: promotable ? mcp.vaults.filter(v => v.kind === "org").map(v => v.id) : [] };
219
+ }
220
+ case "promote": return { ...(await candidates()), selected: str(args.id) ?? null };
221
+ case "lint": {
222
+ const vault = vaultOf(args);
223
+ if (!vault) return { findings: [] };
224
+ const report = lint(vault.id);
225
+ return { vault: vault.id, errors: report.errors, warnings: report.warnings, summary: report.summary, findings: typedFindings(report.findings) };
226
+ }
227
+ case "log": {
228
+ const vault = vaultOf(args);
229
+ if (!vault) return { rows: [] };
230
+ const r = await call("vmem_log_tail", { vault: vault.id, n: 100, ...(str(args.type) ? { type: args.type } : {}) });
231
+ return { vault: vault.id, rows: (Array.isArray(r.lines) ? r.lines as string[] : []).map(parseLogLine).reverse(), ...(r.ok === false ? { error: firstIssue(r) } : {}) };
232
+ }
233
+ default: return { error: `unknown panel ${input.panel}` };
234
+ }
235
+ },
236
+ async action(input) {
237
+ const args = input as Args, confirmed = input.confirmed === true, fingerprint = str(input.fingerprint);
238
+ switch (input.action) {
239
+ case "delete": {
240
+ const path = str(args.path) ?? str(args.id);
241
+ if (!path) return { ok: false, error: "path is required" };
242
+ return twoStep("vmem_delete", forward("vmem_delete", args, { path }), confirmed, fingerprint, p => {
243
+ const backlinks = Array.isArray(p.backlinks) ? p.backlinks as { source: string }[] : [];
244
+ return { verb: "Delete note", preview: [`delete ${path}`], changes: changesOf(p),
245
+ warnings: backlinks.length ? [`${backlinks.length} links point here: ${[...new Set(backlinks.map(b => b.source))].slice(0, 5).join(", ")}`] : [] };
246
+ });
247
+ }
248
+ case "move": {
249
+ const from = str(args.from), to = str(args.to);
250
+ if (!from || !to) return { ok: false, error: "from and to are required" };
251
+ return twoStep("vmem_move", forward("vmem_move", args), confirmed, fingerprint, p => ({ verb: "Move note", preview: [`move ${from} → ${to}`], changes: changesOf(p), warnings: [] }));
252
+ }
253
+ case "promote": {
254
+ const path = str(args.path), to = str(args.to);
255
+ if (!path || !to) return { ok: false, error: "path and to are required" };
256
+ if (vaultOf(args)?.id !== mcp.defaultVault) return { ok: false, error: `promotion to an org vault reads from ${mcp.defaultVault}; open the note there` };
257
+ return twoStep("vmem_promote", forward("vmem_promote", args), confirmed, fingerprint, p => {
258
+ const screen = rec(p.screen), dropped = Array.isArray(p.droppedLinks) ? p.droppedLinks as string[] : [];
259
+ const target = String(p.target ?? `${to}:${path}`);
260
+ // A rerun after an interrupted promotion only restores the source provenance.
261
+ const changes: Change[] = p.recovery === "provenance-only"
262
+ ? [{ op: "modify", target: path, note: `provenance → ${target}` }]
263
+ : [{ op: "create", target }, { op: "modify", target: path, note: "provenance" }, { op: "log", target: "log", note: "SYNC lines in both vaults" }];
264
+ return { verb: p.recovery === "provenance-only" ? "Restore provenance" : "Promote note", preview: String(p.text ?? "").split("\n"),
265
+ changes,
266
+ warnings: [...(dropped.length ? [`${dropped.length} links become plain text: ${dropped.slice(0, 5).join(", ")}`] : []), ...(screen.passed === false ? ["screen reported issues"] : [])] };
267
+ });
268
+ }
269
+ case "promote-from-amem": {
270
+ const id = (str(args.id) ?? "").replace(/^mem:/, "");
271
+ if (!/^[0-9a-f-]{32,36}$/i.test(id)) return { ok: false, error: "a memory id is required" };
272
+ if (!projectOf.has(id)) await candidates();
273
+ const project = str(args.project) ?? projectOf.get(id);
274
+ return twoStep("vmem_promote_from_amem", forward("vmem_promote_from_amem", args, { id, ...(project ? { project } : {}) }), confirmed, fingerprint, p => {
275
+ const dupes = Array.isArray(p.possibleDuplicates) ? p.possibleDuplicates as string[] : [];
276
+ // Linking an existing note writes no vault file; it records the link in agent-mem only.
277
+ const changes: Change[] = p.action === "link"
278
+ ? [{ op: "modify", target: `agent-mem mem:${id}`, note: `vault link → ${String(p.path)}` }]
279
+ : [{ op: "create", target: String(p.path ?? "note") }, { op: "log", target: "log", note: "LEARN line" }, { op: "modify", target: `agent-mem mem:${id}`, note: "vault link" }];
280
+ return { verb: p.action === "link" ? "Link memory" : "Promote memory", preview: String(p.text ?? `link mem:${id} → ${String(p.path)}`).split("\n"),
281
+ changes,
282
+ warnings: dupes.length ? [`possible duplicates: ${dupes.slice(0, 5).join(", ")}`] : [] };
283
+ });
284
+ }
285
+ default: return { ok: false, error: `unknown action ${input.action}` };
286
+ }
287
+ },
288
+ };
289
+ },
290
+ };
291
+
292
+ export default plugin;