negotium 0.3.0 → 0.3.2

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.
Files changed (61) hide show
  1. package/dist/agent-helpers.js +107 -6
  2. package/dist/agent-helpers.js.map +11 -11
  3. package/dist/background-bash.js +3 -2
  4. package/dist/background-bash.js.map +4 -4
  5. package/dist/browser-runtime.js +3 -2
  6. package/dist/browser-runtime.js.map +4 -4
  7. package/dist/chunk-s2gez3wg.js.map +1 -1
  8. package/dist/{chunk-1y2xw0xe.js → chunk-vvfwqxnh.js} +33 -3
  9. package/dist/{chunk-1y2xw0xe.js.map → chunk-vvfwqxnh.js.map} +8 -8
  10. package/dist/hosted-agent.js +34 -4
  11. package/dist/hosted-agent.js.map +8 -8
  12. package/dist/main.js +1179 -366
  13. package/dist/main.js.map +24 -21
  14. package/dist/mcp-catalog.js +2 -1
  15. package/dist/mcp-catalog.js.map +3 -3
  16. package/dist/mcp-factories.js +635 -256
  17. package/dist/mcp-factories.js.map +15 -13
  18. package/dist/mcp-servers.js +3 -1
  19. package/dist/mcp-servers.js.map +3 -3
  20. package/dist/prompts.js +8 -2
  21. package/dist/prompts.js.map +5 -5
  22. package/dist/query-runtime.js +3 -2
  23. package/dist/query-runtime.js.map +4 -4
  24. package/dist/registry.js +3 -3
  25. package/dist/registry.js.map +2 -2
  26. package/dist/rollout.js +1 -1
  27. package/dist/runtime/src/mcp/canonical-bridge-config.ts +1 -1
  28. package/dist/runtime/src/mcp/canonical-proxy-server.ts +74 -1
  29. package/dist/runtime/src/mcp/decision-server.ts +21 -0
  30. package/dist/runtime/src/mcp/factories/decision.ts +178 -0
  31. package/dist/runtime/src/mcp/factories/index.ts +6 -0
  32. package/dist/runtime/src/mcp/runtime-spec.ts +1 -0
  33. package/dist/runtime/src/node-host.ts +1 -0
  34. package/dist/runtime/src/platform/config.ts +2 -1
  35. package/dist/runtime/src/platform/mcp-catalog-policy.ts +1 -0
  36. package/dist/runtime/src/platform/mcp-config.ts +31 -1
  37. package/dist/runtime/src/prompts/builders.ts +5 -0
  38. package/dist/runtime/src/runtime/visual-html.ts +68 -2
  39. package/dist/runtime/src/storage/decisions.ts +231 -0
  40. package/dist/runtime/src/storage/storage-public.ts +2 -0
  41. package/dist/runtime/src/types.ts +13 -0
  42. package/dist/runtime/src/version.ts +1 -1
  43. package/dist/runtime-helpers.js +71 -4
  44. package/dist/runtime-helpers.js.map +5 -5
  45. package/dist/storage.js +261 -56
  46. package/dist/storage.js.map +5 -4
  47. package/dist/types/apps/negotium/src/mcp-servers.d.ts +1 -1
  48. package/dist/types/packages/core/src/mcp/canonical-bridge-config.d.ts +1 -1
  49. package/dist/types/packages/core/src/mcp/factories/decision.d.ts +16 -0
  50. package/dist/types/packages/core/src/mcp/factories/index.d.ts +1 -0
  51. package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +1 -1
  52. package/dist/types/packages/core/src/platform/config.d.ts +2 -1
  53. package/dist/types/packages/core/src/platform/mcp-catalog-policy.d.ts +4 -0
  54. package/dist/types/packages/core/src/storage/decisions.d.ts +45 -0
  55. package/dist/types/packages/core/src/storage/storage-public.d.ts +2 -0
  56. package/dist/types/packages/core/src/types.d.ts +12 -0
  57. package/dist/types/packages/core/src/version.d.ts +1 -1
  58. package/dist/vault.js +3 -2
  59. package/dist/vault.js.map +4 -4
  60. package/install-browser-rs.mjs +3 -3
  61. package/package.json +2 -2
