negotium 0.1.7 → 0.1.8

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 (256) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-0ay6cjdy.js +1331 -0
  3. package/dist/chunk-0ay6cjdy.js.map +16 -0
  4. package/dist/chunk-1k7t7bnx.js +97 -0
  5. package/dist/chunk-1k7t7bnx.js.map +10 -0
  6. package/dist/chunk-5ahkew9c.js +806 -0
  7. package/dist/chunk-5ahkew9c.js.map +17 -0
  8. package/dist/chunk-6cpn7ht2.js +287 -0
  9. package/dist/chunk-6cpn7ht2.js.map +13 -0
  10. package/dist/chunk-6kctn20e.js +3529 -0
  11. package/dist/chunk-6kctn20e.js.map +20 -0
  12. package/dist/chunk-7xqj7aex.js +77 -0
  13. package/dist/chunk-7xqj7aex.js.map +11 -0
  14. package/dist/chunk-9p0ww3tk.js +41 -0
  15. package/dist/chunk-9p0ww3tk.js.map +10 -0
  16. package/dist/chunk-a92a7nrk.js +252 -0
  17. package/dist/chunk-a92a7nrk.js.map +10 -0
  18. package/dist/chunk-c500xe9z.js +33 -0
  19. package/dist/chunk-c500xe9z.js.map +10 -0
  20. package/dist/chunk-cds1qxsf.js +1644 -0
  21. package/dist/chunk-cds1qxsf.js.map +17 -0
  22. package/dist/chunk-e4407g8x.js +274 -0
  23. package/dist/chunk-e4407g8x.js.map +10 -0
  24. package/dist/chunk-e5pbggsh.js +363 -0
  25. package/dist/chunk-e5pbggsh.js.map +11 -0
  26. package/dist/chunk-edb07sjp.js +567 -0
  27. package/dist/chunk-edb07sjp.js.map +14 -0
  28. package/dist/chunk-exd0v74a.js +3012 -0
  29. package/dist/chunk-exd0v74a.js.map +32 -0
  30. package/dist/chunk-fgrme9tr.js +4398 -0
  31. package/dist/chunk-fgrme9tr.js.map +47 -0
  32. package/dist/chunk-fka9eqe1.js +581 -0
  33. package/dist/chunk-fka9eqe1.js.map +11 -0
  34. package/dist/chunk-g82jygez.js +108 -0
  35. package/dist/chunk-g82jygez.js.map +10 -0
  36. package/dist/chunk-ghs2j1fr.js +312 -0
  37. package/dist/chunk-ghs2j1fr.js.map +10 -0
  38. package/dist/chunk-gmy11efb.js +125 -0
  39. package/dist/chunk-gmy11efb.js.map +10 -0
  40. package/dist/chunk-gxma4c70.js +110 -0
  41. package/dist/chunk-gxma4c70.js.map +10 -0
  42. package/dist/chunk-hkdqhm34.js +61 -0
  43. package/dist/chunk-hkdqhm34.js.map +10 -0
  44. package/dist/chunk-hpa0cpzh.js +134 -0
  45. package/dist/chunk-hpa0cpzh.js.map +10 -0
  46. package/dist/chunk-hwy3n31w.js +21 -0
  47. package/dist/chunk-hwy3n31w.js.map +9 -0
  48. package/dist/chunk-j2behpy7.js +214 -0
  49. package/dist/chunk-j2behpy7.js.map +10 -0
  50. package/dist/chunk-jc0at0tc.js +102 -0
  51. package/dist/chunk-jc0at0tc.js.map +10 -0
  52. package/dist/chunk-jkqq5ef6.js +243 -0
  53. package/dist/chunk-jkqq5ef6.js.map +11 -0
  54. package/dist/chunk-jzkw2f01.js +51 -0
  55. package/dist/chunk-jzkw2f01.js.map +10 -0
  56. package/dist/chunk-jzz91jwh.js +70 -0
  57. package/dist/chunk-jzz91jwh.js.map +10 -0
  58. package/dist/chunk-k9z47mab.js +247 -0
  59. package/dist/chunk-k9z47mab.js.map +12 -0
  60. package/dist/chunk-n17yxpa8.js +23 -0
  61. package/dist/chunk-n17yxpa8.js.map +10 -0
  62. package/dist/chunk-naa09ags.js +2143 -0
  63. package/dist/chunk-naa09ags.js.map +15 -0
  64. package/dist/chunk-ns3ke88n.js +142 -0
  65. package/dist/chunk-ns3ke88n.js.map +10 -0
  66. package/dist/chunk-p3cr2ejg.js +1626 -0
  67. package/dist/chunk-p3cr2ejg.js.map +15 -0
  68. package/dist/chunk-q5bcpf23.js +2925 -0
  69. package/dist/chunk-q5bcpf23.js.map +20 -0
  70. package/dist/chunk-rafc55bs.js +424 -0
  71. package/dist/chunk-rafc55bs.js.map +11 -0
  72. package/dist/chunk-rbrxjvbj.js +2830 -0
  73. package/dist/chunk-rbrxjvbj.js.map +23 -0
  74. package/dist/chunk-sa874j5c.js +9 -0
  75. package/dist/chunk-sa874j5c.js.map +10 -0
  76. package/dist/chunk-sgsz2fpq.js +289 -0
  77. package/dist/chunk-sgsz2fpq.js.map +10 -0
  78. package/dist/chunk-sm6fh5c5.js +569 -0
  79. package/dist/chunk-sm6fh5c5.js.map +14 -0
  80. package/dist/chunk-t9zcg8f1.js +499 -0
  81. package/dist/chunk-t9zcg8f1.js.map +13 -0
  82. package/dist/chunk-vp2db42d.js +121 -0
  83. package/dist/chunk-vp2db42d.js.map +10 -0
  84. package/dist/chunk-w13jjf4a.js +46 -0
  85. package/dist/chunk-w13jjf4a.js.map +10 -0
  86. package/dist/chunk-x1arr7ze.js +63 -0
  87. package/dist/chunk-x1arr7ze.js.map +10 -0
  88. package/dist/chunk-xfq68rch.js +583 -0
  89. package/dist/chunk-xfq68rch.js.map +11 -0
  90. package/dist/main.js +132 -20
  91. package/dist/main.js.map +4 -4
  92. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  93. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  94. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  95. package/dist/runtime/src/agents/archiver.ts +396 -0
  96. package/dist/runtime/src/agents/auth-check.ts +64 -0
  97. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  98. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  99. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  100. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  101. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  102. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  103. package/dist/runtime/src/agents/contracts.ts +69 -0
  104. package/dist/runtime/src/agents/deep-map.ts +13 -0
  105. package/dist/runtime/src/agents/execution-host.ts +91 -0
  106. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  107. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  108. package/dist/runtime/src/agents/fork.ts +65 -0
  109. package/dist/runtime/src/agents/hosted-agent.ts +48 -0
  110. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  111. package/dist/runtime/src/agents/index.ts +205 -0
  112. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  113. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  114. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  115. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  116. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  117. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  118. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  119. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  120. package/dist/runtime/src/agents/registry.ts +24 -0
  121. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  122. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  123. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  124. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  125. package/dist/runtime/src/agents/task-events.ts +44 -0
  126. package/dist/runtime/src/agents/tool-format.ts +352 -0
  127. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  128. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  129. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  130. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  131. package/dist/runtime/src/application/submit-user-message.ts +47 -0
  132. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  133. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  134. package/dist/runtime/src/application/topic-service.ts +139 -0
  135. package/dist/runtime/src/application/vault-command.ts +170 -0
  136. package/dist/runtime/src/bus.ts +319 -0
  137. package/dist/runtime/src/index.ts +333 -0
  138. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  139. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  140. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  141. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  142. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  143. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  144. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  145. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  146. package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
  147. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  148. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  149. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  150. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  151. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  152. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  153. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  154. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  155. package/dist/runtime/src/mcp/task-server.ts +173 -0
  156. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  157. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  158. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  159. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  160. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  161. package/dist/runtime/src/media/file-events.ts +33 -0
  162. package/dist/runtime/src/media/text-extractor.ts +312 -0
  163. package/dist/runtime/src/media/video.ts +92 -0
  164. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  165. package/dist/runtime/src/outbox/index.ts +8 -0
  166. package/dist/runtime/src/outbox/utils.ts +51 -0
  167. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  168. package/dist/runtime/src/platform/config.ts +371 -0
  169. package/dist/runtime/src/platform/constants.ts +18 -0
  170. package/dist/runtime/src/platform/debug-users.ts +14 -0
  171. package/dist/runtime/src/platform/delay.ts +1 -0
  172. package/dist/runtime/src/platform/error.ts +9 -0
  173. package/dist/runtime/src/platform/file-utils.ts +16 -0
  174. package/dist/runtime/src/platform/jsonl.ts +202 -0
  175. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  176. package/dist/runtime/src/platform/logger.ts +31 -0
  177. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  178. package/dist/runtime/src/platform/mcp-config.ts +865 -0
  179. package/dist/runtime/src/platform/modules.ts +145 -0
  180. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  181. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  182. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  183. package/dist/runtime/src/prompts/builders.ts +357 -0
  184. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  185. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  186. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  187. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  188. package/dist/runtime/src/query/active-rooms.ts +532 -0
  189. package/dist/runtime/src/query/control.ts +313 -0
  190. package/dist/runtime/src/query/index.ts +24 -0
  191. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  192. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  193. package/dist/runtime/src/query/state.ts +94 -0
  194. package/dist/runtime/src/query/types.ts +83 -0
  195. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  196. package/dist/runtime/src/runtime/attachments.ts +151 -0
  197. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  198. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  199. package/dist/runtime/src/runtime/errors.ts +69 -0
  200. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  201. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  202. package/dist/runtime/src/runtime/footer.ts +23 -0
  203. package/dist/runtime/src/runtime/inbox.ts +946 -0
  204. package/dist/runtime/src/runtime/tasks.ts +56 -0
  205. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  206. package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
  207. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  208. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  209. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  210. package/dist/runtime/src/runtime/visuals.ts +171 -0
  211. package/dist/runtime/src/security/sanitize.ts +27 -0
  212. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  213. package/dist/runtime/src/storage/activity-log.ts +152 -0
  214. package/dist/runtime/src/storage/api-messages.ts +617 -0
  215. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  216. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  217. package/dist/runtime/src/storage/api-topics.ts +844 -0
  218. package/dist/runtime/src/storage/app-settings.ts +44 -0
  219. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  220. package/dist/runtime/src/storage/conversations.ts +268 -0
  221. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  222. package/dist/runtime/src/storage/forum/index.ts +4 -0
  223. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  224. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  225. package/dist/runtime/src/storage/forum-db.ts +23 -0
  226. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  227. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  228. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  229. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  230. package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
  231. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  232. package/dist/runtime/src/storage/session-asks.ts +347 -0
  233. package/dist/runtime/src/storage/sqlite.ts +129 -0
  234. package/dist/runtime/src/storage/tasks.ts +179 -0
  235. package/dist/runtime/src/storage/token-stats.ts +144 -0
  236. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  237. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  238. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  239. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  240. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  241. package/dist/runtime/src/storage/vault.ts +225 -0
  242. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  243. package/dist/runtime/src/storage/wiki.ts +85 -0
  244. package/dist/runtime/src/topics/create.ts +123 -0
  245. package/dist/runtime/src/topics/derive.ts +398 -0
  246. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  247. package/dist/runtime/src/topics/links.ts +7 -0
  248. package/dist/runtime/src/topics/personal-general.ts +65 -0
  249. package/dist/runtime/src/topics/session.ts +381 -0
  250. package/dist/runtime/src/types/api.ts +497 -0
  251. package/dist/runtime/src/types.ts +255 -0
  252. package/dist/runtime/src/version.ts +1 -0
  253. package/dist/runtime/tsconfig.json +13 -0
  254. package/package.json +16 -3
  255. package/dist/main.d.ts +0 -3
  256. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Session-bloat alert.
