@staix/vault-mem 0.2.2 → 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.
@@ -0,0 +1,487 @@
1
+ import { localDate } from "./local-date.ts";
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, readdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { analysisInput, analysisRead } from "./analysis-input.ts";
6
+ import type { VaultSpec, VmemConfig } from "./config.ts";
7
+ import { parseFrontmatter } from "./frontmatter.ts";
8
+ import { canonicalPath, isDenied } from "./platform.ts";
9
+ import { codePointCompare as compare, labelCommunities } from "./vault-graph.ts";
10
+ import { SECRET_PATTERNS } from "./write-validation.ts";
11
+
12
+ /**
13
+ * `vmem skills mine`: read-only skill-candidate mining over amem memory and vault notes, a port of the skill-mine
14
+ * Python pipeline (materialize.py dictionary matching and masking, analyze.py scoring). Nothing is written: no vault
15
+ * file, lock, cache, log event, vmem home file or amem content. The caller decides where the report goes.
16
+ */
17
+ export const MAX_RECORDS = 20000;
18
+ export const MAX_RECORD_BYTES = 64 * 1024;
19
+ export const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
20
+ const MAX_SKILL_FILES = 2000;
21
+ const MAX_VOCAB_BYTES = 1024 * 1024;
22
+ const EXCLUDED_NOTE_TYPES = new Set(["person", "moc", "reference"]);
23
+ const SAMPLE_CHARS = 160;
24
+
25
+ export type RecordSource = "curated" | "capture" | "vault";
26
+ export interface MineRecord { source: RecordSource; kind: string; at: string; text: string; project: string }
27
+ export interface VocabEntry { canon: string; surface: string[] }
28
+ export interface RegistryProject { id?: unknown; name?: unknown; keywords?: { ko?: unknown; en?: unknown }; acronyms?: unknown; people?: unknown; orgs?: unknown; sub_projects?: unknown }
29
+ export type SurfaceKind = "action" | "object" | "project" | "person" | "org";
30
+ export interface Surface { surface: string; kind: SurfaceKind; canon: string }
31
+ export interface SkillEntry { name: string; bag: Set<string> }
32
+ export interface SimilarSkill { name: string; similarity: number }
33
+ export interface Candidate {
34
+ cid: number; label: string; gloss: string; size: number; day_span: number; unique_days: number;
35
+ cohesion: number; novelty: number; rank: number; triggers: string[]; trigger_nodes: string[];
36
+ similar_skills: SimilarSkill[]; sources: Record<RecordSource, number>;
37
+ samples: { source: RecordSource; kind: string; at: string; text: string }[];
38
+ reason?: string;
39
+ }
40
+ export interface MineOptions {
41
+ vault: string; days?: number; project?: string; allProjects?: boolean; vocab?: string; skills?: string[];
42
+ minSize?: number; minNovelty?: number; env?: NodeJS.ProcessEnv; now?: Date;
43
+ /** MCP passes its startup vault binding; a re-registered vault is refused. */
44
+ expectedVault?: VaultSpec;
45
+ }
46
+ export interface MineResult {
47
+ vault: string; days: number; since: string; projects: string[];
48
+ records: Record<RecordSource, number>;
49
+ dropped: { secret: number; policy: number; oversized: number; duplicate: number; unreadable: number; truncatedCapture: number };
50
+ graph: { nodes: number; edges: number; communities: number; partitioner: "leiden" };
51
+ godNodes: { id: string; degree: number }[];
52
+ candidates: Candidate[];
53
+ rejected: { below3: number; detailed: Candidate[] };
54
+ surprising: { source: string; target: string; source_community: number; target_community: number; score: number }[];
55
+ report: string;
56
+ }
57
+
58
+ // ── materialize.py ────────────────────────────────────────────────────
59
+
60
+ /** slugify(): lowercase, spaces to hyphens, everything outside [0-9A-Za-z가-힣._-] to one hyphen. */
61
+ export function slugify(name: string): string {
62
+ let s = name.trim().toLowerCase().replaceAll(" ", "-");
63
+ s = s.replace(/[^0-9A-Za-z가-힣._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
64
+ return s || "unnamed";
65
+ }
66
+ const isAscii = (text: string) => /^[\x00-\x7f]*$/.test(text);
67
+ const strings = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
68
+ const pyStr = (value: unknown): string => value === null || value === undefined ? "" : typeof value === "boolean" ? (value ? "True" : "False") : String(value);
69
+ /** build_surface_index(): (surface, kind, canon) deduplicated and sorted longest-first, then by surface. */
70
+ export function buildSurfaceIndex(actions: VocabEntry[], objects: VocabEntry[], projects: RegistryProject[]): Surface[] {
71
+ const entries: [unknown, SurfaceKind, string][] = [];
72
+ for (const a of actions) if (a.canon) for (const s of a.surface ?? []) entries.push([s, "action", a.canon]);
73
+ for (const o of objects) if (o.canon) for (const s of o.surface ?? []) entries.push([s, "object", o.canon]);
74
+ for (const p of projects) {
75
+ const pid = typeof p.id === "string" && p.id ? p.id : undefined;
76
+ if (!pid) continue;
77
+ const kw = p.keywords && typeof p.keywords === "object" ? p.keywords : {};
78
+ for (const s of [...strings(kw.ko), ...strings(kw.en)]) entries.push([s, "project", pid]);
79
+ for (const s of strings(p.acronyms)) entries.push([s, "project", pid]);
80
+ for (const s of strings(p.people)) entries.push([s, "person", personId(pyStr(s))]);
81
+ for (const s of strings(p.orgs)) entries.push([s, "org", slugify(pyStr(s))]);
82
+ for (const sp of strings(p.sub_projects)) {
83
+ if (!sp || typeof sp !== "object") continue;
84
+ const spid = typeof (sp as { id?: unknown }).id === "string" && (sp as { id: string }).id ? (sp as { id: string }).id : pid;
85
+ for (const s of strings((sp as { keywords_extra?: unknown }).keywords_extra)) entries.push([s, "project", spid]);
86
+ }
87
+ }
88
+ const seen = new Set<string>(), unique: Surface[] = [];
89
+ for (const [raw, kind, canon] of entries) {
90
+ const surface = pyStr(raw);
91
+ if (!surface.trim()) continue;
92
+ const key = JSON.stringify([surface, kind, canon]);
93
+ if (seen.has(key)) continue;
94
+ seen.add(key); unique.push({ surface, kind, canon });
95
+ }
96
+ return unique.sort((a, b) => Array.from(b.surface).length - Array.from(a.surface).length || compare(a.surface, b.surface));
97
+ }
98
+ /** find_matches(): every surface contained in the text; ASCII surfaces case-insensitively, others exactly. */
99
+ export function findMatches(text: string, index: readonly Surface[]): Set<string> {
100
+ const hits = new Set<string>(), lowered = text.toLowerCase();
101
+ for (const { surface, kind, canon } of index) {
102
+ if (!surface) continue;
103
+ const ascii = isAscii(surface);
104
+ if ((ascii ? lowered : text).includes(ascii ? surface.toLowerCase() : surface)) hits.add(nodeId(kind, canon));
105
+ }
106
+ return hits;
107
+ }
108
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
109
+ const PATH = /(?:(?:\/|~\/)[\p{L}\p{N}_.\-/]+|[A-Z]:\\[\p{L}\p{N}_.\-\\]+)/gu;
110
+ /** mask_text(): emails, absolute paths, then registry people (longest first). */
111
+ export function maskText(text: string, people: readonly string[]): string {
112
+ let masked = text.replace(EMAIL, "[EMAIL]").replace(PATH, "[PATH]");
113
+ for (const name of people) {
114
+ if (!name) continue;
115
+ // Same rule as findMatches: ASCII names case-insensitively, others (Hangul) exactly; names are literals.
116
+ const literal = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
117
+ masked = masked.replace(new RegExp(literal, isAscii(name) ? "gi" : "g"), "[PERSON]");
118
+ }
119
+ return masked;
120
+ }
121
+ /** The vault config's `piiPatterns` (regular expressions, as everywhere in vmem) redact sample text from every source to `[PII]`. */
122
+ export function redactPatterns(text: string, patterns: readonly string[]): string {
123
+ let out = text;
124
+ for (const pattern of patterns) if (pattern) out = out.replace(new RegExp(pattern, "gi"), "[PII]");
125
+ return out;
126
+ }
127
+ export function allPeople(projects: RegistryProject[]): string[] {
128
+ const names = new Set<string>();
129
+ for (const p of projects) for (const n of strings(p.people)) if (pyStr(n)) names.add(pyStr(n));
130
+ return [...names].sort((a, b) => Array.from(b).length - Array.from(a).length || compare(a, b));
131
+ }
132
+ /** A person node never carries the name: its id is a short digest of the slug. */
133
+ function personId(name: string): string { return "p" + createHash("sha256").update(slugify(name)).digest("hex").slice(0, 10); }
134
+ /** Node ids: `action--<canon>`, `object--<canon>`, `entity--<kind>--<slug>`. */
135
+ export function nodeId(kind: SurfaceKind, canon: string): string {
136
+ return kind === "action" || kind === "object" ? `${kind}--${slugify(canon)}` : `entity--${kind}--${kind === "person" ? canon : slugify(canon)}`;
137
+ }
138
+ const kindOf = (id: string) => id.split("--", 1)[0]!;
139
+ /** analyze.py's trigger label: everything after the first `--` (`org--acme-corp` for an entity; a person stays a digest). */
140
+ export function nodeLabel(id: string): string {
141
+ const at = id.indexOf("--");
142
+ return at < 0 ? id : id.slice(at + 2);
143
+ }
144
+
145
+ // ── analyze.py ────────────────────────────────────────────────────────
146
+
147
+ /** _parse_frontmatter(): the line parser analyze.py uses for SKILL.md (continuation lines append). */
148
+ export function skillFrontmatter(text: string): Record<string, string> {
149
+ const match = /^---\s*\n([\s\S]*?)\n---\s*\n/.exec(text);
150
+ if (!match) return {};
151
+ const out: Record<string, string> = {};
152
+ let key: string | undefined;
153
+ for (const line of match[1]!.split("\n")) {
154
+ if (line.includes(":") && !line.startsWith(" ")) {
155
+ const colon = line.indexOf(":");
156
+ key = line.slice(0, colon).trim(); out[key] = line.slice(colon + 1).trim();
157
+ } else if (key) out[key] = ((out[key] ?? "") + " " + line.trim()).trim();
158
+ }
159
+ return out;
160
+ }
161
+ export function tokenize(text: string): Set<string> {
162
+ return new Set((text.match(/[가-힣A-Za-z0-9_-]{2,}/g) ?? []).map(token => token.toLowerCase()));
163
+ }
164
+ export function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
165
+ if (!a.size || !b.size) return 0;
166
+ let inter = 0;
167
+ for (const item of a) if (b.has(item)) inter++;
168
+ const union = a.size + b.size - inter;
169
+ return union ? inter / union : 0;
170
+ }
171
+ const round3 = (value: number) => Math.round(value * 1000) / 1000;
172
+ const DAY = 86400000;
173
+ function timestamp(value: string): number | undefined {
174
+ const text = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value;
175
+ const ms = Date.parse(text);
176
+ return Number.isFinite(ms) ? ms : undefined;
177
+ }
178
+ export interface ScoreGraph { neighbors: Map<string, Set<string>>; records: Map<string, MineRecord> }
179
+ /** community_to_candidate(): null below three records, else the scored candidate. */
180
+ export function communityToCandidate(cid: number, members: readonly string[], graph: ScoreGraph, existing: readonly SkillEntry[], people: readonly string[] = [], piiPatterns: readonly string[] = []): Candidate | null {
181
+ const records = members.filter(id => graph.records.has(id));
182
+ if (records.length < 3) return null;
183
+ const times = records.map(id => timestamp(graph.records.get(id)!.at)).filter((t): t is number => t !== undefined).sort((a, b) => a - b);
184
+ let daySpan = 1, uniqueDays = 1;
185
+ if (times.length >= 2) {
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.
188
+ uniqueDays = new Set(times.map(t => new Date(t).toISOString().slice(0, 10))).size;
189
+ }
190
+ const membership = new Set(members), counts = new Map<string, number>();
191
+ for (const id of records) for (const neighbor of graph.neighbors.get(id) ?? []) {
192
+ if (!graph.records.has(neighbor) && membership.has(neighbor)) counts.set(neighbor, (counts.get(neighbor) ?? 0) + 1);
193
+ }
194
+ // Counter.most_common: ties keep first-encounter order (records in member order, neighbors in edge order); sort is stable.
195
+ const ranked = [...counts].sort(([, ac], [, bc]) => bc - ac).slice(0, 20).map(([id]) => id);
196
+ const triggers: string[] = [], seenLabels = new Set<string>();
197
+ for (const id of ranked) {
198
+ const label = nodeLabel(id);
199
+ if (seenLabels.has(label)) continue;
200
+ seenLabels.add(label); triggers.push(id);
201
+ if (triggers.length >= 8) break;
202
+ }
203
+ const actions = triggers.filter(id => kindOf(id) === "action"), objects = triggers.filter(id => kindOf(id) === "object");
204
+ let parts = [...(actions.length ? [nodeLabel(actions[0]!)] : []), ...(objects.length ? [nodeLabel(objects[0]!)] : [])];
205
+ if (!parts.length) parts = triggers.slice(0, 2).map(nodeLabel);
206
+ const label = parts.join("-") || `cluster-${cid}`;
207
+ const gloss = triggers.slice(0, 3).map(nodeLabel).join(" + ");
208
+ let internal = 0;
209
+ for (const id of members) for (const neighbor of graph.neighbors.get(id) ?? []) if (membership.has(neighbor) && compare(id, neighbor) < 0) internal++;
210
+ const n = members.length, possible = n > 1 ? n * (n - 1) / 2 : 1;
211
+ const cohesion = possible ? internal / possible : 0;
212
+ const bag = new Set([...triggers.map(id => nodeLabel(id).toLowerCase()), label.toLowerCase()]);
213
+ // Stable descending sort, as Python's list.sort: ties keep catalog order.
214
+ const overlaps = existing.map(skill => ({ name: skill.name, similarity: jaccard(bag, skill.bag) })).sort((a, b) => b.similarity - a.similarity);
215
+ const novelty = 1 - (overlaps[0]?.similarity ?? 0);
216
+ const rank = Math.log2(Math.max(2, records.length)) * (uniqueDays / daySpan) * Math.max(0.1, cohesion) * Math.max(0.1, novelty);
217
+ const sources: Record<RecordSource, number> = { curated: 0, capture: 0, vault: 0 };
218
+ for (const id of records) sources[graph.records.get(id)!.source]++;
219
+ const samples = records.slice(0, 5).map(id => {
220
+ const record = graph.records.get(id)!, text = redactPatterns(maskText(record.text, people), piiPatterns).replace(/\s+/g, " ").trim();
221
+ return { source: record.source, kind: record.kind, at: record.at, text: Array.from(text).length > SAMPLE_CHARS ? Array.from(text).slice(0, SAMPLE_CHARS).join("") + "…" : text };
222
+ });
223
+ return {
224
+ cid, label, gloss, size: records.length, day_span: daySpan, unique_days: uniqueDays, cohesion: round3(cohesion), novelty: round3(novelty), rank: round3(rank),
225
+ triggers: triggers.map(nodeLabel), trigger_nodes: triggers, similar_skills: overlaps.slice(0, 3).filter(o => o.similarity > 0).map(o => ({ name: o.name, similarity: round3(o.similarity) })),
226
+ sources, samples,
227
+ };
228
+ }
229
+
230
+ // ── inputs ────────────────────────────────────────────────────────────
231
+
232
+ function plainFile(file: string, config: VmemConfig, maximum: number): string | null {
233
+ if (isDenied(canonicalPath(file), config.denyPaths ?? [])) throw new Error(`skills mine: input path is denied: ${file}`);
234
+ return analysisRead(file, maximum);
235
+ }
236
+ function parseYaml(source: string, what: string): unknown {
237
+ try { return Bun.YAML.parse(source); } catch { throw new Error(`skills mine: invalid YAML in ${what}`); }
238
+ }
239
+ export function loadVocab(dir: string | undefined, config: VmemConfig): { actions: VocabEntry[]; objects: VocabEntry[] } {
240
+ if (!dir) return { actions: [], objects: [] };
241
+ const read = (name: "actions" | "objects"): VocabEntry[] => {
242
+ const source = plainFile(join(canonicalPath(dir), `${name}.yaml`), config, MAX_VOCAB_BYTES);
243
+ if (source === null) throw new Error(`skills mine: --vocab has no ${name}.yaml`);
244
+ const value = parseYaml(source, `${name}.yaml`) as Record<string, unknown> | null;
245
+ return strings(value?.[name]).filter((e): e is Record<string, unknown> => !!e && typeof e === "object")
246
+ .filter(e => typeof e.canon === "string" && e.canon).map(e => ({ canon: e.canon as string, surface: strings(e.surface).map(pyStr) }));
247
+ };
248
+ return { actions: read("actions"), objects: read("objects") };
249
+ }
250
+ export function loadRegistryProjects(file: string | undefined, config: VmemConfig): RegistryProject[] {
251
+ if (!file) return [];
252
+ const source = plainFile(file, config, MAX_VOCAB_BYTES);
253
+ if (source === null) return [];
254
+ const value = parseYaml(source, "the project registry") as { projects?: unknown } | null;
255
+ return strings(value?.projects).filter((p): p is RegistryProject => !!p && typeof p === "object");
256
+ }
257
+ /** scan_existing_skills(): SKILL.md under each directory (sorted walk), first name wins, bag of name + description. */
258
+ export function scanSkills(dirs: readonly string[], config: VmemConfig): SkillEntry[] {
259
+ const files: string[] = [];
260
+ const walk = (dir: string, depth: number) => {
261
+ if (depth > 8 || isDenied(dir, config.denyPaths ?? [])) return;
262
+ let entries;
263
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
264
+ for (const entry of entries.sort((a, b) => compare(a.name, b.name))) {
265
+ const path = join(dir, entry.name);
266
+ if (entry.isDirectory()) walk(path, depth + 1);
267
+ else if (entry.isFile() && entry.name === "SKILL.md") {
268
+ files.push(path);
269
+ if (files.length > MAX_SKILL_FILES) throw new Error(`skills mine: skill catalog exceeds ${MAX_SKILL_FILES} files`);
270
+ }
271
+ }
272
+ };
273
+ for (const dir of dirs) {
274
+ const root = canonicalPath(dir);
275
+ if (!lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`skills mine: --skills is not a directory: ${dir}`);
276
+ walk(root, 0);
277
+ }
278
+ const catalog: SkillEntry[] = [], seen = new Set<string>();
279
+ for (const file of files) {
280
+ let fm: Record<string, string>;
281
+ try { fm = skillFrontmatter(plainFile(file, config, 256 * 1024) ?? ""); } catch { continue; }
282
+ const name = fm.name || file.split("/").at(-2)!;
283
+ if (seen.has(name)) continue;
284
+ seen.add(name);
285
+ catalog.push({ name, bag: tokenize(`${fm.name ?? ""} ${fm.description ?? ""}`) });
286
+ }
287
+ return catalog;
288
+ }
289
+ /** Vault notes as records: every type but person/moc/reference; `at` from created/date, else the file mtime. */
290
+ export function vaultRecords(home: string, vault: string, since: string, expectedVault?: VaultSpec): { records: MineRecord[]; config: VmemConfig; registry: string | undefined; unreadable: number } {
291
+ const { snapshot, vault: spec, config } = analysisInput(home, vault, { memory: true, ...(expectedVault ? { expectedVault } : {}) });
292
+ const records: MineRecord[] = [];
293
+ let unreadable = 0;
294
+ for (const note of snapshot.notes) {
295
+ let data: Record<string, unknown>, body: string;
296
+ try { const parsed = parseFrontmatter(note.source); data = parsed.data; body = parsed.body; } catch { unreadable++; continue; }
297
+ const type = typeof data.type === "string" ? data.type : "";
298
+ if (EXCLUDED_NOTE_TYPES.has(type)) continue;
299
+ const declared = [data.created, data.date].find(v => typeof v === "string" && timestamp(v) !== undefined) as string | undefined;
300
+ let at = declared;
301
+ if (at === undefined) {
302
+ const stat = lstatSync(join(spec.path, "notes", note.path.replace(/^notes\//, "")), { throwIfNoEntry: false });
303
+ if (!stat) { unreadable++; continue; }
304
+ at = stat.mtime.toISOString();
305
+ }
306
+ if (new Date(timestamp(at)!).toISOString() < since) continue;
307
+ const title = typeof data.title === "string" ? data.title : note.path.replace(/^notes\//, "").replace(/\.md$/, "");
308
+ const description = typeof data.description === "string" ? data.description : "";
309
+ records.push({ source: "vault", kind: type || "note", at, text: [title, description, body.trim()].filter(Boolean).join("\n"), project: `vault:${vault}` });
310
+ }
311
+ return { records, config, registry: spec.registry, unreadable };
312
+ }
313
+
314
+ // ── pipeline ──────────────────────────────────────────────────────────
315
+
316
+ export interface MineInputs {
317
+ vault: string; days: number; since: string; projects: string[]; records: MineRecord[];
318
+ vocab: { actions: VocabEntry[]; objects: VocabEntry[] }; registry: RegistryProject[]; skills: SkillEntry[];
319
+ minSize: number; minNovelty: number; today: string; piiPatterns?: string[];
320
+ dropped?: Partial<MineResult["dropped"]>;
321
+ }
322
+ /** The pure part: screen, mask, match, cluster, score and render. */
323
+ export function mineRecords(input: MineInputs): MineResult {
324
+ const dropped: MineResult["dropped"] = { secret: 0, policy: 0, oversized: 0, duplicate: 0, unreadable: 0, truncatedCapture: 0, ...input.dropped };
325
+ const ordered = [...input.records].sort((a, b) => compare(a.at, b.at) || compare(a.source, b.source) || compare(a.project, b.project) || compare(a.text, b.text));
326
+ const kept: MineRecord[] = [], texts = new Set<string>();
327
+ let total = 0;
328
+ for (const record of ordered) {
329
+ const text = record.text.trim();
330
+ if (SECRET_PATTERNS.some(pattern => pattern.test(text))) { dropped.secret++; continue; }
331
+ const bytes = Buffer.byteLength(text);
332
+ if (bytes > MAX_RECORD_BYTES) { dropped.oversized++; continue; }
333
+ if (texts.has(text)) { dropped.duplicate++; continue; }
334
+ texts.add(text); kept.push({ ...record, text });
335
+ total += bytes;
336
+ if (kept.length > MAX_RECORDS) throw new Error(`skills mine: more than ${MAX_RECORDS} records; narrow --days or --project`);
337
+ if (total > MAX_TOTAL_BYTES) throw new Error("skills mine: records exceed 64 MiB; narrow --days or --project");
338
+ }
339
+ const index = buildSurfaceIndex(input.vocab.actions, input.vocab.objects, input.registry), people = allPeople(input.registry);
340
+ const recordIds = new Map<string, MineRecord>(), neighbors = new Map<string, Set<string>>(), edges: [string, string][] = [];
341
+ const width = String(kept.length).length;
342
+ kept.forEach((record, i) => {
343
+ const id = `record--${String(i + 1).padStart(width, "0")}`;
344
+ recordIds.set(id, record); neighbors.set(id, new Set());
345
+ for (const hit of [...findMatches(record.text, index)].sort(compare)) {
346
+ if (!neighbors.has(hit)) neighbors.set(hit, new Set());
347
+ neighbors.get(id)!.add(hit); neighbors.get(hit)!.add(id); edges.push([id, hit]);
348
+ }
349
+ });
350
+ const ids = [...neighbors.keys()], community = labelCommunities(ids, edges);
351
+ const groups = new Map<number, string[]>();
352
+ for (const id of ids.sort(compare)) { const c = community.get(id)!; groups.set(c, [...(groups.get(c) ?? []), id]); }
353
+ const graph: ScoreGraph = { neighbors, records: recordIds };
354
+ const candidates: Candidate[] = [], detailed: Candidate[] = [];
355
+ let below3 = 0;
356
+ for (const [cid, members] of [...groups].sort(([a], [b]) => a - b)) {
357
+ const candidate = communityToCandidate(cid, members, graph, input.skills, people, input.piiPatterns ?? []);
358
+ if (!candidate) { below3++; continue; }
359
+ if (candidate.size < input.minSize) detailed.push({ ...candidate, reason: "size<min" });
360
+ else if (candidate.novelty < input.minNovelty) detailed.push({ ...candidate, reason: "novelty<min" });
361
+ else candidates.push(candidate);
362
+ }
363
+ candidates.sort((a, b) => b.rank - a.rank || a.cid - b.cid);
364
+ const degree = (id: string) => neighbors.get(id)!.size;
365
+ const godNodes = ids.filter(id => !recordIds.has(id)).map(id => ({ id, degree: degree(id) }))
366
+ .sort((a, b) => b.degree - a.degree || compare(a.id, b.id)).slice(0, 10);
367
+ // Vocabulary nodes never link each other directly; cross-community pairs that share a record are the vocab analogue.
368
+ const pairs = new Map<string, MineResult["surprising"][number]>();
369
+ for (const id of recordIds.keys()) {
370
+ const vocab = [...neighbors.get(id)!].sort(compare);
371
+ for (let i = 0; i < vocab.length; i++) for (let j = i + 1; j < vocab.length; j++) {
372
+ const [s, t] = [vocab[i]!, vocab[j]!], cs = community.get(s)!, ct = community.get(t)!;
373
+ if (cs !== ct) pairs.set(`${s}\n${t}`, { source: s, target: t, source_community: cs, target_community: ct, score: degree(s) + degree(t) });
374
+ }
375
+ }
376
+ const used = new Map<string, number>(), surprising: MineResult["surprising"] = [];
377
+ for (const pair of [...pairs.values()].sort((a, b) => b.score - a.score || compare(a.source, b.source) || compare(a.target, b.target))) {
378
+ if ((used.get(pair.source) ?? 0) >= 2 || (used.get(pair.target) ?? 0) >= 2) continue;
379
+ surprising.push(pair); used.set(pair.source, (used.get(pair.source) ?? 0) + 1); used.set(pair.target, (used.get(pair.target) ?? 0) + 1);
380
+ if (surprising.length === 10) break;
381
+ }
382
+ const counts: Record<RecordSource, number> = { curated: 0, capture: 0, vault: 0 };
383
+ for (const record of kept) counts[record.source]++;
384
+ const result: MineResult = {
385
+ vault: input.vault, days: input.days, since: input.since, projects: input.projects, records: counts, dropped,
386
+ graph: { nodes: ids.length, edges: edges.length, communities: groups.size, partitioner: "leiden" },
387
+ godNodes, candidates, rejected: { below3, detailed }, surprising, report: "",
388
+ };
389
+ result.report = renderReport(result, input.today, input.minSize, input.minNovelty);
390
+ return result;
391
+ }
392
+ /** render_report(), with per-candidate source counts and an aggregate line for clusters below three records. */
393
+ export function renderReport(result: Omit<MineResult, "report">, today: string, minSize: number, minNovelty: number): string {
394
+ const total = result.records.curated + result.records.capture + result.records.vault;
395
+ const lines = [
396
+ `# Skill Candidates (${today}, memory-based)`, "",
397
+ `- corpus: ${total} records / ${result.days} days (curated ${result.records.curated}, capture ${result.records.capture}, vault ${result.records.vault})`,
398
+ `- projects: ${result.projects.length}`,
399
+ `- dropped: secret ${result.dropped.secret}, policy ${result.dropped.policy}, oversized ${result.dropped.oversized}, duplicate ${result.dropped.duplicate}, unreadable ${result.dropped.unreadable}`,
400
+ `- graph: ${result.graph.nodes} nodes, ${result.graph.edges} edges, ${result.graph.communities} communities (deterministic Leiden)`, "",
401
+ ];
402
+ if (result.godNodes.length) {
403
+ lines.push("## God nodes (workspace vocabulary)", "");
404
+ for (const g of result.godNodes) lines.push(`- \`${g.id}\` (degree ${g.degree})`);
405
+ lines.push("");
406
+ }
407
+ lines.push("## Candidates", "");
408
+ if (!result.candidates.length) lines.push(`_No community met the threshold (size ≥ ${minSize} records, novelty ≥ ${minNovelty})._`, "");
409
+ result.candidates.forEach((c, i) => {
410
+ lines.push(`### ${i + 1}. \`${c.label}\` (rank ${c.rank})`, "",
411
+ `- size: ${c.size} records / ${c.unique_days} unique days / span ${c.day_span}d`,
412
+ `- sources: curated ${c.sources.curated}, capture ${c.sources.capture}, vault ${c.sources.vault}`,
413
+ `- cohesion: ${c.cohesion} novelty: ${c.novelty}`, `- gloss: ${c.gloss}`, `- triggers: ${c.triggers.join(", ")}`);
414
+ if (c.similar_skills.length) lines.push(`- overlap with existing skills: ${c.similar_skills.map(s => `\`${s.name}\`(${s.similarity})`).join(", ")}`);
415
+ if (c.samples.length) { lines.push("- samples:"); for (const s of c.samples) lines.push(` - [${s.source}/${s.kind} ${s.at.slice(0, 10)}] ${s.text}`); }
416
+ lines.push("");
417
+ });
418
+ if (result.surprising.length) {
419
+ lines.push("## Surprising connections (cross-community vocab)", "");
420
+ for (const s of result.surprising) lines.push(`- \`${s.source}\` ↔ \`${s.target}\` (comm ${s.source_community}→${s.target_community}, score ${s.score})`);
421
+ lines.push("");
422
+ }
423
+ if (result.rejected.detailed.length || result.rejected.below3) {
424
+ lines.push("## Rejected", "");
425
+ if (result.rejected.below3) lines.push(`- ${result.rejected.below3} clusters with fewer than 3 records`);
426
+ for (const r of result.rejected.detailed) lines.push(`- ${r.label} (size=${r.size}, novelty=${r.novelty}, reason=${r.reason})`);
427
+ lines.push("");
428
+ }
429
+ lines.push("## Next steps", "",
430
+ "- Review the candidates; add a SKILL.md for a meaningful one through an issue and PR in the skill source repository (never edit the installed runtime copy).",
431
+ "- If dictionary coverage looks thin, extend the skill's `vocab/actions.yaml` and `objects.yaml`.",
432
+ "- When overlap with an existing skill is high, extend that skill's triggers instead of creating a new one.");
433
+ return lines.join("\n") + "\n";
434
+ }
435
+
436
+ /** The whole command: amem through the seam, vault notes through the read-only analysis snapshot, then `mineRecords`. */
437
+ export async function mineSkills(home: string, options: MineOptions): Promise<MineResult> {
438
+ const days = options.days ?? 30, minSize = options.minSize ?? 3, minNovelty = options.minNovelty ?? 0.3;
439
+ if (!Number.isInteger(days) || days < 1 || days > 3650) throw new Error("skills mine: --days takes 1..3650");
440
+ if (!Number.isInteger(minSize) || minSize < 1 || minSize > 10000) throw new Error("skills mine: --min-size takes 1..10000");
441
+ if (!Number.isFinite(minNovelty) || minNovelty < 0 || minNovelty > 1) throw new Error("skills mine: --min-novelty takes 0..1");
442
+ if (options.allProjects && options.project) throw new Error("skills mine: choose either --all-projects or --project");
443
+ const now = options.now ?? new Date(), since = new Date(now.getTime() - days * DAY).toISOString();
444
+ const local = vaultRecords(home, options.vault, since, options.expectedVault);
445
+ const vocab = loadVocab(options.vocab, local.config), registry = loadRegistryProjects(local.registry, local.config);
446
+ const skills = scanSkills(options.skills ?? [], local.config);
447
+ const { readAmemForMining } = await import("./amem/seam.ts");
448
+ const amem = await readAmemForMining({ env: options.env, since, ...(options.project ? { project: options.project } : {}), piiPatterns: local.config.piiPatterns ?? [] });
449
+ return mineRecords({
450
+ vault: options.vault, days, since, projects: amem.projects, records: [...amem.records, ...local.records], vocab, registry, skills, minSize, minNovelty, piiPatterns: local.config.piiPatterns ?? [],
451
+ today: localDate(now), dropped: { policy: amem.screened, unreadable: amem.unreadable + local.unreadable, truncatedCapture: amem.truncatedCapture },
452
+ });
453
+ }
454
+
455
+ const VALUES = ["--days", "--project", "--vocab", "--skills", "--min-size", "--min-novelty"];
456
+ const BOOLEANS = ["--all-projects", "--json"];
457
+ /** `vmem skills mine <vault> ...`. Prints the JSON result, or the markdown report without --json. */
458
+ export async function runSkillsCommand(args: string[], home: string, env: NodeJS.ProcessEnv): Promise<{ text: string; code: number }> {
459
+ if (args[0] !== "mine") throw new Error("skills: use `vmem skills mine <vault>`");
460
+ const flags: Record<string, string | boolean> = {}, skills: string[] = [], positional: string[] = [];
461
+ for (let i = 1; i < args.length; i++) {
462
+ const arg = args[i]!;
463
+ if (!arg.startsWith("--")) { positional.push(arg); continue; }
464
+ if (!VALUES.includes(arg) && !BOOLEANS.includes(arg)) throw new Error(`skills mine: unknown option ${arg}`);
465
+ if (arg !== "--skills" && Object.hasOwn(flags, arg)) throw new Error(`skills mine: repeated option ${arg}`);
466
+ if (BOOLEANS.includes(arg)) { flags[arg] = true; continue; }
467
+ const value = args[++i];
468
+ if (value === undefined || value.startsWith("--")) throw new Error(`${arg}: value required`);
469
+ if (arg === "--skills") skills.push(value); else flags[arg] = value;
470
+ }
471
+ if (positional.length !== 1) throw new Error("skills mine: requires one vault id");
472
+ const number = (flag: string) => {
473
+ const value = flags[flag];
474
+ if (typeof value !== "string") return undefined;
475
+ if (!/^\d+(\.\d+)?$/.test(value)) throw new Error(`${flag}: expected a number`);
476
+ return Number(value);
477
+ };
478
+ const result = await mineSkills(home, {
479
+ vault: positional[0]!, env, skills, allProjects: flags["--all-projects"] === true,
480
+ ...(typeof flags["--project"] === "string" ? { project: flags["--project"] } : {}),
481
+ ...(typeof flags["--vocab"] === "string" ? { vocab: flags["--vocab"] } : {}),
482
+ ...(number("--days") !== undefined ? { days: number("--days") } : {}),
483
+ ...(number("--min-size") !== undefined ? { minSize: number("--min-size") } : {}),
484
+ ...(number("--min-novelty") !== undefined ? { minNovelty: number("--min-novelty") } : {}),
485
+ });
486
+ return { text: flags["--json"] ? JSON.stringify(result) : result.report.trimEnd(), code: 0 };
487
+ }
@@ -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
+ };