@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,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;
@@ -6,23 +6,24 @@ export interface GraphNode {
6
6
  label: string;
7
7
  type: string;
8
8
  domain: string;
9
- description: string;
10
- confidence: string;
9
+ description?: string;
10
+ confidence?: string;
11
11
  source_file: string;
12
- topics: string;
12
+ topics?: string;
13
13
  community: number | null;
14
14
  degree: number;
15
15
  }
16
- export interface GraphEdge { source: string; target: string; relation: "wiki_link"; confidence_tag: "EXTRACTED"; confidence: 1 }
16
+ export interface GraphEdge { source: string; target: string; relation: "wiki_link" | "fm_ref" | "bu_member" | "source_ref" | "related" | "supersedes"; field?: string; rel?: string; confidence_tag: "EXTRACTED"; confidence: 1 }
17
17
  export interface VaultGraph {
18
18
  directed: false;
19
19
  multigraph: false;
20
- graph: { partitioner: "label-propagation"; iterations: number; max_iterations: number };
20
+ graph: { partitioner: "leiden"; resolution: number; modularity: number; iterations: number; max_iterations: number };
21
21
  nodes: GraphNode[];
22
22
  edges: GraphEdge[];
23
23
  }
24
24
  export interface GraphNeighborhood { start: string; hops: 1 | 2; nodes: GraphNode[]; edges: GraphEdge[]; truncated: boolean }
25
- const MAX_ITERATIONS = 32;
25
+ /** Bound on Leiden levels summed over refinement rounds (`MAX_LEVELS` × `MAX_ROUNDS`). */
26
+ const MAX_ITERATIONS = 32 * 8;
26
27
  const MAX_NODES = 10000;
27
28
  const MAX_EDGES = 100000;
28
29
  const FRONTMATTER = /^---\s*\n([\s\S]*?)\n---\s*\n/;