3
+ *
4
+ * Adapted from the Telegram build's `telegram/topic/alert.ts`. After each AI turn
5
+ * we inspect the provider's latest-request context occupancy. Aggregate turn
6
+ * input is deliberately not used: a tool-heavy turn can make many cached model
7
+ * calls and report millions of billable input tokens while the actual context
8
+ * window remains mostly empty.
9
+ *
10
+ * Deliberately quiet: at most ONE alert per session lifetime (until the next
11
+ * `/new` clears it), so a long-running topic is never nagged turn after turn.
12
+ *
13
+ * The heavy lifting here (threshold, per-topic dedup, message text) is kept free
14
+ * of I/O so it unit-tests cleanly; the caller in `runtime/turn-runner.ts` handles
15
+ * the General/subagent guards and the actual posting.
16
+ */
17
+ import type { TokenUsage } from "#types";
18
+
19
+ const CONTEXT_ALERT_RATIO = 0.8;
20
+
21
+ // Topics already warned this session, keyed by (user, topic) so the same topic
22
+ // under different users tracks independently. Cleared on session reset (`/new`).
23
+ const alerted = new Set<string>();
24
+
25
+ const alertKey = (userId: string, topicId: string) => `${userId}:${topicId}`;
26
+
27
+ /** Latest-request context occupancy, or null when the provider did not report it. */
28
+ export function contextUsageRatio(usage: TokenUsage): number | null {
29
+ if (
30
+ usage.contextTokens === undefined ||
31
+ usage.contextWindow === undefined ||
32
+ !Number.isFinite(usage.contextTokens) ||
33
+ !Number.isFinite(usage.contextWindow) ||
34
+ usage.contextTokens < 0 ||
35
+ usage.contextWindow <= 0
36
+ ) {
37
+ return null;
38
+ }
39
+ return usage.contextTokens / usage.contextWindow;
40
+ }
41
+
42
+ function fmt(n: number): string {
43
+ return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`;
44
+ }
45
+
46
+ function buildUsageAlertText(topicTitle: string, usage: TokenUsage): string {
47
+ const ratio = contextUsageRatio(usage) ?? 0;
48
+ const used = usage.contextTokens ?? 0;
49
+ const window = usage.contextWindow ?? 0;
50
+ return (
51
+ `⚠️ "${topicTitle}" context가 ${Math.round(ratio * 100)}% 찼어요 ` +
52
+ `(${fmt(used)} / ${fmt(window)} 토큰)\n\n` +
53
+ `이 토픽에서 /compact 를 입력하면 핵심 맥락을 요약해 이어가면서 context를 줄입니다. ` +
54
+ `완전히 새로 시작하려면 /new 를 사용하세요.\n\n` +
55
+ `두 명령 모두 지금까지 주고받은 보이는 대화 내역은 그대로 유지합니다.`
56
+ );
57
+ }
58
+
59
+ /**
60
+ * If the latest request filled at least 80% of the provider context window and
61
+ * the topic hasn't been warned yet this session, return the alert text and mark
62
+ * it warned; otherwise null. Fires at most once per session lifetime — the
63
+ * notice won't repeat turn after turn — and re-arms only after `/new` clears it.
64
+ */
65
+ export function nextUsageAlert(
66
+ userId: string,
67
+ topicId: string,
68
+ topicTitle: string,
69
+ usage: TokenUsage,
70
+ ): string | null {
71
+ const ratio = contextUsageRatio(usage);
72
+ if (ratio === null || ratio < CONTEXT_ALERT_RATIO) return null;
73
+
74
+ const key = alertKey(userId, topicId);
75
+ if (alerted.has(key)) return null;
76
+ alerted.add(key);
77
+
78
+ return buildUsageAlertText(topicTitle, usage);
79
+ }
80
+
81
+ /** Re-arm the alert for a topic so a fresh session can warn again (called on `/new`). */
82
+ export function clearQueryUsageAlert(userId: string, topicId: string): void {
83
+ alerted.delete(alertKey(userId, topicId));
84
+ }
@@ -0,0 +1,109 @@
1
+ export const MERMAID_BROWSER_VERSION = "11.4.1";
2
+ export const MERMAID_BROWSER_ASSET_PATH = `/api/v1/assets/mermaid-${MERMAID_BROWSER_VERSION}.min.js`;
3
+ export const MERMAID_BROWSER_ASSET_RELATIVE_URL = `../../../../assets/mermaid-${MERMAID_BROWSER_VERSION}.min.js`;
4
+
5
+ export type MermaidTheme = "default" | "neutral" | "dark" | "forest";
6
+
7
+ function escapeHtml(value: string): string {
8
+ return value
9
+ .replace(/&/g, "&amp;")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;")
13
+ .replace(/'/g, "&#39;");
14
+ }
15
+
16
+ export function normalizeMermaidTheme(value: unknown): MermaidTheme {
17
+ return value === "default" || value === "dark" || value === "forest" ? value : "neutral";
18
+ }
19
+
20
+ /** Preserve the selected theme when a stored Mermaid document is rebuilt. */
21
+ export function mermaidThemeFromHtml(html: string): MermaidTheme {
22
+ const match = html.match(/\btheme\s*:\s*["'](default|neutral|dark|forest)["']/i);
23
+ return normalizeMermaidTheme(match?.[1]?.toLowerCase());
24
+ }
25
+
26
+ /**
27
+ * Build a self-contained Mermaid viewer. Diagrams retain a readable minimum
28
+ * scale and scroll instead of being squeezed into a narrow side panel; quiet
29
+ * zoom controls remain available for large graphs.
30
+ */
31
+ export function buildMermaidHtml(
32
+ code: string,
33
+ theme: MermaidTheme,
34
+ scriptUrl = MERMAID_BROWSER_ASSET_RELATIVE_URL,
35
+ ): string {
36
+ const safeCode = escapeHtml(code);
37
+ const safeTheme = JSON.stringify(theme);
38
+ const safeScriptUrl = escapeHtml(scriptUrl);
39
+ return `<!doctype html>
40
+ <html data-otium-mermaid-version="3">
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width,initial-scale=1">
44
+ <style>
45
+ :root{color-scheme:light;--ink:#161614;--ivory:#F6F2EA;--surface:#FFFCF6;--graphite:#2C2D2A;--celadon:#A8BDB2;--border:#DDD7CD}
46
+ *{box-sizing:border-box}
47
+ html,body{margin:0;width:100%;min-height:100%;background:var(--ivory);color:var(--ink);font-family:Geist,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
48
+ body{overflow:hidden}
49
+ .viewport{width:100vw;height:100vh;min-height:260px;overflow:auto;padding:58px 24px 24px;scrollbar-gutter:stable}
50
+ .mermaid{display:flex;width:max-content;min-width:100%;justify-content:center;align-items:flex-start}
51
+ .mermaid svg{display:block;max-width:none!important;height:auto!important;flex:none}
52
+ .controls{position:fixed;z-index:2;top:12px;right:12px;display:flex;align-items:center;gap:2px;padding:3px;background:color-mix(in srgb,var(--surface) 94%,transparent);border:1px solid var(--border);border-radius:6px}
53
+ .controls button{height:28px;min-width:30px;padding:0 8px;border:0;border-radius:4px;background:transparent;color:var(--graphite);font:600 12px/1 Geist,system-ui,sans-serif;cursor:pointer}
54
+ .controls button:hover{background:color-mix(in srgb,var(--celadon) 24%,transparent)}
55
+ .controls button:focus-visible{outline:2px solid var(--celadon);outline-offset:1px}
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
+ .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
+ @media(max-width:600px){.viewport{padding:54px 14px 18px}.controls{top:10px;right:10px}}
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <div class="controls" role="group" aria-label="Diagram zoom">
63
+ <button type="button" data-zoom-out aria-label="Zoom out">−</button>
64
+ <span class="zoom-value" aria-live="polite">100%</span>
65
+ <button type="button" data-zoom-in aria-label="Zoom in">+</button>
66
+ <button type="button" data-zoom-fit aria-label="Fit diagram">Fit</button>
67
+ </div>
68
+ <main class="viewport"><pre class="mermaid">${safeCode}</pre></main>
69
+ <script data-otium-mermaid-runtime src="${safeScriptUrl}"></script>
70
+ <script>
71
+ (async () => {
72
+ try {
73
+ const runtime = globalThis.mermaid;
74
+ if (!runtime) throw new Error("Mermaid renderer failed to load.");
75
+ runtime.initialize({ startOnLoad: false, securityLevel: "strict", theme: ${safeTheme} });
76
+ await runtime.run({ querySelector: ".mermaid" });
77
+ const viewport = document.querySelector(".viewport");
78
+ const svg = document.querySelector(".mermaid svg");
79
+ const value = document.querySelector(".zoom-value");
80
+ const bounds = svg && svg.viewBox && svg.viewBox.baseVal;
81
+ const naturalWidth = Math.max(1, Math.ceil((bounds && bounds.width) || (svg && svg.getBoundingClientRect().width) || 1));
82
+ let scale = Math.max(0.72, Math.min(1, (viewport.clientWidth - 48) / naturalWidth));
83
+ const applyScale = (next, center = true) => {
84
+ const previousWidth = naturalWidth * scale;
85
+ const centerRatio = previousWidth > viewport.clientWidth
86
+ ? (viewport.scrollLeft + viewport.clientWidth / 2) / previousWidth
87
+ : 0.5;
88
+ scale = Math.max(0.5, Math.min(2, next));
89
+ svg.style.width = Math.round(naturalWidth * scale) + "px";
90
+ svg.style.maxWidth = "none";
91
+ svg.style.height = "auto";
92
+ value.textContent = Math.round(scale * 100) + "%";
93
+ if (center) requestAnimationFrame(() => {
94
+ viewport.scrollLeft = Math.max(0, centerRatio * viewport.scrollWidth - viewport.clientWidth / 2);
95
+ });
96
+ };
97
+ document.querySelector("[data-zoom-out]").addEventListener("click", () => applyScale(scale - 0.1));
98
+ document.querySelector("[data-zoom-in]").addEventListener("click", () => applyScale(scale + 0.1));
99
+ document.querySelector("[data-zoom-fit]").addEventListener("click", () => applyScale(Math.min(1, (viewport.clientWidth - 48) / naturalWidth)));
100
+ applyScale(scale, false);
101
+ } catch (error) {
102
+ 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>';
104
+ }
105
+ })();
106
+ </script>
107
+ </body>
108
+ </html>`;
109
+ }
@@ -0,0 +1,430 @@
1
+ /** Visual (HTML snippet) storage for the Otium WebView renderer.
2
+ *
3
+ * Each topic stores up to 10 visuals (newest-first); older ones are pruned.
4
+ * Each user's active selection is tracked separately so multiple people in
5
+ * the same topic can be on different visuals independently.
6
+ *
7
+ * Port of the storage/prompt-context half of otium's `api/routes/visual.ts`;
8
+ * the REST routes (serve HTML/media, CSP headers, list/select endpoints)
9
+ * stayed in the host.
10
+ */
11
+ import { randomUUID } from "node:crypto";
12
+ import { db } from "#storage/forum-db";
13
+
14
+ const MAX_HISTORY = 10;
15
+ const MAX_VISUAL_TITLE_CHARS = 180;
16
+ export const VISUAL_MEDIA_URL_PLACEHOLDER = "__NEGOTIUM_VISUAL_MEDIA_URL__";
17
+
18
+ db.exec(`
19
+ CREATE TABLE IF NOT EXISTS api_topic_visuals (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ topic_id TEXT NOT NULL,
22
+ html TEXT NOT NULL,
23
+ title TEXT,
24
+ created_at INTEGER NOT NULL,
25
+ kind TEXT NOT NULL DEFAULT 'html',
26
+ source TEXT,
27
+ file_id TEXT,
28
+ mime_type TEXT,
29
+ media_token TEXT
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_api_topic_visuals_topic
32
+ ON api_topic_visuals(topic_id, created_at DESC);
33
+
34
+ CREATE TABLE IF NOT EXISTS api_topic_visual_views (
35
+ topic_id TEXT NOT NULL,
36
+ user_id TEXT NOT NULL,
37
+ visual_id INTEGER NOT NULL,
38
+ PRIMARY KEY (topic_id, user_id)
39
+ );
40
+ `);
41
+
42
+ for (const statement of [
43
+ "ALTER TABLE api_topic_visuals ADD COLUMN kind TEXT NOT NULL DEFAULT 'html'",
44
+ "ALTER TABLE api_topic_visuals ADD COLUMN source TEXT",
45
+ "ALTER TABLE api_topic_visuals ADD COLUMN file_id TEXT",
46
+ "ALTER TABLE api_topic_visuals ADD COLUMN mime_type TEXT",
47
+ "ALTER TABLE api_topic_visuals ADD COLUMN media_token TEXT",
48
+ ]) {
49
+ try {
50
+ db.exec(statement);
51
+ } catch {
52
+ // Column already exists.
53
+ }
54
+ }
55
+
56
+ // ── DB helpers ──────────────────────────────────────────────────────────────
57
+
58
+ export const VISUAL_KINDS = ["html", "mermaid", "image", "video"] as const;
59
+ export type VisualKind = (typeof VISUAL_KINDS)[number];
60
+
61
+ type VisualRow = {
62
+ id: number;
63
+ topic_id: string;
64
+ html: string;
65
+ title: string | null;
66
+ created_at: number;
67
+ kind: VisualKind;
68
+ source: string | null;
69
+ file_id: string | null;
70
+ mime_type: string | null;
71
+ media_token: string | null;
72
+ };
73
+ type VisualListRow = {
74
+ id: number;
75
+ title: string | null;
76
+ created_at: number;
77
+ kind: VisualKind;
78
+ mime_type: string | null;
79
+ };
80
+
81
+ export function normalizeVisualTitle(value: unknown): string | undefined {
82
+ if (typeof value !== "string") return undefined;
83
+ const normalized = value.replace(/\s+/g, " ").trim();
84
+ if (!normalized) return undefined;
85
+ return normalized.length > MAX_VISUAL_TITLE_CHARS
86
+ ? `${normalized.slice(0, MAX_VISUAL_TITLE_CHARS).trimEnd()}...`
87
+ : normalized;
88
+ }
89
+
90
+ function normalizeVisualKind(value: unknown): VisualKind {
91
+ return VISUAL_KINDS.includes(value as VisualKind) ? (value as VisualKind) : "html";
92
+ }
93
+
94
+ function insertVisual(input: {
95
+ topicId: string;
96
+ html: string;
97
+ title: string | null;
98
+ kind?: VisualKind;
99
+ source?: string | null;
100
+ fileId?: string | null;
101
+ mimeType?: string | null;
102
+ mediaToken?: string | null;
103
+ }): number {
104
+ const result = db
105
+ .query<
106
+ { id: number },
107
+ [
108
+ string,
109
+ string,
110
+ string | null,
111
+ number,
112
+ string,
113
+ string | null,
114
+ string | null,
115
+ string | null,
116
+ string | null,
117
+ ]
118
+ >(
119
+ `INSERT INTO api_topic_visuals
120
+ (topic_id, html, title, created_at, kind, source, file_id, mime_type, media_token)
121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
122
+ RETURNING id`,
123
+ )
124
+ .get(
125
+ input.topicId,
126
+ input.html,
127
+ input.title,
128
+ Date.now(),
129
+ input.kind ?? "html",
130
+ input.source ?? null,
131
+ input.fileId ?? null,
132
+ input.mimeType ?? null,
133
+ input.mediaToken ?? null,
134
+ );
135
+ if (!result) throw new Error("Failed to insert visual");
136
+ // Prune: keep only the newest MAX_HISTORY rows per topic.
137
+ db.query(
138
+ `DELETE FROM api_topic_visual_views
139
+ WHERE visual_id IN (
140
+ SELECT id FROM api_topic_visuals
141
+ WHERE topic_id = ? AND id NOT IN (
142
+ SELECT id FROM api_topic_visuals WHERE topic_id = ?
143
+ ORDER BY created_at DESC LIMIT ?
144
+ )
145
+ )`,
146
+ ).run(input.topicId, input.topicId, MAX_HISTORY);
147
+ db.query(
148
+ `DELETE FROM api_topic_visuals
149
+ WHERE topic_id = ? AND id NOT IN (
150
+ SELECT id FROM api_topic_visuals WHERE topic_id = ?
151
+ ORDER BY created_at DESC LIMIT ?
152
+ )`,
153
+ ).run(input.topicId, input.topicId, MAX_HISTORY);
154
+ return result.id;
155
+ }
156
+
157
+ function listVisuals(topicId: string): VisualListRow[] {
158
+ return db
159
+ .query<Omit<VisualListRow, "kind"> & { kind: string | null }, [string]>(
160
+ "SELECT id, title, created_at, kind, mime_type FROM api_topic_visuals WHERE topic_id = ? ORDER BY created_at DESC",
161
+ )
162
+ .all(topicId)
163
+ .map((row) => ({ ...row, kind: normalizeVisualKind(row.kind) }));
164
+ }
165
+
166
+ function getVisualById(id: number): VisualRow | null {
167
+ const row = db
168
+ .query<Omit<VisualRow, "kind"> & { kind: string | null }, [number]>(
169
+ "SELECT * FROM api_topic_visuals WHERE id = ?",
170
+ )
171
+ .get(id);
172
+ return row ? { ...row, kind: normalizeVisualKind(row.kind) } : null;
173
+ }
174
+
175
+ export function topicHasVisualFileId(topicId: string, fileId: string): boolean {
176
+ const row = db
177
+ .query<{ id: number }, [string, string]>(
178
+ "SELECT id FROM api_topic_visuals WHERE topic_id = ? AND file_id = ? LIMIT 1",
179
+ )
180
+ .get(topicId, fileId);
181
+ return Boolean(row);
182
+ }
183
+
184
+ function getUserActiveVisualId(topicId: string, userId: string): number | null {
185
+ const row = db
186
+ .query<{ visual_id: number }, [string, string]>(
187
+ "SELECT visual_id FROM api_topic_visual_views WHERE topic_id = ? AND user_id = ?",
188
+ )
189
+ .get(topicId, userId);
190
+ return row?.visual_id ?? null;
191
+ }
192
+
193
+ function setUserActiveVisualId(topicId: string, userId: string, visualId: number): void {
194
+ db.query(
195
+ `INSERT INTO api_topic_visual_views (topic_id, user_id, visual_id) VALUES (?, ?, ?)
196
+ ON CONFLICT(topic_id, user_id) DO UPDATE SET visual_id = excluded.visual_id`,
197
+ ).run(topicId, userId, visualId);
198
+ }
199
+
200
+ // ── Public helpers ──────────────────────────────────────────────────────────
201
+
202
+ // Dark-step token values shared by the two dark selectors below (OS-auto via
203
+ // media query, and the explicit data-viz-theme="dark" override). Single source
204
+ // so the manual switch can never drift from the auto dark palette.
205
+ const VIZ_DARK_TOKENS =
206
+ "--viz-surface:#1a1a1a;--viz-plane:#111111;--viz-text:#f2f2f0;--viz-text-secondary:#b6b8ae;--viz-muted:#8f938a;--viz-grid:#2a2a2a;--viz-axis:#3a3a38;--viz-border:rgba(255,255,255,.12);" +
207
+ "--viz-accent:#98ac83;--viz-accent-strong:#b3c49f;--viz-accent-soft:#2a3320;" +
208
+ "--viz-series-1:#98ac83;--viz-series-2:#9aa08f;--viz-series-3:#b0b4a8;--viz-series-4:#7f8f70;--viz-series-5:#d9dbcf;--viz-series-6:#c9d1bf;" +
209
+ "--viz-seq-100:#2f3a26;--viz-seq-200:#40513b;--viz-seq-300:#556845;--viz-seq-400:#6c7d57;--viz-seq-500:#8a9a74;--viz-seq-600:#a7b499;--viz-seq-700:#c9d1bf;" +
210
+ "--viz-good:#4caf50;--viz-warning:#d9a520;--viz-serious:#d98a5a;--viz-critical:#e06666;";
211
+
212
+ // Base design tokens injected into every stored HTML visual so charts share one
213
+ // clean, near-white look built around the olive accent #40513B, with a selected
214
+ // dark-mode step (values chosen for the dark surface, validated for contrast and
215
+ // CVD, not an automatic flip). Placed first in <head> so an agent's own styles
216
+ // override the reset, while the --viz-* custom properties stay available to
217
+ // reference. Values mirror prompts/sessions/visual-design.md; keep them in sync.
218
+ //
219
+ // Theme resolution: no data-viz-theme attribute → follow the OS
220
+ // (prefers-color-scheme); data-viz-theme="light"/"dark" → forced by the in-viz
221
+ // switch (see VISUAL_THEME_TOGGLE below).
222
+ const VISUAL_BASE_STYLE = `<style id="viz-base">
223
+ :root{color-scheme:light dark;
224
+ --viz-surface:#ffffff;--viz-plane:#f6f6f4;--viz-text:#141414;--viz-text-secondary:#565b50;--viz-muted:#8a8f84;--viz-grid:#ececea;--viz-axis:#d7d7d2;--viz-border:rgba(20,20,20,.10);
225
+ --viz-accent:#40513b;--viz-accent-strong:#2c3826;--viz-accent-soft:#eef1ea;
226
+ --viz-series-1:#40513b;--viz-series-2:#8c9187;--viz-series-3:#6b7a5f;--viz-series-4:#b9bcb4;--viz-series-5:#2c3826;--viz-series-6:#a0a89a;
227
+ --viz-seq-100:#eef1ea;--viz-seq-200:#d4dcca;--viz-seq-300:#9aab8c;--viz-seq-400:#77896a;--viz-seq-500:#566848;--viz-seq-600:#40513b;--viz-seq-700:#2c3826;
228
+ --viz-good:#2e7d32;--viz-warning:#b8860b;--viz-serious:#bf6a3a;--viz-critical:#b23b3b;}
229
+ @media (prefers-color-scheme:dark){:root:not([data-viz-theme=light]){${VIZ_DARK_TOKENS}}}
230
+ :root[data-viz-theme=dark]{${VIZ_DARK_TOKENS}}
231
+ :root[data-viz-theme=light]{color-scheme:light}
232
+ :root[data-viz-theme=dark]{color-scheme:dark}
233
+ *{box-sizing:border-box}
234
+ html{background:var(--viz-surface)}
235
+ html,body{margin:0;min-height:100%}
236
+ body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--viz-surface);color:var(--viz-text);-webkit-text-size-adjust:100%}
237
+ </style>`;
238
+
239
+ // Floating light/dark switch rendered inside every HTML visual. One inline
240
+ // <script> (CSP already allows 'unsafe-inline' for html visuals) that injects
241
+ // its own style, so upgrades and idempotency hinge on a single id. The button
242
+ // shows the mode a click switches TO (moon while light, sun while dark) and
243
+ // flips the data-viz-theme override consumed by VISUAL_BASE_STYLE above.
244
+ // No persistence: sandboxed srcDoc iframes have no reliable localStorage, and
245
+ // per-document lifetime matches how visuals are reopened from the store.
246
+ const VISUAL_THEME_TOGGLE = `<script id="viz-theme-toggle">
247
+ (function(){function init(){if(document.getElementById("viz-theme-toggle-btn"))return;
248
+ var s=document.createElement("style");
249
+ s.textContent="#viz-theme-toggle-btn{position:fixed;top:10px;right:10px;z-index:2147483647;width:30px;height:30px;border-radius:50%;border:1px solid var(--viz-border);background:var(--viz-plane);font:15px/1 system-ui;display:grid;place-items:center;cursor:pointer;opacity:.5;transition:opacity .15s;padding:0}#viz-theme-toggle-btn:hover{opacity:1}";
250
+ document.head.appendChild(s);
251
+ var b=document.createElement("button");b.id="viz-theme-toggle-btn";b.type="button";b.setAttribute("aria-label","Toggle color theme");
252
+ var mq=window.matchMedia?window.matchMedia("(prefers-color-scheme: dark)"):null;
253
+ function effective(){var f=document.documentElement.getAttribute("data-viz-theme");return f==="dark"||f==="light"?f:(mq&&mq.matches?"dark":"light")}
254
+ function paint(){var dark=effective()==="dark";b.textContent=dark?"\\u2600\\uFE0F":"\\uD83C\\uDF19";b.title=dark?"Light mode":"Dark mode"}
255
+ b.addEventListener("click",function(){document.documentElement.setAttribute("data-viz-theme",effective()==="dark"?"light":"dark");paint()});
256
+ if(mq&&mq.addEventListener)mq.addEventListener("change",paint);
257
+ paint();document.body.appendChild(b)}
258
+ if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",init);else init()})();
259
+ </script>`;
260
+
261
+ /**
262
+ * Bake the base design tokens + theme switch into an agent-provided HTML
263
+ * document so every visual shares one palette/reset regardless of render path
264
+ * (web srcDoc, desktop iframe, and native WebView all serve this stored HTML
265
+ * directly). Inserted at the top of <head> so agent styles win the cascade;
266
+ * the --viz-* variables are only added, never overriding what the agent set.
267
+ * Idempotent AND upgrading: a doc that already carries a baked block gets it
268
+ * swapped for the current version (so legacy stored visuals pick up the theme
269
+ * switch at serve time) instead of stacking a second copy.
270
+ */
271
+ export function withVisualBaseStyle(html: string): string {
272
+ const inject = `${VISUAL_BASE_STYLE}${VISUAL_THEME_TOGGLE}`;
273
+ if (html.includes('id="viz-base"')) {
274
+ let out = html.replace(/<style id="viz-base">[\s\S]*?<\/style>/, VISUAL_BASE_STYLE);
275
+ out = out.includes('id="viz-theme-toggle"')
276
+ ? out.replace(/<script id="viz-theme-toggle">[\s\S]*?<\/script>/, VISUAL_THEME_TOGGLE)
277
+ : out.replace(VISUAL_BASE_STYLE, inject);
278
+ return out;
279
+ }
280
+ if (/<head(?:\s[^>]*)?>/i.test(html)) {
281
+ return html.replace(/<head(?:\s[^>]*)?>/i, (match) => `${match}${inject}`);
282
+ }
283
+ if (/<html(?:\s[^>]*)?>/i.test(html)) {
284
+ return html.replace(/<html(?:\s[^>]*)?>/i, (match) => `${match}<head>${inject}</head>`);
285
+ }
286
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${inject}</head><body>${html}</body></html>`;
287
+ }
288
+
289
+ /** Persist a new visual and return its id. Called by the show_html tool path. */
290
+ export function storeTopicVisual(
291
+ topicId: string,
292
+ html: string,
293
+ title?: string,
294
+ activeUserId?: string,
295
+ ): number {
296
+ const visualId = insertVisual({
297
+ topicId,
298
+ // Rendered document carries the house style; source stays the agent's own
299
+ // HTML so edit-context feedback isn't padded with our boilerplate.
300
+ html: withVisualBaseStyle(html),
301
+ title: normalizeVisualTitle(title) ?? null,
302
+ kind: "html",
303
+ source: html,
304
+ });
305
+ if (activeUserId) setUserActiveVisualId(topicId, activeUserId, visualId);
306
+ return visualId;
307
+ }
308
+
309
+ export function storeTopicMermaidVisual(
310
+ topicId: string,
311
+ code: string,
312
+ html: string,
313
+ title?: string,
314
+ activeUserId?: string,
315
+ ): number {
316
+ const visualId = insertVisual({
317
+ topicId,
318
+ html,
319
+ title: normalizeVisualTitle(title) ?? null,
320
+ kind: "mermaid",
321
+ source: code,
322
+ });
323
+ if (activeUserId) setUserActiveVisualId(topicId, activeUserId, visualId);
324
+ return visualId;
325
+ }
326
+
327
+ export function storeTopicMediaVisual(input: {
328
+ topicId: string;
329
+ kind: "image" | "video";
330
+ html: string;
331
+ title?: string;
332
+ fileId: string;
333
+ mimeType: string;
334
+ source?: string;
335
+ activeUserId?: string;
336
+ }): number {
337
+ const visualId = insertVisual({
338
+ topicId: input.topicId,
339
+ html: input.html,
340
+ title: normalizeVisualTitle(input.title) ?? null,
341
+ kind: input.kind,
342
+ source: input.source ?? null,
343
+ fileId: input.fileId,
344
+ mimeType: input.mimeType,
345
+ mediaToken: randomUUID(),
346
+ });
347
+ if (input.activeUserId) setUserActiveVisualId(input.topicId, input.activeUserId, visualId);
348
+ return visualId;
349
+ }
350
+
351
+ /** Returns info for the system-prompt visual context section, or null if none. */
352
+ export function getActiveVisualForPrompt(
353
+ topicId: string,
354
+ userId: string,
355
+ ): {
356
+ kind: VisualKind;
357
+ content: string;
358
+ fence: "html" | "mermaid" | "text";
359
+ title: string | null;
360
+ index: number;
361
+ total: number;
362
+ } | null {
363
+ const rows = listVisuals(topicId);
364
+ if (rows.length === 0) return null;
365
+ const activeId = getUserActiveVisualId(topicId, userId) ?? rows[0]!.id;
366
+ const idx = rows.findIndex((r) => r.id === activeId);
367
+ const targetId = idx >= 0 ? activeId : rows[0]!.id;
368
+ const row = getVisualById(targetId);
369
+ if (!row) return null;
370
+ const total = rows.length;
371
+ const position = idx >= 0 ? idx + 1 : 1;
372
+ if (row.kind === "mermaid") {
373
+ return {
374
+ kind: row.kind,
375
+ content: row.source ?? row.html,
376
+ fence: "mermaid",
377
+ title: row.title,
378
+ index: position,
379
+ total,
380
+ };
381
+ }
382
+ if (row.kind === "image" || row.kind === "video") {
383
+ return {
384
+ kind: row.kind,
385
+ content: [
386
+ `${row.kind === "image" ? "Image" : "Video"} visual`,
387
+ row.title ? `title: ${row.title}` : null,
388
+ row.file_id ? `file_id: ${row.file_id}` : null,
389
+ row.mime_type ? `mime_type: ${row.mime_type}` : null,
390
+ "Binary media data is omitted from the prompt.",
391
+ ]
392
+ .filter(Boolean)
393
+ .join("\n"),
394
+ fence: "text",
395
+ title: row.title,
396
+ index: position,
397
+ total,
398
+ };
399
+ }
400
+ return {
401
+ kind: row.kind,
402
+ content: row.html,
403
+ fence: "html",
404
+ title: row.title,
405
+ index: position,
406
+ total,
407
+ };
408
+ }
409
+
410
+ /** Delete visual history and per-user selections for a hard-deleted topic. */
411
+ export function deleteTopicVisuals(topicId: string): { visuals: number; views: number } {
412
+ return db.transaction(() => {
413
+ const views = db
414
+ .query("DELETE FROM api_topic_visual_views WHERE topic_id = ?")
415
+ .run(topicId).changes;
416
+ const visuals = db
417
+ .query("DELETE FROM api_topic_visuals WHERE topic_id = ?")
418
+ .run(topicId).changes;
419
+ return { visuals, views };
420
+ })();
421
+ }
422
+
423
+ /** URL that serves the HTML content for a specific visual id (used by iframe). */
424
+ export function topicVisualUrl(topicId: string, vizId: number): string {
425
+ return `/api/v1/topics/${encodeURIComponent(topicId)}/visual/${vizId}/html`;
426
+ }
427
+
428
+ export function topicVisualMediaUrl(topicId: string, vizId: number, token: string): string {
429
+ return `/api/v1/topics/${encodeURIComponent(topicId)}/visual/${vizId}/media?token=${encodeURIComponent(token)}`;
430
+ }