@@ -85,6 +85,72 @@ const taskTools: Tool[] = [
85
85
  },
86
86
  ];
87
87
 
88
+ const decisionStatus = ["proposed", "accepted", "executed", "rejected", "superseded"];
89
+ const decisionFields = {
90
+ action: { type: "string" },
91
+ reasoning: { type: "string" },
92
+ status: { type: "string", enum: decisionStatus },
93
+ caused_by: { type: "array", items: { type: "string" } },
94
+ } as const;
95
+ const decisionTools: Tool[] = [
96
+ {
97
+ name: "decision_create",
98
+ description: "Record durable decisions and their causal predecessors in this topic.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {
102
+ decisions: {
103
+ type: "array",
104
+ minItems: 1,
105
+ items: {
106
+ type: "object",
107
+ properties: decisionFields,
108
+ required: ["action", "reasoning"],
109
+ },
110
+ },
111
+ },
112
+ required: ["decisions"],
113
+ },
114
+ },
115
+ {
116
+ name: "decision_update",
117
+ description: "Update decisions or causal links in this topic.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {
121
+ updates: {
122
+ type: "array",
123
+ minItems: 1,
124
+ items: {
125
+ type: "object",
126
+ properties: { id: { type: "string" }, ...decisionFields },
127
+ required: ["id"],
128
+ },
129
+ },
130
+ },
131
+ required: ["updates"],
132
+ },
133
+ },
134
+ {
135
+ name: "decision_list",
136
+ description: "Read this topic's decisions.",
137
+ inputSchema: { type: "object", properties: {} },
138
+ },
139
+ {
140
+ name: "decision_get",
141
+ description: "Read one decision as JSON.",
142
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
143
+ },
144
+ {
145
+ name: "decision_delete",
146
+ description: "Delete decisions from this topic.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: { ids: { type: "array", items: { type: "string" } }, all: { type: "boolean" } },
150
+ },
151
+ },
152
+ ];
153
+
88
154
  const wikiTools: Tool[] = [
89
155
  {
90
156
  name: "wiki_query",
@@ -160,7 +226,14 @@ const wikiTools: Tool[] = [
160
226
  },
161
227
  ];
162
228
 
163
- const tools = surface === "task" ? taskTools : surface === "wiki" ? wikiTools : [];
229
+ const tools =
230
+ surface === "task"
231
+ ? taskTools
232
+ : surface === "decision"
233
+ ? decisionTools
234
+ : surface === "wiki"
235
+ ? wikiTools
236
+ : [];
164
237
  const allowed = new Set(tools.map((tool) => tool.name));
165
238
 
166
239
  function error(text: string): CallToolResult {
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import "./stdio-protect";
3
+ import { createDecisionMcpServer } from "#mcp/factories/decision";
4
+ import { connectStdio, parseUserIdArg } from "#mcp/mcp-helpers";
5
+ import { isAgentKind } from "#types";
6
+
7
+ const args = process.argv.slice(2);
8
+ const topic = args.find((arg) => arg.startsWith("--topic="))?.slice("--topic=".length) || "";
9
+ const topicId = args.find((arg) => arg.startsWith("--topic-id="))?.slice("--topic-id=".length);
10
+ const agentArg = args.find((arg) => arg.startsWith("--agent="))?.slice("--agent=".length);
11
+ const model = args.find((arg) => arg.startsWith("--model="))?.slice("--model=".length);
12
+
13
+ await connectStdio(
14
+ createDecisionMcpServer({
15
+ userId: parseUserIdArg(args),
16
+ topic,
17
+ topicId,
18
+ agent: agentArg && isAgentKind(agentArg) ? agentArg : "codex",
19
+ model,
20
+ }),
21
+ );
@@ -0,0 +1,178 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { errMsg } from "#platform/error";
4
+ import {
5
+ createDecisions,
6
+ DECISION_STATUS_VALUES,
7
+ decisionScopeKey,
8
+ deleteDecisions,
9
+ readDecisions,
10
+ renderDecisionList,
11
+ type StoredDecision,
12
+ updateDecisions,
13
+ writeDecisions,
14
+ } from "#storage/decisions";
15
+ import type { AgentKind } from "#types";
16
+ import { mcpError, mcpOk } from "../mcp-helpers";
17
+
18
+ export interface DecisionMcpContext {
19
+ userId: string;
20
+ topic: string;
21
+ topicId?: string;
22
+ agent: AgentKind;
23
+ model?: string;
24
+ }
25
+
26
+ export interface DecisionMcpHost {
27
+ readDecisions(userId: string, scopeKey: string): StoredDecision[];
28
+ writeDecisions(userId: string, scopeKey: string, decisions: StoredDecision[]): void;
29
+ }
30
+
31
+ export const defaultDecisionMcpHost: DecisionMcpHost = { readDecisions, writeDecisions };
32
+
33
+ export function createDecisionMcpServer(
34
+ context: DecisionMcpContext,
35
+ host: DecisionMcpHost = defaultDecisionMcpHost,
36
+ ): McpServer {
37
+ const scopeKey = context.topic
38
+ ? decisionScopeKey({ topicId: context.topicId, session: context.topic })
39
+ : "";
40
+ const server = new McpServer({ name: "decision", version: "1.0.0" });
41
+ const requireContext = (): ReturnType<typeof mcpError> | null =>
42
+ !context.userId || !scopeKey ? mcpError("Error: missing userId/topic context.") : null;
43
+ const statusEnum = z.enum(DECISION_STATUS_VALUES);
44
+
45
+ server.tool(
46
+ "decision_create",
47
+ "Record one or more durable decisions in this topic. Include the rationale and causal predecessors.",
48
+ {
49
+ decisions: z
50
+ .array(
51
+ z.object({
52
+ action: z.string().min(1).describe("Concise statement of what was decided"),
53
+ reasoning: z.string().min(1).describe("Why this choice was made"),
54
+ status: statusEnum.optional().describe("Defaults to accepted"),
55
+ caused_by: z.array(z.string()).optional().describe("Upstream decision ids"),
56
+ }),
57
+ )
58
+ .min(1),
59
+ },
60
+ async ({ decisions: inputs }) => {
61
+ const guard = requireContext();
62
+ if (guard) return guard;
63
+ try {
64
+ const current = host.readDecisions(context.userId, scopeKey);
65
+ const { decisions, created } = createDecisions(
66
+ current,
67
+ inputs.map((input) => ({
68
+ action: input.action,
69
+ reasoning: input.reasoning,
70
+ status: input.status,
71
+ causedBy: input.caused_by,
72
+ agent: context.agent,
73
+ model: context.model,
74
+ })),
75
+ );
76
+ host.writeDecisions(context.userId, scopeKey, decisions);
77
+ return mcpOk(
78
+ `${created.length} decision(s) recorded (${created.map((item) => `#${item.id}`).join(", ")})\n\n${renderDecisionList(decisions)}`,
79
+ );
80
+ } catch (error) {
81
+ return mcpError(`decision_create failed: ${errMsg(error)}`);
82
+ }
83
+ },
84
+ );
85
+
86
+ server.tool(
87
+ "decision_update",
88
+ "Update decisions or their causal links in this topic.",
89
+ {
90
+ updates: z
91
+ .array(
92
+ z.object({
93
+ id: z.string(),
94
+ action: z.string().min(1).optional(),
95
+ reasoning: z.string().min(1).optional(),
96
+ status: statusEnum.optional(),
97
+ caused_by: z.array(z.string()).optional().describe("Replacement upstream decision ids"),
98
+ }),
99
+ )
100
+ .min(1),
101
+ },
102
+ async ({ updates }) => {
103
+ const guard = requireContext();
104
+ if (guard) return guard;
105
+ try {
106
+ const current = host.readDecisions(context.userId, scopeKey);
107
+ const { decisions, missing } = updateDecisions(
108
+ current,
109
+ updates.map((update) => ({
110
+ ...update,
111
+ causedBy: update.caused_by,
112
+ })),
113
+ );
114
+ host.writeDecisions(context.userId, scopeKey, decisions);
115
+ const warning = missing.length ? `\nMissing ids ignored: ${missing.join(", ")}` : "";
116
+ return mcpOk(`${renderDecisionList(decisions)}${warning}`);
117
+ } catch (error) {
118
+ return mcpError(`decision_update failed: ${errMsg(error)}`);
119
+ }
120
+ },
121
+ );
122
+
123
+ server.tool(
124
+ "decision_list",
125
+ "Read this topic's decision graph as a concise list.",
126
+ {},
127
+ async () => {
128
+ const guard = requireContext();
129
+ if (guard) return guard;
130
+ try {
131
+ return mcpOk(renderDecisionList(host.readDecisions(context.userId, scopeKey)));
132
+ } catch (error) {
133
+ return mcpError(`decision_list failed: ${errMsg(error)}`);
134
+ }
135
+ },
136
+ );
137
+
138
+ server.tool(
139
+ "decision_get",
140
+ "Read one topic decision as JSON.",
141
+ { id: z.string() },
142
+ async ({ id }) => {
143
+ const guard = requireContext();
144
+ if (guard) return guard;
145
+ try {
146
+ const decision = host
147
+ .readDecisions(context.userId, scopeKey)
148
+ .find((item) => item.id === id);
149
+ return decision
150
+ ? mcpOk(JSON.stringify(decision, null, 2))
151
+ : mcpError(`Decision #${id} not found.`);
152
+ } catch (error) {
153
+ return mcpError(`decision_get failed: ${errMsg(error)}`);
154
+ }
155
+ },
156
+ );
157
+
158
+ server.tool(
159
+ "decision_delete",
160
+ "Delete decisions. Downstream causal references to deleted decisions are removed.",
161
+ { ids: z.array(z.string()).optional(), all: z.boolean().optional() },
162
+ async ({ ids, all }) => {
163
+ const guard = requireContext();
164
+ if (guard) return guard;
165
+ if (!all && (!ids || ids.length === 0)) return mcpError("Provide ids or all=true.");
166
+ try {
167
+ const current = host.readDecisions(context.userId, scopeKey);
168
+ const { decisions, removed } = deleteDecisions(current, { ids, all });
169
+ host.writeDecisions(context.userId, scopeKey, decisions);
170
+ return mcpOk(`${removed} decision(s) deleted\n\n${renderDecisionList(decisions)}`);
171
+ } catch (error) {
172
+ return mcpError(`decision_delete failed: ${errMsg(error)}`);
173
+ }
174
+ },
175
+ );
176
+
177
+ return server;
178
+ }
@@ -29,6 +29,12 @@ export {
29
29
  type CompactionLogMcpContext,
30
30
  createCompactionLogMcpServer,
31
31
  } from "./compaction-log";
32
+ export {
33
+ createDecisionMcpServer,
34
+ type DecisionMcpContext,
35
+ type DecisionMcpHost,
36
+ defaultDecisionMcpHost,
37
+ } from "./decision";
32
38
  export {
33
39
  createSessionCommMcpServer,
34
40
  type SessionCommMcpHost,
@@ -17,6 +17,7 @@ export const RUNTIME_MCP_KEY = "runtime";
17
17
  export const RUNTIME_MCP_BASE_PATH = "/mcp/runtime";
18
18
  export const HOSTED_MCP_SURFACES = [
19
19
  "task",
20
+ "decision",
20
21
  "token-stats",
21
22
  "system-health",
22
23
  "vault",
@@ -67,6 +67,7 @@ export { startSessionInboxWorker } from "#runtime/inbox";
67
67
  export { startAiTurn, startDurableTurnRequestWorker } from "#runtime/turn-runner";
68
68
  export { appendApiMessage, getApiMessage, listApiMessages } from "#storage/api-messages";
69
69
  export { getTopic, upsertTopic } from "#storage/api-topics";
70
+ export { readDecisions, writeDecisionGraphSvg } from "#storage/decisions";
70
71
  export type { StoredRuntimeEvent } from "#storage/runtime-events";
71
72
  export {
72
73
  latestRuntimeEventSeq,
@@ -99,7 +99,7 @@ export function resolveOutputLanguage(): string {
99
99
  }
100
100
 
101
101
  /** Browser.rs release tested with this Negotium version. */
102
- export const BROWSER_RS_VERSION = "v0.1.19";
102
+ export const BROWSER_RS_VERSION = "v0.1.21";
103
103
  /** Require the authenticated listener and the current Browser.rs tool contract. */
104
104
  export const BROWSER_RS_MIN_SECURE_VERSION = "0.1.15";
105
105
 
@@ -277,6 +277,7 @@ export const TSCONFIG_PATH = resolve(PROJECT_ROOT, "tsconfig.json");
277
277
  export const SESSION_COMM_SERVER = resolve(PROJECT_ROOT, "src/mcp/session-comm/server.ts");
278
278
 
279
279
  export const TASK_SERVER = resolve(PROJECT_ROOT, "src/mcp/task-server.ts");
280
+ export const DECISION_SERVER = resolve(PROJECT_ROOT, "src/mcp/decision-server.ts");
280
281
  export const BROWSER_MCP_SSE_PROXY_SERVER = resolve(
281
282
  PROJECT_ROOT,
282
283
  "src/mcp/browser-sse-proxy-server.ts",
@@ -11,6 +11,7 @@ export const COMMON_RUNTIME_MCP_POLICY = {
11
11
  runtime: { scopes: ["forum", "manager", "fork", "cron"], forumRequired: true },
12
12
  "token-stats": { scopes: ["dm", "forum", "manager", "cron"], forumRequired: true },
13
13
  task: { scopes: ["dm", "forum", "manager", "cron"], forumRequired: true },
14
+ decision: { scopes: ["dm", "forum", "manager", "cron"], forumRequired: true },
14
15
  "session-comm": { scopes: ["forum", "fork", "manager"], forumRequired: true },
15
16
  wiki: { scopes: ["dm", "forum", "manager", "cron"], forumRequired: true },
16
17
  skills: { scopes: ["dm", "forum", "manager", "cron"], forumRequired: true },
@@ -11,6 +11,7 @@ import {
11
11
  AGENT_HEALTH_SERVER,
12
12
  BROWSER_MCP_SSE_PROXY_SERVER,
13
13
  CANONICAL_MCP_PROXY_SERVER,
14
+ DECISION_SERVER,
14
15
  envText,
15
16
  FALLBACK_AGENT,
16
17
  resolveTopicWorkspaceDir,
@@ -417,6 +418,35 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
417
418
  );
418
419
  },
419
420
  },
421
+ decision: {
422
+ ...commonRuntimeMcpPolicy("decision"),
423
+ build(ctx) {
424
+ const { userId, session, topicId, queryId, agent, model, peerBridge } = ctx;
425
+ if (peerBridge) {
426
+ if (!topicId || !queryId) return null;
427
+ const env = canonicalMcpBridgeEnv({
428
+ surface: "decision",
429
+ userId,
430
+ topicId,
431
+ queryId,
432
+ peerBridge,
433
+ });
434
+ return env
435
+ ? buildStdioMcpServer(agent, CANONICAL_MCP_PROXY_SERVER, ["--surface=decision"], env)
436
+ : null;
437
+ }
438
+ const args = [
439
+ `--user-id=${userId}`,
440
+ `--topic=${session}`,
441
+ `--agent=${agent ?? FALLBACK_AGENT}`,
442
+ ];
443
+ if (topicId) args.push(`--topic-id=${topicId}`);
444
+ if (model) args.push(`--model=${model}`);
445
+ return buildBuiltinMcpServer("decision", ctx, () =>
446
+ buildStdioMcpServer(agent, DECISION_SERVER, args),
447
+ );
448
+ },
449
+ },
420
450
  "session-comm": {
421
451
  // Exposed in `manager` scope as well so the General-topic manager agent
422
452
  // can wake fresh-created topics via tell_session/ask_session right after
@@ -818,7 +848,7 @@ export function getForumMcpServers(opts: {
818
848
  } = opts;
819
849
 
820
850
  const filter = (name: string) => {
821
- if (silent && name === "task") return false;
851
+ if (silent && (name === "task" || name === "decision")) return false;
822
852
  if (enabled === null) return true;
823
853
  return (
824
854
  enabled.includes(name) || (REQUIRED_FORUM_MCP_SERVERS as readonly string[]).includes(name)
@@ -274,6 +274,7 @@ function buildRuntimeToolSection(
274
274
  } = opts;
275
275
  const runtimeNamespace = "mcp__runtime";
276
276
  const taskNamespace = "mcp__task";
277
+ const decisionNamespace = "mcp__decision";
277
278
  const visualToolLine =
278
279
  agentKind === "codex"
279
280
  ? `To display charts, tables, or interactive HTML results to the user, call the \`show_html\` function in the \`${runtimeNamespace}\` namespace with { html: "<complete HTML string>", title?: "optional title" }.`
@@ -302,6 +303,7 @@ function buildRuntimeToolSection(
302
303
  agentKind === "codex"
303
304
  ? `For task tracking, use \`task_create\`, \`task_update\`, \`task_list\`, \`task_get\`, and \`task_delete\` functions in the \`${taskNamespace}\` namespace.`
304
305
  : `For task tracking, use MCP tools "${taskNamespace}__task_create", "${taskNamespace}__task_update", "${taskNamespace}__task_list", "${taskNamespace}__task_get", and "${taskNamespace}__task_delete".`;
306
+ const decisionToolLine = `Use the shared Decision tools in the \`${decisionNamespace}\` namespace when an architectural, product, or operational choice establishes or changes a durable direction or constraint. Do not record routine task progress or temporary implementation details; link causal predecessors when relevant.`;
305
307
  const runtimeToolRef = (name: string): string =>
306
308
  agentKind === "codex" ? `\`${name}\`` : `"${runtimeNamespace}__${name}"`;
307
309
  const spawnSubagentToolLine = `Use ${runtimeToolRef("spawn_subagent")} for self-contained parallel or long-running background work; keep quick work inline.`;
@@ -364,6 +366,9 @@ function buildRuntimeToolSection(
364
366
  taskToolLine,
365
367
  "Use this shared task store for plans, progress, and checklist updates; it is visible across claude/codex/maestro turns.",
366
368
  nativeTaskPolicyLine,
369
+ "",
370
+ "## Shared Decisions",
371
+ decisionToolLine,
367
372
  ...extensions.render("after-shared-tasks"),
368
373
  ...fileDeliverySection,
369
374
  ...extensions.render("before-session-communication"),
@@ -55,6 +55,10 @@ export function buildMermaidHtml(
55
55
  .controls button:focus-visible{outline:2px solid var(--celadon);outline-offset:1px}
56
56
  .zoom-value{min-width:46px;color:var(--graphite);font:500 11px/1 Geist,system-ui,sans-serif;text-align:center;font-variant-numeric:tabular-nums}
57
57
  .error{margin:0;white-space:pre-wrap;color:#7D2E2E;background:#FFF5F2;border:1px solid #E4B9B1;border-radius:6px;padding:14px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}
58
+ .failure{max-width:56ch;margin:0 auto}
59
+ .failure p{margin:0 0 12px;color:var(--graphite);font:14px/1.6 Geist,system-ui,sans-serif}
60
+ .failure details{color:var(--graphite);font:12px/1.5 Geist,system-ui,sans-serif}
61
+ .failure summary{cursor:pointer;padding:4px 0}
58
62
  @media(max-width:600px){.viewport{padding:54px 14px 18px}.controls{top:10px;right:10px}}
59
63
  </style>
60
64
  </head>
@@ -69,11 +73,58 @@ export function buildMermaidHtml(
69
73
  <script data-otium-mermaid-runtime src="${safeScriptUrl}"></script>
70
74
  <script>
71
75
  (async () => {
76
+ // An unrendered document reports itself in more than one voice: Mermaid's
77
+ // own 0x0 guard, and the browser refusing geometry on a path that was
78
+ // never laid out. Both mean the same thing, so both are worth one retry
79
+ // and, if it still fails, the same explanation.
80
+ const unrendered = (error) => {
81
+ const message = String(error && error.message ? error.message : error);
82
+ return message.indexOf("not in render tree") !== -1 || message.indexOf("path is empty") !== -1;
83
+ };
72
84
  try {
73
85
  const runtime = globalThis.mermaid;
74
86
  if (!runtime) throw new Error("Mermaid renderer failed to load.");
75
87
  runtime.initialize({ startOnLoad: false, securityLevel: "strict", theme: ${safeTheme} });
76
- await runtime.run({ querySelector: ".mermaid" });
88
+ const host = document.querySelector(".mermaid");
89
+ // Mermaid sizes every label by appending a probe <svg> to the body and
90
+ // reading getBBox(), and it throws "svg element not in render tree" the
91
+ // moment that comes back 0x0. That is what a hidden panel looks like from
92
+ // in here: the document exists but nothing is in the render tree, so the
93
+ // measurement has no geometry to report. Ask the same question Mermaid
94
+ // will ask, and only start once it has an answer.
95
+ const measurable = () => {
96
+ const probe = document.createElementNS("http://www.w3.org/2000/svg", "svg");
97
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
98
+ text.textContent = "M";
99
+ probe.appendChild(text);
100
+ document.body.appendChild(probe);
101
+ let box = { width: 0, height: 0 };
102
+ try { box = text.getBBox(); } catch (ignored) {}
103
+ probe.remove();
104
+ return box.width > 0 || box.height > 0;
105
+ };
106
+ // requestAnimationFrame is the right clock here: a hidden document stops
107
+ // being animated, so this waits without spinning and resumes on the frame
108
+ // the panel is shown. The cap counts rendered frames, not wall time.
109
+ const waitUntilMeasurable = async (maxFrames) => {
110
+ for (let frame = 0; frame < maxFrames; frame += 1) {
111
+ if (measurable()) return true;
112
+ await new Promise((next) => requestAnimationFrame(next));
113
+ }
114
+ return measurable();
115
+ };
116
+ await waitUntilMeasurable(600);
117
+ try {
118
+ await runtime.run({ querySelector: ".mermaid" });
119
+ } catch (firstAttempt) {
120
+ if (!unrendered(firstAttempt)) throw firstAttempt;
121
+ // The panel can be hidden again between the probe and the real measure.
122
+ // Clear the marker Mermaid leaves behind so the retry is not skipped as
123
+ // already done, then wait for the render tree once more.
124
+ host.removeAttribute("data-processed");
125
+ await waitUntilMeasurable(600);
126
+ await runtime.run({ querySelector: ".mermaid" });
127
+ }
77
128
  const viewport = document.querySelector(".viewport");
78
129
  const svg = document.querySelector(".mermaid svg");
79
130
  const value = document.querySelector(".zoom-value");
@@ -100,7 +151,22 @@ export function buildMermaidHtml(
100
151
  applyScale(scale, false);
101
152
  } catch (error) {
102
153
  document.querySelector(".controls")?.remove();
103
- document.querySelector(".viewport").innerHTML = '<pre class="error">' + String(error && error.message ? error.message : error).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])) + '</pre>';
154
+ const raw = String(error && error.message ? error.message : error);
155
+ const escape = (value) => value.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
156
+ // Mermaid aborts the whole render when it cannot place an edge label:
157
+ // cardinality markers ask for a point a fixed distance along the edge,
158
+ // and a relation laid out shorter than that walks off the end. Nothing
159
+ // is wrong with the diagram, so the raw message sends authors looking
160
+ // for a syntax error that does not exist. Say what actually moves it.
161
+ const note = raw.indexOf("Could not find a suitable point") !== -1
162
+ ? "Two nodes ended up too close together for Mermaid to fit a label on the edge between them. Renaming a node, adding another, or setting an explicit direction usually spreads the layout enough to render."
163
+ : unrendered(error)
164
+ ? "The panel stayed hidden long enough that there was never a laid-out page to measure the diagram against. Reopening the panel renders it."
165
+ : "This diagram could not be rendered.";
166
+ document.querySelector(".viewport").innerHTML =
167
+ '<div class="failure"><p>' + escape(note) +
168
+ '</p><details><summary>Technical detail</summary><pre class="error">' +
169
+ escape(raw) + '</pre></details></div>';
104
170
  }
105
171
  })();
106
172
  </script>