@@ -74,7 +75,7 @@ function mapping(value: unknown): value is Record<string, unknown> { return valu
74
75
  function truthy(value: unknown): boolean {
75
76
  return yamlTruthy(value);
76
77
  }
77
- function pyString(value: unknown): string {
78
+ export function pyString(value: unknown): string {
78
79
  const scalar = scalarText(value);
79
80
  if (scalar !== undefined) return scalar;
80
81
  if (value === null || value === undefined) return "None";
@@ -116,28 +117,224 @@ function adjacency(graph: Pick<VaultGraph, "nodes" | "edges">): Map<string, Set<
116
117
  for (const edge of graph.edges) { neighbors.get(edge.source)?.add(edge.target); neighbors.get(edge.target)?.add(edge.source); }
117
118
  return neighbors;
118
119
  }
119
- function communities(nodes: GraphNode[], neighbors: Map<string, Set<string>>): number {
120
- const labels = new Map(nodes.filter(node => !HUB_TYPES.has(node.type)).map(node => [node.id, node.id]));
121
- let iterations = 0;
122
- for (; iterations < MAX_ITERATIONS; iterations++) {
123
- let changed = false;
124
- for (const [id, current] of labels) {
125
- const counts = new Map<string, number>();
126
- for (const neighbor of neighbors.get(id)!) {
127
- const label = labels.get(neighbor);
128
- if (label !== undefined) counts.set(label, (counts.get(label) ?? 0) + 1);
120
+ /** Leiden over the graph's own nodes and edges, with MOC/bu hubs excluded; writes `community` and the method metadata. */
121
+ function communities(graph: VaultGraph): void {
122
+ const exclude = new Set(graph.nodes.filter(node => HUB_TYPES.has(node.type)).map(node => node.id));
123
+ const result = leidenCommunities(graph.nodes.map(node => node.id), graph.edges.map(edge => [edge.source, edge.target] as const), { exclude });
124
+ for (const node of graph.nodes) node.community = result.membership.get(node.id) ?? null;
125
+ graph.graph = { partitioner: "leiden", resolution: result.resolution, modularity: result.modularity, iterations: result.levels, max_iterations: MAX_ITERATIONS };
126
+ }
127
+
128
+ // ── Leiden (Traag, Waltman, van Eck 2019), modularity, deterministic ─────────────────────────────
129
+
130
+ /** Resolution γ of the modularity objective. */
131
+ export const LEIDEN_RESOLUTION = 1;
132
+ const MAX_LEVELS = 32, MAX_ROUNDS = 8;
133
+ interface WeightedGraph { n: number; start: Int32Array; to: Int32Array; weight: Float64Array; self: Float64Array; strength: Float64Array; m2: number }
134
+ function weightedGraph(n: number, pairs: Map<number, number>, self: Float64Array): WeightedGraph {
135
+ const degree = new Int32Array(n);
136
+ for (const key of pairs.keys()) { degree[Math.floor(key / n)]!++; degree[key % n]!++; }
137
+ const start = new Int32Array(n + 1);
138
+ for (let i = 0; i < n; i++) start[i + 1] = start[i]! + degree[i]!;
139
+ const to = new Int32Array(start[n]!), weight = new Float64Array(start[n]!), fill = start.slice(0, n), strength = new Float64Array(n);
140
+ // Keys are visited in ascending order so every adjacency list is sorted by neighbor index.
141
+ for (const key of [...pairs.keys()].sort((a, b) => a - b)) {
142
+ const a = Math.floor(key / n), b = key % n, w = pairs.get(key)!;
143
+ to[fill[a]!] = b; weight[fill[a]!++] = w; to[fill[b]!] = a; weight[fill[b]!++] = w;
144
+ strength[a]! += w; strength[b]! += w;
145
+ }
146
+ let m2 = 0;
147
+ for (let i = 0; i < n; i++) { strength[i]! += 2 * self[i]!; m2 += strength[i]!; }
148
+ return { n, start, to, weight, self, strength, m2 };
149
+ }
150
+ /**
151
+ * Fast local moving (queue). Gains are compared as `k_in·2m − γ·k_v·K_C`, exact in floating point for integer weights.
152
+ * Ties keep the current community, then take the lowest community id. Returns whether any node moved.
153
+ */
154
+ function moveNodes(g: WeightedGraph, comm: Int32Array, gamma: number): boolean {
155
+ const { n, start, to, weight, strength, m2 } = g;
156
+ const total = new Float64Array(n), size = new Int32Array(n);
157
+ for (let i = 0; i < n; i++) { total[comm[i]!]! += strength[i]!; size[comm[i]!]!++; }
158
+ const empty: number[] = [];
159
+ for (let c = n - 1; c >= 0; c--) if (!size[c]) empty.push(c);
160
+ const queue = new Int32Array(n), queued = new Uint8Array(n), neighbor = new Float64Array(n), seen = new Uint8Array(n), touched: number[] = [];
161
+ for (let i = 0; i < n; i++) { queue[i] = i; queued[i] = 1; }
162
+ let head = 0, count = n, moved = false;
163
+ while (count > 0) {
164
+ const v = queue[head]!; head = (head + 1) % n; count--; queued[v] = 0;
165
+ const own = comm[v]!, kv = strength[v]!;
166
+ for (let e = start[v]!; e < start[v + 1]!; e++) {
167
+ const c = comm[to[e]!]!;
168
+ if (!seen[c]) { seen[c] = 1; touched.push(c); }
169
+ neighbor[c]! += weight[e]!;
170
+ }
171
+ total[own]! -= kv; size[own]!--;
172
+ let best = own, bestScore = neighbor[own]! * m2 - gamma * kv * total[own]!;
173
+ for (const c of touched) {
174
+ if (c === own) continue;
175
+ const score = neighbor[c]! * m2 - gamma * kv * total[c]!;
176
+ if (score > bestScore || (score === bestScore && best !== own && c < best)) { best = c; bestScore = score; }
177
+ }
178
+ if (bestScore < 0 && size[own]! > 0) { best = empty.pop()!; bestScore = 0; }
179
+ comm[v] = best; total[best]! += kv; size[best]!++;
180
+ if (!size[own]) empty.push(own);
181
+ if (best !== own) {
182
+ moved = true;
183
+ for (let e = start[v]!; e < start[v + 1]!; e++) {
184
+ const u = to[e]!;
185
+ if (comm[u] !== best && !queued[u]) { queue[(head + count) % n] = u; queued[u] = 1; count++; }
129
186
  }
130
- const next = [...counts].sort(([a, ac], [b, bc]) => bc - ac || compare(a, b))[0]?.[0] ?? current;
131
- if (next !== current) { labels.set(id, next); changed = true; }
132
187
  }
133
- if (!changed) { iterations++; break; }
188
+ for (const c of touched) { neighbor[c] = 0; seen[c] = 0; }
189
+ touched.length = 0;
190
+ }
191
+ return moved;
192
+ }
193
+ /**
194
+ * Refinement inside each community of `comm`, merging only well-connected singletons into well-connected subsets along
195
+ * edges (the greedy θ → 0 variant: the largest non-negative gain wins, ties to the lowest id). Returns the refined partition.
196
+ */
197
+ function refine(g: WeightedGraph, comm: Int32Array, gamma: number): Int32Array {
198
+ const { n, start, to, weight, strength, m2 } = g;
199
+ const refined = new Int32Array(n), totalR = new Float64Array(n), external = new Float64Array(n), sizeR = new Int32Array(n), totalS = new Float64Array(n);
200
+ for (let i = 0; i < n; i++) { refined[i] = i; totalR[i] = strength[i]!; sizeR[i] = 1; totalS[comm[i]!]! += strength[i]!; }
201
+ for (let v = 0; v < n; v++) for (let e = start[v]!; e < start[v + 1]!; e++) if (comm[to[e]!] === comm[v]) external[v]! += weight[e]!;
202
+ const neighbor = new Float64Array(n), seen = new Uint8Array(n), touched: number[] = [];
203
+ for (let v = 0; v < n; v++) {
204
+ if (sizeR[refined[v]!] !== 1) continue;
205
+ const s = comm[v]!, kv = strength[v]!, ks = totalS[s]!, ext = external[refined[v]!]!;
206
+ if (ext * m2 < gamma * kv * (ks - kv)) continue;
207
+ for (let e = start[v]!; e < start[v + 1]!; e++) {
208
+ const u = to[e]!;
209
+ if (comm[u] !== s) continue;
210
+ const c = refined[u]!;
211
+ if (!seen[c]) { seen[c] = 1; touched.push(c); }
212
+ neighbor[c]! += weight[e]!;
213
+ }
214
+ let best = -1, bestScore = 0;
215
+ for (const c of touched) {
216
+ if (c === refined[v]) continue;
217
+ if (external[c]! * m2 < gamma * totalR[c]! * (ks - totalR[c]!)) continue;
218
+ const score = neighbor[c]! * m2 - gamma * kv * totalR[c]!;
219
+ if (score > bestScore || (score === bestScore && best !== -1 && c < best)) { best = c; bestScore = score; }
220
+ }
221
+ if (best >= 0 && bestScore > 0) {
222
+ const from = refined[v]!;
223
+ external[best] = external[best]! + ext - 2 * neighbor[best]!;
224
+ totalR[best]! += kv; sizeR[best]!++; refined[v] = best;
225
+ totalR[from] = 0; sizeR[from] = 0; external[from] = 0;
226
+ }
227
+ for (const c of touched) { neighbor[c] = 0; seen[c] = 0; }
228
+ touched.length = 0;
134
229
  }
135
- const groups = new Map<string, string[]>();
136
- for (const [id, label] of labels) { const group = groups.get(label) ?? []; group.push(id); groups.set(label, group); }
137
- const ids = new Map<string, number>();
138
- [...groups.values()].sort((a, b) => compare(a[0]!, b[0]!)).forEach((members, id) => { for (const member of members) ids.set(member, id); });
139
- for (const node of nodes) node.community = ids.get(node.id) ?? null;
140
- return iterations;
230
+ return refined;
231
+ }
232
+ /** Aggregate on `refined` (nodes numbered by first member), with `comm` as the initial partition of the aggregate graph. */
233
+ function aggregate(g: WeightedGraph, refined: Int32Array, comm: Int32Array): { graph: WeightedGraph; nodeOf: Int32Array; comm: Int32Array } {
234
+ const index = new Int32Array(g.n).fill(-1), nodeOf = new Int32Array(g.n);
235
+ let count = 0;
236
+ for (let i = 0; i < g.n; i++) { const r = refined[i]!; if (index[r] === -1) index[r] = count++; nodeOf[i] = index[r]!; }
237
+ const self = new Float64Array(count), pairs = new Map<number, number>(), partition = new Int32Array(count);
238
+ const dense = new Int32Array(g.n).fill(-1);
239
+ let communities = 0;
240
+ for (let i = 0; i < g.n; i++) {
241
+ const a = nodeOf[i]!;
242
+ if (dense[comm[i]!] === -1) dense[comm[i]!] = communities++;
243
+ partition[a] = dense[comm[i]!]!;
244
+ self[a]! += g.self[i]!;
245
+ for (let e = g.start[i]!; e < g.start[i + 1]!; e++) {
246
+ const j = g.to[e]!;
247
+ if (j < i) continue;
248
+ const b = nodeOf[j]!, w = g.weight[e]!;
249
+ if (a === b) self[a]! += w;
250
+ else { const key = Math.min(a, b) * count + Math.max(a, b); pairs.set(key, (pairs.get(key) ?? 0) + w); }
251
+ }
252
+ }
253
+ return { graph: weightedGraph(count, pairs, self), nodeOf, comm: partition };
254
+ }
255
+ function modularity(g: WeightedGraph, comm: Int32Array, gamma: number): number {
256
+ if (!g.m2) return 0;
257
+ const inside = new Float64Array(g.n), total = new Float64Array(g.n);
258
+ for (let i = 0; i < g.n; i++) {
259
+ total[comm[i]!]! += g.strength[i]!; inside[comm[i]!]! += 2 * g.self[i]!;
260
+ for (let e = g.start[i]!; e < g.start[i + 1]!; e++) if (comm[g.to[e]!] === comm[i]) inside[comm[i]!]! += g.weight[e]!;
261
+ }
262
+ let q = 0;
263
+ for (let c = 0; c < g.n; c++) if (total[c]) q += inside[c]! / g.m2 - gamma * (total[c]! / g.m2) ** 2;
264
+ return q;
265
+ }
266
+ /** Split every community into its connected components (never lowers modularity), then renumber densely by first node. */
267
+ function connectedSplit(g: WeightedGraph, comm: Int32Array): Int32Array {
268
+ const out = new Int32Array(g.n).fill(-1);
269
+ let next = 0;
270
+ const stack: number[] = [];
271
+ for (let i = 0; i < g.n; i++) {
272
+ if (out[i] !== -1) continue;
273
+ out[i] = next; stack.push(i);
274
+ while (stack.length) {
275
+ const v = stack.pop()!;
276
+ for (let e = g.start[v]!; e < g.start[v + 1]!; e++) { const u = g.to[e]!; if (out[u] === -1 && comm[u] === comm[i]) { out[u] = next; stack.push(u); } }
277
+ }
278
+ next++;
279
+ }
280
+ return out;
281
+ }
282
+ function leidenPass(base: WeightedGraph, initial: Int32Array, gamma: number): { comm: Int32Array; levels: number } {
283
+ let g = base, comm: Int32Array = initial.slice(), map: Int32Array = new Int32Array(base.n).map((_, i) => i), levels = 0;
284
+ for (; levels < MAX_LEVELS; levels++) {
285
+ moveNodes(g, comm, gamma);
286
+ let communities = 0;
287
+ const used = new Uint8Array(g.n);
288
+ for (let i = 0; i < g.n; i++) if (!used[comm[i]!]) { used[comm[i]!] = 1; communities++; }
289
+ if (communities === g.n) { levels++; break; }
290
+ const refined = refine(g, comm, gamma);
291
+ const next = aggregate(g, refined, comm);
292
+ if (next.graph.n === g.n) { levels++; break; } // no refinement merge: further levels cannot progress
293
+ map = map.map(x => next.nodeOf[x]!);
294
+ g = next.graph; comm = next.comm;
295
+ }
296
+ return { comm: connectedSplit(base, map.map(x => comm[x]!)), levels };
297
+ }
298
+ export interface LeidenResult { membership: Map<string, number>; modularity: number; levels: number; resolution: number }
299
+ /**
300
+ * Deterministic Leiden for modularity over an undirected simple graph. Excluded ids get no community; every other id gets
301
+ * one (an isolate is a singleton). Duplicate pairs count once and a self-loop has weight 1, as in a networkx Graph. Nodes are
302
+ * indexed in code-point id order, so the result does not depend on input order. Communities are numbered by size
303
+ * (descending), then by their smallest member id, and every community induces a connected subgraph.
304
+ */
305
+ export function leidenCommunities(ids: readonly string[], edges: readonly (readonly [string, string])[], options: { exclude?: ReadonlySet<string>; resolution?: number } = {}): LeidenResult {
306
+ const gamma = options.resolution ?? LEIDEN_RESOLUTION;
307
+ const order = [...new Set(ids)].filter(id => !options.exclude?.has(id)).sort(compare), index = new Map(order.map((id, i) => [id, i]));
308
+ const n = order.length, pairs = new Map<number, number>(), self = new Float64Array(n), selfSeen = new Uint8Array(n);
309
+ for (const [source, target] of edges) {
310
+ const a = index.get(source), b = index.get(target);
311
+ if (a === undefined || b === undefined) continue;
312
+ if (a === b) { if (!selfSeen[a]) { selfSeen[a] = 1; self[a] = 1; } continue; }
313
+ pairs.set(Math.min(a, b) * n + Math.max(a, b), 1);
314
+ }
315
+ const g = weightedGraph(n, pairs, self);
316
+ let comm: Int32Array = new Int32Array(n).map((_, i) => i), quality = modularity(g, comm, gamma), levels = 0;
317
+ for (let round = 0; round < MAX_ROUNDS; round++) {
318
+ const pass = leidenPass(g, comm, gamma), q = modularity(g, pass.comm, gamma);
319
+ levels += pass.levels;
320
+ if (round > 0 && !(q > quality)) break;
321
+ comm = pass.comm; quality = q;
322
+ }
323
+ const groups = new Map<number, number[]>();
324
+ for (let i = 0; i < n; i++) { const members = groups.get(comm[i]!) ?? []; members.push(i); groups.set(comm[i]!, members); }
325
+ const ranked = [...groups.values()].sort((a, b) => b.length - a.length || a[0]! - b[0]!);
326
+ const membership = new Map<string, number>();
327
+ ranked.forEach((members, id) => { for (const i of members) membership.set(order[i]!, id); });
328
+ return { membership, modularity: Math.round(quality * 1e6) / 1e6, levels, resolution: gamma };
329
+ }
330
+ /** Code-point string order, the tie-break every deterministic graph output uses. */
331
+ export const codePointCompare = compare;
332
+ /**
333
+ * The same deterministic Leiden as `buildGraph`, over an arbitrary undirected graph whose nodes are all clustered (no hub
334
+ * exclusion). Returns node id → community id (by size descending, then smallest member id).
335
+ */
336
+ export function labelCommunities(ids: readonly string[], edges: readonly (readonly [string, string])[]): Map<string, number> {
337
+ return leidenCommunities(ids, edges).membership;
141
338
  }
142
339
  /** Pure transformation of the derived-index snapshot, preserving the reference wiki graph semantics. */
143
340
  export function buildGraph(snapshot: AnalysisSnapshot): VaultGraph {
@@ -161,11 +358,11 @@ export function buildGraph(snapshot: AnalysisSnapshot): VaultGraph {
161
358
  if (edges.size > MAX_EDGES) throw new Error(`graph: exceeds ${MAX_EDGES} edges`);
162
359
  }
163
360
  }
164
- const graph: VaultGraph = { directed: false, multigraph: false, graph: { partitioner: "label-propagation", iterations: 0, max_iterations: MAX_ITERATIONS }, nodes,
361
+ const graph: VaultGraph = { directed: false, multigraph: false, graph: { partitioner: "leiden", resolution: LEIDEN_RESOLUTION, modularity: 0, iterations: 0, max_iterations: MAX_ITERATIONS }, nodes,
165
362
  edges: [...edges.values()].sort((a, b) => compare(a.source, b.source) || compare(a.target, b.target)) };
166
363
  const neighbors = adjacency(graph);
167
364
  for (const node of nodes) node.degree = neighbors.get(node.id)!.size + Number(neighbors.get(node.id)!.has(node.id));
168
- graph.graph.iterations = communities(nodes, neighbors);
365
+ communities(graph);
169
366
  return graph;
170
367
  }
171
368
  export function graphNeighbors(graph: VaultGraph, start: string, hops: 1 | 2, limit: number): GraphNeighborhood {
@@ -199,7 +396,7 @@ export function graphReport(graph: VaultGraph, today: string): string {
199
396
  for (let i = 0; i < queue.length; i++) for (const neighbor of neighbors.get(queue[i]!)!) if (!seen.has(neighbor)) { seen.add(neighbor); queue.push(neighbor); }
200
397
  }
201
398
  const count = graph.nodes.length;
202
- const out = [`# Graph Report (${today})`, "", "## Overview", "", "- **Mode**: wiki", "- **Partitioner**: deterministic label-propagation (32 iterations maximum)",
399
+ const out = [`# Graph Report (${today})`, "", "## Overview", "", "- **Mode**: wiki", "- **Partitioner**: deterministic Leiden (modularity)", `- **Resolution**: ${graph.graph.resolution}`, `- **Modularity**: ${graph.graph.modularity.toFixed(4)}`,
203
400
  `- **Nodes**: ${count} (${graph.nodes.filter(node => HUB_TYPES.has(node.type)).length} hubs excluded from clustering)`, `- **Edges**: ${graph.edges.length}`,
204
401
  `- **Communities**: ${groups.size}`, `- **Isolates**: ${graph.nodes.filter(node => node.degree === 0).length}`, `- **Connected components**: ${components}`,
205
402
  `- **Density**: ${(count > 1 ? 2 * graph.edges.length / (count * (count - 1)) : 0).toFixed(4)}`, "", "## God Nodes (Top Connected)", "", "| Rank | Node | Degree |", "|------|------|--------|"];
@@ -231,3 +428,11 @@ export function graphReport(graph: VaultGraph, today: string): string {
231
428
  else out.push("No cross-community edges found.");
232
429
  return out.join("\n") + "\n";
233
430
  }
431
+
432
+ /** Recompute derived degrees and communities after adding workspace nodes and edges. */
433
+ export function finalizeGraph(graph: VaultGraph): VaultGraph {
434
+ const neighbors = adjacency(graph);
435
+ for (const node of graph.nodes) node.degree = neighbors.get(node.id)!.size + Number(neighbors.get(node.id)!.has(node.id));
436
+ communities(graph);
437
+ return graph;
438
+ }