@geminixiang/mikan 0.2.0

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 (316) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/LICENSE +22 -0
  3. package/README.md +297 -0
  4. package/dist/adapter.d.ts +134 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +2 -0
  7. package/dist/adapter.js.map +1 -0
  8. package/dist/adapters/discord/bot.d.ts +63 -0
  9. package/dist/adapters/discord/bot.d.ts.map +1 -0
  10. package/dist/adapters/discord/bot.js +577 -0
  11. package/dist/adapters/discord/bot.js.map +1 -0
  12. package/dist/adapters/discord/context.d.ts +9 -0
  13. package/dist/adapters/discord/context.d.ts.map +1 -0
  14. package/dist/adapters/discord/context.js +245 -0
  15. package/dist/adapters/discord/context.js.map +1 -0
  16. package/dist/adapters/discord/index.d.ts +3 -0
  17. package/dist/adapters/discord/index.d.ts.map +1 -0
  18. package/dist/adapters/discord/index.js +3 -0
  19. package/dist/adapters/discord/index.js.map +1 -0
  20. package/dist/adapters/shared.d.ts +91 -0
  21. package/dist/adapters/shared.d.ts.map +1 -0
  22. package/dist/adapters/shared.js +191 -0
  23. package/dist/adapters/shared.js.map +1 -0
  24. package/dist/adapters/slack/bot.d.ts +139 -0
  25. package/dist/adapters/slack/bot.d.ts.map +1 -0
  26. package/dist/adapters/slack/bot.js +1272 -0
  27. package/dist/adapters/slack/bot.js.map +1 -0
  28. package/dist/adapters/slack/branch-manager.d.ts +28 -0
  29. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  30. package/dist/adapters/slack/branch-manager.js +117 -0
  31. package/dist/adapters/slack/branch-manager.js.map +1 -0
  32. package/dist/adapters/slack/context.d.ts +12 -0
  33. package/dist/adapters/slack/context.d.ts.map +1 -0
  34. package/dist/adapters/slack/context.js +327 -0
  35. package/dist/adapters/slack/context.js.map +1 -0
  36. package/dist/adapters/slack/index.d.ts +3 -0
  37. package/dist/adapters/slack/index.d.ts.map +1 -0
  38. package/dist/adapters/slack/index.js +3 -0
  39. package/dist/adapters/slack/index.js.map +1 -0
  40. package/dist/adapters/slack/session.d.ts +38 -0
  41. package/dist/adapters/slack/session.d.ts.map +1 -0
  42. package/dist/adapters/slack/session.js +66 -0
  43. package/dist/adapters/slack/session.js.map +1 -0
  44. package/dist/adapters/slack/tools/attach.d.ts +12 -0
  45. package/dist/adapters/slack/tools/attach.d.ts.map +1 -0
  46. package/dist/adapters/slack/tools/attach.js +40 -0
  47. package/dist/adapters/slack/tools/attach.js.map +1 -0
  48. package/dist/adapters/telegram/bot.d.ts +51 -0
  49. package/dist/adapters/telegram/bot.d.ts.map +1 -0
  50. package/dist/adapters/telegram/bot.js +430 -0
  51. package/dist/adapters/telegram/bot.js.map +1 -0
  52. package/dist/adapters/telegram/context.d.ts +9 -0
  53. package/dist/adapters/telegram/context.d.ts.map +1 -0
  54. package/dist/adapters/telegram/context.js +190 -0
  55. package/dist/adapters/telegram/context.js.map +1 -0
  56. package/dist/adapters/telegram/html.d.ts +3 -0
  57. package/dist/adapters/telegram/html.d.ts.map +1 -0
  58. package/dist/adapters/telegram/html.js +98 -0
  59. package/dist/adapters/telegram/html.js.map +1 -0
  60. package/dist/adapters/telegram/index.d.ts +3 -0
  61. package/dist/adapters/telegram/index.d.ts.map +1 -0
  62. package/dist/adapters/telegram/index.js +3 -0
  63. package/dist/adapters/telegram/index.js.map +1 -0
  64. package/dist/agent.d.ts +36 -0
  65. package/dist/agent.d.ts.map +1 -0
  66. package/dist/agent.js +1147 -0
  67. package/dist/agent.js.map +1 -0
  68. package/dist/commands/auto-reply.d.ts +5 -0
  69. package/dist/commands/auto-reply.d.ts.map +1 -0
  70. package/dist/commands/auto-reply.js +79 -0
  71. package/dist/commands/auto-reply.js.map +1 -0
  72. package/dist/commands/index.d.ts +5 -0
  73. package/dist/commands/index.d.ts.map +1 -0
  74. package/dist/commands/index.js +18 -0
  75. package/dist/commands/index.js.map +1 -0
  76. package/dist/commands/login.d.ts +5 -0
  77. package/dist/commands/login.d.ts.map +1 -0
  78. package/dist/commands/login.js +91 -0
  79. package/dist/commands/login.js.map +1 -0
  80. package/dist/commands/model.d.ts +14 -0
  81. package/dist/commands/model.d.ts.map +1 -0
  82. package/dist/commands/model.js +110 -0
  83. package/dist/commands/model.js.map +1 -0
  84. package/dist/commands/new.d.ts +5 -0
  85. package/dist/commands/new.d.ts.map +1 -0
  86. package/dist/commands/new.js +24 -0
  87. package/dist/commands/new.js.map +1 -0
  88. package/dist/commands/parse.d.ts +7 -0
  89. package/dist/commands/parse.d.ts.map +1 -0
  90. package/dist/commands/parse.js +17 -0
  91. package/dist/commands/parse.js.map +1 -0
  92. package/dist/commands/registry.d.ts +4 -0
  93. package/dist/commands/registry.d.ts.map +1 -0
  94. package/dist/commands/registry.js +9 -0
  95. package/dist/commands/registry.js.map +1 -0
  96. package/dist/commands/sandbox.d.ts +10 -0
  97. package/dist/commands/sandbox.d.ts.map +1 -0
  98. package/dist/commands/sandbox.js +83 -0
  99. package/dist/commands/sandbox.js.map +1 -0
  100. package/dist/commands/session-view.d.ts +5 -0
  101. package/dist/commands/session-view.d.ts.map +1 -0
  102. package/dist/commands/session-view.js +62 -0
  103. package/dist/commands/session-view.js.map +1 -0
  104. package/dist/commands/types.d.ts +41 -0
  105. package/dist/commands/types.d.ts.map +1 -0
  106. package/dist/commands/types.js +2 -0
  107. package/dist/commands/types.js.map +1 -0
  108. package/dist/commands/utils.d.ts +8 -0
  109. package/dist/commands/utils.d.ts.map +1 -0
  110. package/dist/commands/utils.js +14 -0
  111. package/dist/commands/utils.js.map +1 -0
  112. package/dist/config.d.ts +59 -0
  113. package/dist/config.d.ts.map +1 -0
  114. package/dist/config.js +370 -0
  115. package/dist/config.js.map +1 -0
  116. package/dist/context.d.ts +17 -0
  117. package/dist/context.d.ts.map +1 -0
  118. package/dist/context.js +24 -0
  119. package/dist/context.js.map +1 -0
  120. package/dist/conversation-history.d.ts +16 -0
  121. package/dist/conversation-history.d.ts.map +1 -0
  122. package/dist/conversation-history.js +144 -0
  123. package/dist/conversation-history.js.map +1 -0
  124. package/dist/download.d.ts +2 -0
  125. package/dist/download.d.ts.map +1 -0
  126. package/dist/download.js +89 -0
  127. package/dist/download.js.map +1 -0
  128. package/dist/env.d.ts +3 -0
  129. package/dist/env.d.ts.map +1 -0
  130. package/dist/env.js +12 -0
  131. package/dist/env.js.map +1 -0
  132. package/dist/events.d.ts +85 -0
  133. package/dist/events.d.ts.map +1 -0
  134. package/dist/events.js +483 -0
  135. package/dist/events.js.map +1 -0
  136. package/dist/execution-resolver.d.ts +25 -0
  137. package/dist/execution-resolver.d.ts.map +1 -0
  138. package/dist/execution-resolver.js +167 -0
  139. package/dist/execution-resolver.js.map +1 -0
  140. package/dist/file-guards.d.ts +9 -0
  141. package/dist/file-guards.d.ts.map +1 -0
  142. package/dist/file-guards.js +56 -0
  143. package/dist/file-guards.js.map +1 -0
  144. package/dist/fs-atomic.d.ts +10 -0
  145. package/dist/fs-atomic.d.ts.map +1 -0
  146. package/dist/fs-atomic.js +45 -0
  147. package/dist/fs-atomic.js.map +1 -0
  148. package/dist/index.d.ts +10 -0
  149. package/dist/index.d.ts.map +1 -0
  150. package/dist/index.js +7 -0
  151. package/dist/index.js.map +1 -0
  152. package/dist/instrument.d.ts +2 -0
  153. package/dist/instrument.d.ts.map +1 -0
  154. package/dist/instrument.js +10 -0
  155. package/dist/instrument.js.map +1 -0
  156. package/dist/log.d.ts +36 -0
  157. package/dist/log.d.ts.map +1 -0
  158. package/dist/log.js +206 -0
  159. package/dist/log.js.map +1 -0
  160. package/dist/login/index.d.ts +42 -0
  161. package/dist/login/index.d.ts.map +1 -0
  162. package/dist/login/index.js +239 -0
  163. package/dist/login/index.js.map +1 -0
  164. package/dist/login/portal.d.ts +19 -0
  165. package/dist/login/portal.d.ts.map +1 -0
  166. package/dist/login/portal.js +1544 -0
  167. package/dist/login/portal.js.map +1 -0
  168. package/dist/login/session.d.ts +26 -0
  169. package/dist/login/session.d.ts.map +1 -0
  170. package/dist/login/session.js +56 -0
  171. package/dist/login/session.js.map +1 -0
  172. package/dist/main.d.ts +3 -0
  173. package/dist/main.d.ts.map +1 -0
  174. package/dist/main.js +366 -0
  175. package/dist/main.js.map +1 -0
  176. package/dist/provisioner.d.ts +83 -0
  177. package/dist/provisioner.d.ts.map +1 -0
  178. package/dist/provisioner.js +500 -0
  179. package/dist/provisioner.js.map +1 -0
  180. package/dist/runtime/conversation-orchestrator.d.ts +40 -0
  181. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  182. package/dist/runtime/conversation-orchestrator.js +183 -0
  183. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  184. package/dist/runtime/index.d.ts +2 -0
  185. package/dist/runtime/index.d.ts.map +1 -0
  186. package/dist/runtime/index.js +2 -0
  187. package/dist/runtime/index.js.map +1 -0
  188. package/dist/runtime/session-runtime.d.ts +26 -0
  189. package/dist/runtime/session-runtime.d.ts.map +1 -0
  190. package/dist/runtime/session-runtime.js +221 -0
  191. package/dist/runtime/session-runtime.js.map +1 -0
  192. package/dist/sandbox/cloudflare.d.ts +15 -0
  193. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  194. package/dist/sandbox/cloudflare.js +138 -0
  195. package/dist/sandbox/cloudflare.js.map +1 -0
  196. package/dist/sandbox/container.d.ts +16 -0
  197. package/dist/sandbox/container.d.ts.map +1 -0
  198. package/dist/sandbox/container.js +138 -0
  199. package/dist/sandbox/container.js.map +1 -0
  200. package/dist/sandbox/errors.d.ts +6 -0
  201. package/dist/sandbox/errors.d.ts.map +1 -0
  202. package/dist/sandbox/errors.js +11 -0
  203. package/dist/sandbox/errors.js.map +1 -0
  204. package/dist/sandbox/firecracker.d.ts +17 -0
  205. package/dist/sandbox/firecracker.d.ts.map +1 -0
  206. package/dist/sandbox/firecracker.js +212 -0
  207. package/dist/sandbox/firecracker.js.map +1 -0
  208. package/dist/sandbox/host.d.ts +11 -0
  209. package/dist/sandbox/host.d.ts.map +1 -0
  210. package/dist/sandbox/host.js +89 -0
  211. package/dist/sandbox/host.js.map +1 -0
  212. package/dist/sandbox/image.d.ts +5 -0
  213. package/dist/sandbox/image.d.ts.map +1 -0
  214. package/dist/sandbox/image.js +30 -0
  215. package/dist/sandbox/image.js.map +1 -0
  216. package/dist/sandbox/index.d.ts +22 -0
  217. package/dist/sandbox/index.d.ts.map +1 -0
  218. package/dist/sandbox/index.js +54 -0
  219. package/dist/sandbox/index.js.map +1 -0
  220. package/dist/sandbox/path-context.d.ts +4 -0
  221. package/dist/sandbox/path-context.d.ts.map +1 -0
  222. package/dist/sandbox/path-context.js +20 -0
  223. package/dist/sandbox/path-context.js.map +1 -0
  224. package/dist/sandbox/types.d.ts +67 -0
  225. package/dist/sandbox/types.d.ts.map +1 -0
  226. package/dist/sandbox/types.js +2 -0
  227. package/dist/sandbox/types.js.map +1 -0
  228. package/dist/sandbox/utils.d.ts +4 -0
  229. package/dist/sandbox/utils.d.ts.map +1 -0
  230. package/dist/sandbox/utils.js +51 -0
  231. package/dist/sandbox/utils.js.map +1 -0
  232. package/dist/sentry.d.ts +50 -0
  233. package/dist/sentry.d.ts.map +1 -0
  234. package/dist/sentry.js +257 -0
  235. package/dist/sentry.js.map +1 -0
  236. package/dist/session-view/command.d.ts +5 -0
  237. package/dist/session-view/command.d.ts.map +1 -0
  238. package/dist/session-view/command.js +7 -0
  239. package/dist/session-view/command.js.map +1 -0
  240. package/dist/session-view/portal.d.ts +16 -0
  241. package/dist/session-view/portal.d.ts.map +1 -0
  242. package/dist/session-view/portal.js +1822 -0
  243. package/dist/session-view/portal.js.map +1 -0
  244. package/dist/session-view/service.d.ts +34 -0
  245. package/dist/session-view/service.d.ts.map +1 -0
  246. package/dist/session-view/service.js +434 -0
  247. package/dist/session-view/service.js.map +1 -0
  248. package/dist/session-view/store.d.ts +18 -0
  249. package/dist/session-view/store.d.ts.map +1 -0
  250. package/dist/session-view/store.js +36 -0
  251. package/dist/session-view/store.js.map +1 -0
  252. package/dist/sessions/metadata.d.ts +15 -0
  253. package/dist/sessions/metadata.d.ts.map +1 -0
  254. package/dist/sessions/metadata.js +11 -0
  255. package/dist/sessions/metadata.js.map +1 -0
  256. package/dist/sessions/policy.d.ts +13 -0
  257. package/dist/sessions/policy.d.ts.map +1 -0
  258. package/dist/sessions/policy.js +23 -0
  259. package/dist/sessions/policy.js.map +1 -0
  260. package/dist/sessions/store.d.ts +103 -0
  261. package/dist/sessions/store.d.ts.map +1 -0
  262. package/dist/sessions/store.js +349 -0
  263. package/dist/sessions/store.js.map +1 -0
  264. package/dist/store.d.ts +58 -0
  265. package/dist/store.d.ts.map +1 -0
  266. package/dist/store.js +152 -0
  267. package/dist/store.js.map +1 -0
  268. package/dist/tool-diagnostics.d.ts +2 -0
  269. package/dist/tool-diagnostics.d.ts.map +1 -0
  270. package/dist/tool-diagnostics.js +7 -0
  271. package/dist/tool-diagnostics.js.map +1 -0
  272. package/dist/tools/bash.d.ts +10 -0
  273. package/dist/tools/bash.d.ts.map +1 -0
  274. package/dist/tools/bash.js +80 -0
  275. package/dist/tools/bash.js.map +1 -0
  276. package/dist/tools/edit.d.ts +11 -0
  277. package/dist/tools/edit.d.ts.map +1 -0
  278. package/dist/tools/edit.js +133 -0
  279. package/dist/tools/edit.js.map +1 -0
  280. package/dist/tools/event.d.ts +62 -0
  281. package/dist/tools/event.d.ts.map +1 -0
  282. package/dist/tools/event.js +138 -0
  283. package/dist/tools/event.js.map +1 -0
  284. package/dist/tools/index.d.ts +14 -0
  285. package/dist/tools/index.d.ts.map +1 -0
  286. package/dist/tools/index.js +23 -0
  287. package/dist/tools/index.js.map +1 -0
  288. package/dist/tools/read.d.ts +11 -0
  289. package/dist/tools/read.d.ts.map +1 -0
  290. package/dist/tools/read.js +136 -0
  291. package/dist/tools/read.js.map +1 -0
  292. package/dist/tools/truncate.d.ts +57 -0
  293. package/dist/tools/truncate.d.ts.map +1 -0
  294. package/dist/tools/truncate.js +184 -0
  295. package/dist/tools/truncate.js.map +1 -0
  296. package/dist/tools/write.d.ts +10 -0
  297. package/dist/tools/write.d.ts.map +1 -0
  298. package/dist/tools/write.js +33 -0
  299. package/dist/tools/write.js.map +1 -0
  300. package/dist/trigger.d.ts +31 -0
  301. package/dist/trigger.d.ts.map +1 -0
  302. package/dist/trigger.js +98 -0
  303. package/dist/trigger.js.map +1 -0
  304. package/dist/ui-copy.d.ts +12 -0
  305. package/dist/ui-copy.d.ts.map +1 -0
  306. package/dist/ui-copy.js +36 -0
  307. package/dist/ui-copy.js.map +1 -0
  308. package/dist/vault-routing.d.ts +4 -0
  309. package/dist/vault-routing.d.ts.map +1 -0
  310. package/dist/vault-routing.js +16 -0
  311. package/dist/vault-routing.js.map +1 -0
  312. package/dist/vault.d.ts +72 -0
  313. package/dist/vault.d.ts.map +1 -0
  314. package/dist/vault.js +281 -0
  315. package/dist/vault.js.map +1 -0
  316. package/package.json +83 -0
package/dist/sentry.js ADDED
@@ -0,0 +1,257 @@
1
+ import * as Sentry from "@sentry/node";
2
+ const REDACTED = "[REDACTED]";
3
+ const REDACTED_PATH = "[REDACTED_PATH]";
4
+ const MAX_STRING_LENGTH = 256;
5
+ const MAX_DEPTH = 4;
6
+ const SENSITIVE_KEYS = new Set([
7
+ "accesstoken",
8
+ "apikey",
9
+ "args",
10
+ "attachment",
11
+ "attachments",
12
+ "authorization",
13
+ "body",
14
+ "code",
15
+ "content",
16
+ "contents",
17
+ "cookie",
18
+ "cookies",
19
+ "credential",
20
+ "filepath",
21
+ "headers",
22
+ "image",
23
+ "imageattachments",
24
+ "images",
25
+ "localpath",
26
+ "messages",
27
+ "newusermessage",
28
+ "password",
29
+ "path",
30
+ "paths",
31
+ "prompt",
32
+ "refreshtoken",
33
+ "response",
34
+ "result",
35
+ "secret",
36
+ "systemprompt",
37
+ "text",
38
+ "thinking",
39
+ "token",
40
+ "url",
41
+ "uri",
42
+ "workspacepath",
43
+ ]);
44
+ const ABSOLUTE_PATH_PATTERN = /(?:\/Users\/[^\s"'`]+|\/workspace\/[^\s"'`]+|\/tmp\/[^\s"'`]+|\/var\/folders\/[^\s"'`]+|[A-Za-z]:\\[^\s"'`]+)/;
45
+ const TOKEN_PATTERNS = [
46
+ /\bsk-[A-Za-z0-9_-]{12,}\b/,
47
+ /\bxox[a-z]-[A-Za-z0-9-]{10,}\b/,
48
+ /\bAIza[0-9A-Za-z_-]{20,}\b/,
49
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/,
50
+ ];
51
+ export function createSentryInitOptions(dsn) {
52
+ return {
53
+ dsn,
54
+ environment: process.env.SENTRY_ENVIRONMENT ?? "production",
55
+ enabled: Boolean(dsn) && process.env.SENTRY_ENABLED !== "false",
56
+ sendDefaultPii: false,
57
+ tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 1.0,
58
+ includeLocalVariables: false,
59
+ enableLogs: true,
60
+ beforeSend(event, hint) {
61
+ return sanitizeEvent(event, hint);
62
+ },
63
+ beforeBreadcrumb(breadcrumb) {
64
+ return sanitizeBreadcrumb(breadcrumb);
65
+ },
66
+ };
67
+ }
68
+ export function reportUserFacingError(error, options) {
69
+ if (options.expected)
70
+ return undefined;
71
+ const exception = error instanceof Error ? error : new Error(String(error));
72
+ return Sentry.withScope((scope) => {
73
+ scope.setLevel(options.severity ?? "error");
74
+ scope.setTag("user_facing", "true");
75
+ scope.setTag("expected", "false");
76
+ scope.setTag("error_domain", options.domain);
77
+ scope.setTag("error_surface", options.surface);
78
+ scope.setTag("operation", options.operation);
79
+ setOptionalTag(scope, "platform", options.platform);
80
+ setOptionalTag(scope, "provider", options.provider);
81
+ setOptionalTag(scope, "model", options.model);
82
+ setOptionalTag(scope, "tool", options.toolName);
83
+ setOptionalTag(scope, "stop_reason", options.stopReason);
84
+ for (const [key, value] of Object.entries(options.tags ?? {})) {
85
+ if (value !== undefined)
86
+ scope.setTag(key, String(value));
87
+ }
88
+ if (options.fingerprint)
89
+ scope.setFingerprint(options.fingerprint);
90
+ scope.setContext("user_facing_error", {
91
+ domain: options.domain,
92
+ surface: options.surface,
93
+ operation: options.operation,
94
+ severity: options.severity ?? "error",
95
+ platform: options.platform,
96
+ provider: options.provider,
97
+ model: options.model,
98
+ toolName: options.toolName,
99
+ stopReason: options.stopReason,
100
+ ...sanitizeValue(options.context ?? {}),
101
+ });
102
+ return Sentry.captureException(exception);
103
+ });
104
+ }
105
+ function setOptionalTag(scope, key, value) {
106
+ if (value !== undefined)
107
+ scope.setTag(key, value);
108
+ }
109
+ export function applyRunScope(scope, context) {
110
+ scope.setTag("conversation_id", context.conversationId);
111
+ scope.setTag("channel_id", context.conversationId);
112
+ scope.setTag("session_key", context.sessionKey);
113
+ scope.setTag("platform", context.platform);
114
+ if (context.threadTs)
115
+ scope.setTag("thread_ts", context.threadTs);
116
+ if (context.provider)
117
+ scope.setTag("provider", context.provider);
118
+ if (context.model)
119
+ scope.setTag("model", context.model);
120
+ scope.setUser({
121
+ id: context.userId,
122
+ username: context.userName,
123
+ });
124
+ scope.setContext("agent_run", {
125
+ conversationId: context.conversationId,
126
+ channelId: context.conversationId,
127
+ sessionKey: context.sessionKey,
128
+ messageId: context.messageId,
129
+ threadTs: context.threadTs,
130
+ platform: context.platform,
131
+ provider: context.provider,
132
+ model: context.model,
133
+ });
134
+ }
135
+ export function metricAttributes(attributes) {
136
+ return Object.fromEntries(Object.entries(attributes).filter((entry) => {
137
+ const [, value] = entry;
138
+ return value !== undefined;
139
+ }));
140
+ }
141
+ export function addLifecycleBreadcrumb(message, data) {
142
+ Sentry.addBreadcrumb({
143
+ category: "agent.lifecycle",
144
+ message,
145
+ level: "info",
146
+ data: data ? metricAttributes(data) : undefined,
147
+ });
148
+ }
149
+ export function sanitizeEvent(event, _hint) {
150
+ const sanitized = {
151
+ ...event,
152
+ breadcrumbs: event.breadcrumbs
153
+ ?.map((breadcrumb) => sanitizeBreadcrumb(breadcrumb))
154
+ .filter((breadcrumb) => breadcrumb !== null),
155
+ extra: sanitizeValue(event.extra),
156
+ contexts: sanitizeValue(event.contexts),
157
+ request: sanitizeRequest(event.request),
158
+ user: undefined,
159
+ server_name: undefined,
160
+ };
161
+ if (sanitized.message) {
162
+ sanitized.message = sanitizeString(sanitized.message);
163
+ }
164
+ if (sanitized.logentry) {
165
+ sanitized.logentry = {
166
+ ...sanitized.logentry,
167
+ message: sanitized.logentry.message ? sanitizeString(sanitized.logentry.message) : undefined,
168
+ };
169
+ }
170
+ if (sanitized.exception?.values) {
171
+ sanitized.exception.values = sanitized.exception.values.map((value) => ({
172
+ ...value,
173
+ value: value.value ? sanitizeString(value.value) : value.value,
174
+ stacktrace: value.stacktrace
175
+ ? {
176
+ ...value.stacktrace,
177
+ frames: value.stacktrace.frames?.map((frame) => ({
178
+ ...frame,
179
+ filename: frame.filename ? sanitizeString(frame.filename) : frame.filename,
180
+ abs_path: frame.abs_path ? sanitizeString(frame.abs_path) : frame.abs_path,
181
+ vars: undefined,
182
+ })),
183
+ }
184
+ : value.stacktrace,
185
+ }));
186
+ }
187
+ return sanitized;
188
+ }
189
+ export function sanitizeBreadcrumb(breadcrumb) {
190
+ if (breadcrumb.category === "console") {
191
+ return null;
192
+ }
193
+ return {
194
+ ...breadcrumb,
195
+ message: breadcrumb.message ? sanitizeString(breadcrumb.message) : breadcrumb.message,
196
+ data: sanitizeValue(breadcrumb.data),
197
+ };
198
+ }
199
+ export function sanitizeValue(value, key, depth = 0) {
200
+ if (value == null)
201
+ return value;
202
+ if (depth > MAX_DEPTH)
203
+ return "[Truncated]";
204
+ if (isSensitiveKey(key)) {
205
+ return summarizeValue(value, key);
206
+ }
207
+ if (typeof value === "string") {
208
+ return sanitizeString(value);
209
+ }
210
+ if (Array.isArray(value)) {
211
+ return value.slice(0, 20).map((entry) => sanitizeValue(entry, key, depth + 1));
212
+ }
213
+ if (typeof value === "object") {
214
+ const entries = Object.entries(value).map(([entryKey, entryValue]) => [entryKey, sanitizeValue(entryValue, entryKey, depth + 1)]);
215
+ return Object.fromEntries(entries);
216
+ }
217
+ return value;
218
+ }
219
+ function sanitizeRequest(request) {
220
+ if (!request)
221
+ return request;
222
+ return {
223
+ ...request,
224
+ data: request.data ? summarizeValue(request.data, "body") : undefined,
225
+ headers: undefined,
226
+ cookies: undefined,
227
+ };
228
+ }
229
+ function isSensitiveKey(key) {
230
+ if (!key)
231
+ return false;
232
+ return SENSITIVE_KEYS.has(key.toLowerCase());
233
+ }
234
+ function summarizeValue(value, key) {
235
+ const label = key ?? "field";
236
+ if (typeof value === "string") {
237
+ return `[Redacted ${label}; length=${value.length}]`;
238
+ }
239
+ if (Array.isArray(value)) {
240
+ return `[Redacted ${label}; items=${value.length}]`;
241
+ }
242
+ if (value && typeof value === "object") {
243
+ return `[Redacted ${label}; keys=${Object.keys(value).length}]`;
244
+ }
245
+ return `[Redacted ${label}]`;
246
+ }
247
+ function sanitizeString(value) {
248
+ let sanitized = value.replace(new RegExp(ABSOLUTE_PATH_PATTERN, "g"), REDACTED_PATH);
249
+ for (const pattern of TOKEN_PATTERNS) {
250
+ sanitized = sanitized.replace(new RegExp(pattern, "g"), REDACTED);
251
+ }
252
+ if (sanitized.length > MAX_STRING_LENGTH) {
253
+ return `${sanitized.slice(0, MAX_STRING_LENGTH)}… [truncated ${sanitized.length - MAX_STRING_LENGTH} chars]`;
254
+ }
255
+ return sanitized;
256
+ }
257
+ //# sourceMappingURL=sentry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sentry.js","sourceRoot":"","sources":["../src/sentry.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Breadcrumb, ErrorEvent, Event, EventHint, Scope } from \"@sentry/node\";\nimport * as Sentry from \"@sentry/node\";\n\nconst REDACTED = \"[REDACTED]\";\nconst REDACTED_PATH = \"[REDACTED_PATH]\";\nconst MAX_STRING_LENGTH = 256;\nconst MAX_DEPTH = 4;\n\ntype SentryPrimitive = string | number | boolean;\n\nconst SENSITIVE_KEYS = new Set([\n \"accesstoken\",\n \"apikey\",\n \"args\",\n \"attachment\",\n \"attachments\",\n \"authorization\",\n \"body\",\n \"code\",\n \"content\",\n \"contents\",\n \"cookie\",\n \"cookies\",\n \"credential\",\n \"filepath\",\n \"headers\",\n \"image\",\n \"imageattachments\",\n \"images\",\n \"localpath\",\n \"messages\",\n \"newusermessage\",\n \"password\",\n \"path\",\n \"paths\",\n \"prompt\",\n \"refreshtoken\",\n \"response\",\n \"result\",\n \"secret\",\n \"systemprompt\",\n \"text\",\n \"thinking\",\n \"token\",\n \"url\",\n \"uri\",\n \"workspacepath\",\n]);\n\nconst ABSOLUTE_PATH_PATTERN =\n /(?:\\/Users\\/[^\\s\"'`]+|\\/workspace\\/[^\\s\"'`]+|\\/tmp\\/[^\\s\"'`]+|\\/var\\/folders\\/[^\\s\"'`]+|[A-Za-z]:\\\\[^\\s\"'`]+)/;\nconst TOKEN_PATTERNS = [\n /\\bsk-[A-Za-z0-9_-]{12,}\\b/,\n /\\bxox[a-z]-[A-Za-z0-9-]{10,}\\b/,\n /\\bAIza[0-9A-Za-z_-]{20,}\\b/,\n /\\bgh[pousr]_[A-Za-z0-9]{20,}\\b/,\n];\n\nexport interface SentryRunScopeContext {\n conversationId: string;\n sessionKey: string;\n messageId: string;\n platform: string;\n userId: string;\n userName?: string;\n threadTs?: string;\n provider?: string;\n model?: string;\n}\n\nexport type UserFacingErrorDomain =\n | \"llm\"\n | \"chat_platform\"\n | \"mikan\"\n | \"sandbox\"\n | \"login\"\n | \"events\"\n | \"session_view\";\n\nexport type UserFacingErrorSeverity = \"warning\" | \"error\" | \"fatal\";\n\nexport interface ReportUserFacingErrorOptions {\n domain: UserFacingErrorDomain;\n surface: string;\n operation: string;\n severity?: UserFacingErrorSeverity;\n platform?: string;\n provider?: string;\n model?: string;\n toolName?: string;\n stopReason?: string;\n expected?: boolean;\n fingerprint?: string[];\n tags?: Record<string, SentryPrimitive | undefined>;\n context?: Record<string, unknown>;\n}\n\nexport function createSentryInitOptions(dsn?: string) {\n return {\n dsn,\n environment: process.env.SENTRY_ENVIRONMENT ?? \"production\",\n enabled: Boolean(dsn) && process.env.SENTRY_ENABLED !== \"false\",\n sendDefaultPii: false,\n tracesSampleRate: process.env.NODE_ENV === \"development\" ? 1.0 : 1.0,\n includeLocalVariables: false,\n enableLogs: true,\n beforeSend(event: ErrorEvent, hint: EventHint): ErrorEvent | null {\n return sanitizeEvent(event, hint);\n },\n beforeBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb | null {\n return sanitizeBreadcrumb(breadcrumb);\n },\n };\n}\n\nexport function reportUserFacingError(\n error: unknown,\n options: ReportUserFacingErrorOptions,\n): string | undefined {\n if (options.expected) return undefined;\n\n const exception = error instanceof Error ? error : new Error(String(error));\n return Sentry.withScope((scope) => {\n scope.setLevel(options.severity ?? \"error\");\n scope.setTag(\"user_facing\", \"true\");\n scope.setTag(\"expected\", \"false\");\n scope.setTag(\"error_domain\", options.domain);\n scope.setTag(\"error_surface\", options.surface);\n scope.setTag(\"operation\", options.operation);\n setOptionalTag(scope, \"platform\", options.platform);\n setOptionalTag(scope, \"provider\", options.provider);\n setOptionalTag(scope, \"model\", options.model);\n setOptionalTag(scope, \"tool\", options.toolName);\n setOptionalTag(scope, \"stop_reason\", options.stopReason);\n for (const [key, value] of Object.entries(options.tags ?? {})) {\n if (value !== undefined) scope.setTag(key, String(value));\n }\n if (options.fingerprint) scope.setFingerprint(options.fingerprint);\n scope.setContext(\"user_facing_error\", {\n domain: options.domain,\n surface: options.surface,\n operation: options.operation,\n severity: options.severity ?? \"error\",\n platform: options.platform,\n provider: options.provider,\n model: options.model,\n toolName: options.toolName,\n stopReason: options.stopReason,\n ...(sanitizeValue(options.context ?? {}) as Record<string, unknown>),\n });\n return Sentry.captureException(exception);\n });\n}\n\nfunction setOptionalTag(scope: Scope, key: string, value: string | undefined): void {\n if (value !== undefined) scope.setTag(key, value);\n}\n\nexport function applyRunScope(scope: Scope, context: SentryRunScopeContext): void {\n scope.setTag(\"conversation_id\", context.conversationId);\n scope.setTag(\"channel_id\", context.conversationId);\n scope.setTag(\"session_key\", context.sessionKey);\n scope.setTag(\"platform\", context.platform);\n if (context.threadTs) scope.setTag(\"thread_ts\", context.threadTs);\n if (context.provider) scope.setTag(\"provider\", context.provider);\n if (context.model) scope.setTag(\"model\", context.model);\n\n scope.setUser({\n id: context.userId,\n username: context.userName,\n });\n scope.setContext(\"agent_run\", {\n conversationId: context.conversationId,\n channelId: context.conversationId,\n sessionKey: context.sessionKey,\n messageId: context.messageId,\n threadTs: context.threadTs,\n platform: context.platform,\n provider: context.provider,\n model: context.model,\n });\n}\n\nexport function metricAttributes(\n attributes: Record<string, string | number | boolean | undefined>,\n): Record<string, string | number | boolean> {\n return Object.fromEntries(\n Object.entries(attributes).filter((entry): entry is [string, string | number | boolean] => {\n const [, value] = entry;\n return value !== undefined;\n }),\n );\n}\n\nexport function addLifecycleBreadcrumb(\n message: string,\n data?: Record<string, string | number | boolean | undefined>,\n): void {\n Sentry.addBreadcrumb({\n category: \"agent.lifecycle\",\n message,\n level: \"info\",\n data: data ? metricAttributes(data) : undefined,\n });\n}\n\nexport function sanitizeEvent<T extends Event>(event: T, _hint?: EventHint): T | null {\n const sanitized: T = {\n ...event,\n breadcrumbs: event.breadcrumbs\n ?.map((breadcrumb) => sanitizeBreadcrumb(breadcrumb))\n .filter((breadcrumb): breadcrumb is Breadcrumb => breadcrumb !== null),\n extra: sanitizeValue(event.extra) as T[\"extra\"],\n contexts: sanitizeValue(event.contexts) as T[\"contexts\"],\n request: sanitizeRequest(event.request),\n user: undefined,\n server_name: undefined,\n };\n\n if (sanitized.message) {\n sanitized.message = sanitizeString(sanitized.message);\n }\n\n if (sanitized.logentry) {\n sanitized.logentry = {\n ...sanitized.logentry,\n message: sanitized.logentry.message ? sanitizeString(sanitized.logentry.message) : undefined,\n };\n }\n\n if (sanitized.exception?.values) {\n sanitized.exception.values = sanitized.exception.values.map((value) => ({\n ...value,\n value: value.value ? sanitizeString(value.value) : value.value,\n stacktrace: value.stacktrace\n ? {\n ...value.stacktrace,\n frames: value.stacktrace.frames?.map((frame) => ({\n ...frame,\n filename: frame.filename ? sanitizeString(frame.filename) : frame.filename,\n abs_path: frame.abs_path ? sanitizeString(frame.abs_path) : frame.abs_path,\n vars: undefined,\n })),\n }\n : value.stacktrace,\n }));\n }\n\n return sanitized;\n}\n\nexport function sanitizeBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb | null {\n if (breadcrumb.category === \"console\") {\n return null;\n }\n\n return {\n ...breadcrumb,\n message: breadcrumb.message ? sanitizeString(breadcrumb.message) : breadcrumb.message,\n data: sanitizeValue(breadcrumb.data) as Breadcrumb[\"data\"],\n };\n}\n\nexport function sanitizeValue(value: unknown, key?: string, depth = 0): unknown {\n if (value == null) return value;\n if (depth > MAX_DEPTH) return \"[Truncated]\";\n\n if (isSensitiveKey(key)) {\n return summarizeValue(value, key);\n }\n\n if (typeof value === \"string\") {\n return sanitizeString(value);\n }\n\n if (Array.isArray(value)) {\n return value.slice(0, 20).map((entry) => sanitizeValue(entry, key, depth + 1));\n }\n\n if (typeof value === \"object\") {\n const entries = Object.entries(value as Record<string, unknown>).map(\n ([entryKey, entryValue]) => [entryKey, sanitizeValue(entryValue, entryKey, depth + 1)],\n );\n return Object.fromEntries(entries);\n }\n\n return value;\n}\n\nfunction sanitizeRequest(request: Event[\"request\"]): Event[\"request\"] {\n if (!request) return request;\n\n return {\n ...request,\n data: request.data ? summarizeValue(request.data, \"body\") : undefined,\n headers: undefined,\n cookies: undefined,\n };\n}\n\nfunction isSensitiveKey(key?: string): boolean {\n if (!key) return false;\n return SENSITIVE_KEYS.has(key.toLowerCase());\n}\n\nfunction summarizeValue(value: unknown, key?: string): string {\n const label = key ?? \"field\";\n if (typeof value === \"string\") {\n return `[Redacted ${label}; length=${value.length}]`;\n }\n if (Array.isArray(value)) {\n return `[Redacted ${label}; items=${value.length}]`;\n }\n if (value && typeof value === \"object\") {\n return `[Redacted ${label}; keys=${Object.keys(value as Record<string, unknown>).length}]`;\n }\n return `[Redacted ${label}]`;\n}\n\nfunction sanitizeString(value: string): string {\n let sanitized = value.replace(new RegExp(ABSOLUTE_PATH_PATTERN, \"g\"), REDACTED_PATH);\n for (const pattern of TOKEN_PATTERNS) {\n sanitized = sanitized.replace(new RegExp(pattern, \"g\"), REDACTED);\n }\n if (sanitized.length > MAX_STRING_LENGTH) {\n return `${sanitized.slice(0, MAX_STRING_LENGTH)}… [truncated ${sanitized.length - MAX_STRING_LENGTH} chars]`;\n }\n return sanitized;\n}\n"]}
@@ -0,0 +1,5 @@
1
+ export interface ParsedSessionViewCommand {
2
+ command: "session" | "/session" | "/pi-session";
3
+ }
4
+ export declare function parseSessionViewCommand(text: string): ParsedSessionViewCommand | null;
5
+ //# sourceMappingURL=command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/session-view/command.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,SAAS,GAAG,UAAU,GAAG,aAAa,CAAC;CACjD;AAID,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,wBAAwB,GAAG,IAAI,CAGrF","sourcesContent":["import { matchCommand } from \"../commands/parse.js\";\n\nexport interface ParsedSessionViewCommand {\n command: \"session\" | \"/session\" | \"/pi-session\";\n}\n\nconst SESSION_VIEW_COMMANDS = [\"session\", \"/session\", \"/pi-session\"] as const;\n\nexport function parseSessionViewCommand(text: string): ParsedSessionViewCommand | null {\n const matched = matchCommand(text, SESSION_VIEW_COMMANDS);\n return matched ? { command: matched.command } : null;\n}\n"]}
@@ -0,0 +1,7 @@
1
+ import { matchCommand } from "../commands/parse.js";
2
+ const SESSION_VIEW_COMMANDS = ["session", "/session", "/pi-session"];
3
+ export function parseSessionViewCommand(text) {
4
+ const matched = matchCommand(text, SESSION_VIEW_COMMANDS);
5
+ return matched ? { command: matched.command } : null;
6
+ }
7
+ //# sourceMappingURL=command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.js","sourceRoot":"","sources":["../../src/session-view/command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAMpD,MAAM,qBAAqB,GAAG,CAAC,SAAS,EAAE,UAAU,EAAE,aAAa,CAAU,CAAC;AAE9E,MAAM,UAAU,uBAAuB,CAAC,IAAY;IAClD,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,EAAE,qBAAqB,CAAC,CAAC;IAC1D,OAAO,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACvD,CAAC","sourcesContent":["import { matchCommand } from \"../commands/parse.js\";\n\nexport interface ParsedSessionViewCommand {\n command: \"session\" | \"/session\" | \"/pi-session\";\n}\n\nconst SESSION_VIEW_COMMANDS = [\"session\", \"/session\", \"/pi-session\"] as const;\n\nexport function parseSessionViewCommand(text: string): ParsedSessionViewCommand | null {\n const matched = matchCommand(text, SESSION_VIEW_COMMANDS);\n return matched ? { command: matched.command } : null;\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { Bot, BotHandler } from "../adapter.js";
3
+ import type { InMemorySessionViewTokenStore } from "./store.js";
4
+ export interface SessionViewInteractiveOptions {
5
+ handler: BotHandler;
6
+ botsByPlatform: Partial<Record<string, Bot>>;
7
+ }
8
+ export declare function handleSessionViewRequest(req: IncomingMessage, res: ServerResponse, url: URL, sessionViewTokenStore?: InMemorySessionViewTokenStore, interactive?: SessionViewInteractiveOptions): Promise<boolean>;
9
+ export declare function parseUserBody(raw: string): {
10
+ timestamp: string | null;
11
+ username: string | null;
12
+ threadTs: string | null;
13
+ header: string | null;
14
+ content: string;
15
+ };
16
+ //# sourceMappingURL=portal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../src/session-view/portal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAG5D,OAAO,KAAK,EAAE,GAAG,EAAyB,UAAU,EAAuB,MAAM,eAAe,CAAC;AAUjG,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,YAAY,CAAC;AA4DhE,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;CAC9C;AAED,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,GAAG,EACR,qBAAqB,CAAC,EAAE,6BAA6B,EACrD,WAAW,CAAC,EAAE,6BAA6B,GAC1C,OAAO,CAAC,OAAO,CAAC,CA2GlB;AA0ID,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG;IAC1C,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB,CA8BA","sourcesContent":["import type { IncomingMessage, ServerResponse } from \"http\";\nimport { basename } from \"path\";\nimport MarkdownIt from \"markdown-it\";\nimport type { Bot, BotAdapters, BotEvent, BotHandler, ChatResponseContext } from \"../adapter.js\";\nimport * as log from \"../log.js\";\nimport { reportUserFacingError } from \"../sentry.js\";\nimport { inferConversationKind } from \"../sessions/policy.js\";\nimport {\n loadSessionViewModel,\n resolveRequestedSessionFile,\n type SessionViewItem,\n type SessionViewRelation,\n} from \"./service.js\";\nimport type { InMemorySessionViewTokenStore } from \"./store.js\";\n\nconst markdown = new MarkdownIt({\n html: false,\n linkify: true,\n breaks: true,\n});\n\nconst defaultLinkOpen = markdown.renderer.rules.link_open;\ntype LinkOpenRule = NonNullable<typeof defaultLinkOpen>;\nmarkdown.renderer.rules.link_open = (...args: Parameters<LinkOpenRule>) => {\n const [tokens, idx, options, env, self] = args;\n const token = tokens[idx];\n token.attrSet(\"target\", \"_blank\");\n token.attrSet(\"rel\", \"noreferrer noopener\");\n return defaultLinkOpen\n ? defaultLinkOpen(tokens, idx, options, env, self)\n : self.renderToken(tokens, idx, options);\n};\n\ntype SessionStreamEvent =\n | { type: \"status\"; running: boolean }\n | { type: \"user\"; html: string }\n | { type: \"assistant\"; html: string }\n | { type: \"assistant_remove\" }\n | { type: \"tool\"; html: string }\n | { type: \"system\"; html: string }\n | {\n type: \"refresh\";\n timelineHtml: string;\n updatedAt: string;\n entryCount: number;\n running: boolean;\n }\n | { type: \"error\"; message: string };\n\nclass SessionViewStreamHub {\n private listeners = new Map<string, Set<(event: SessionStreamEvent) => void>>();\n\n subscribe(key: string, listener: (event: SessionStreamEvent) => void): () => void {\n const set = this.listeners.get(key) ?? new Set<(event: SessionStreamEvent) => void>();\n set.add(listener);\n this.listeners.set(key, set);\n return () => {\n const current = this.listeners.get(key);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) this.listeners.delete(key);\n };\n }\n\n publish(key: string, event: SessionStreamEvent): void {\n const set = this.listeners.get(key);\n if (!set) return;\n for (const listener of set) listener(event);\n }\n}\n\nconst sessionViewStreamHub = new SessionViewStreamHub();\n\nexport interface SessionViewInteractiveOptions {\n handler: BotHandler;\n botsByPlatform: Partial<Record<string, Bot>>;\n}\n\nexport async function handleSessionViewRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n sessionViewTokenStore?: InMemorySessionViewTokenStore,\n interactive?: SessionViewInteractiveOptions,\n): Promise<boolean> {\n if (req.method === \"POST\" && url.pathname === \"/session/message\") {\n await handleSessionMessageRequest(req, res, sessionViewTokenStore, interactive);\n return true;\n }\n\n if (req.method === \"GET\" && url.pathname === \"/session/stream\") {\n await handleSessionStreamRequest(req, res, url, sessionViewTokenStore, interactive);\n return true;\n }\n\n if (req.method !== \"GET\" || url.pathname !== \"/session\") {\n return false;\n }\n\n const token = url.searchParams.get(\"token\")?.trim();\n if (!token || !sessionViewTokenStore) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderStatusPage(\"Session unavailable\", \"This session link is invalid or has expired.\"),\n );\n return true;\n }\n\n const entry = sessionViewTokenStore.peek(token);\n if (!entry) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderStatusPage(\"Session unavailable\", \"This session link is invalid or has expired.\"),\n );\n return true;\n }\n\n const requestedSession = url.searchParams.get(\"session\");\n let targetSessionFile: string | null;\n try {\n targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);\n } catch (error) {\n log.logWarning(\n `[${entry.conversationId}] Corrupted session file referenced for ${entry.sessionFile}`,\n error instanceof Error ? error.message : String(error),\n );\n reportUserFacingError(error, {\n domain: \"session_view\",\n surface: \"session_view\",\n operation: \"resolve_requested_session\",\n severity: \"error\",\n platform: entry.platform,\n context: {\n conversationId: entry.conversationId,\n sessionKey: entry.sessionKey,\n sessionFile: basename(entry.sessionFile),\n requestedSession,\n },\n });\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderStatusPage(\"Session unavailable\", \"The selected session file appears to be corrupted.\"),\n );\n return true;\n }\n if (!targetSessionFile) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderStatusPage(\"Session unavailable\", \"The selected session link is invalid.\"));\n return true;\n }\n\n try {\n const model = loadSessionViewModel(targetSessionFile);\n const displayedSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);\n const isRunning = interactive?.handler.isRunning(displayedSessionKey) ?? false;\n res.writeHead(200, {\n \"Content-Type\": \"text/html; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(\n renderSessionPage(\n model,\n entry.token,\n entry.expiresAt,\n isRunning,\n displayedSessionKey,\n entry.conversationId,\n ),\n );\n } catch (error) {\n log.logWarning(\n `[${entry.conversationId}] Failed to render session ${entry.sessionFile}`,\n error instanceof Error ? error.message : String(error),\n );\n reportUserFacingError(error, {\n domain: \"session_view\",\n surface: \"session_view\",\n operation: \"render_session\",\n severity: \"error\",\n platform: entry.platform,\n context: {\n conversationId: entry.conversationId,\n sessionKey: entry.sessionKey,\n sessionFile: basename(targetSessionFile),\n },\n });\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderStatusPage(\"Session unavailable\", \"The session could not be loaded right now.\"));\n }\n\n return true;\n}\n\nfunction resolveDisplayedSessionKey(\n entry: { platform: string; conversationId: string; sessionKey: string },\n sessionFile: string,\n): string {\n if (entry.platform === \"slack\") {\n const fileName = basename(sessionFile, \".jsonl\");\n if (/^\\d+\\.\\d+$/.test(fileName)) {\n return `${entry.conversationId}:${fileName}`;\n }\n return entry.conversationId;\n }\n return entry.sessionKey;\n}\n\nfunction sessionStreamKey(entry: {\n platform: string;\n conversationId: string;\n sessionKey: string;\n}): string {\n return `${entry.platform}:${entry.conversationId}:${entry.sessionKey}`;\n}\n\nfunction renderTimelineItems(items: SessionViewItem[], token: string): string {\n return items.length > 0\n ? items.map((item) => renderItem(item, token)).join(\"\\n\")\n : `<div class=\"system-event\"><span class=\"event-dot\"></span><span class=\"event-text\">No messages yet — send one to the bot, then refresh.</span></div>`;\n}\n\nfunction renderSessionPage(\n model: {\n title: string;\n sessionId: string;\n fileName: string;\n createdAt: string;\n updatedAt: string;\n entryCount: number;\n items: SessionViewItem[];\n parent?: SessionViewRelation;\n forks: SessionViewRelation[];\n },\n token: string,\n expiresAt: number,\n isRunning: boolean,\n displayedSessionKey: string,\n conversationId: string,\n): string {\n const items = renderTimelineItems(model.items, token);\n\n const relatedSections = model.parent\n ? `<section class=\"related-card stack\">\n <p class=\"eyebrow\">Forked from</p>\n ${renderRelationCard(model.parent, token)}\n </section>`\n : \"\";\n\n return renderHtmlDocument(\n `${model.title} · Session Viewer`,\n `<header class=\"hero-card\">\n <div class=\"hero-top\">\n <div class=\"hero-title-group\">\n <span class=\"hero-wordmark\">mikan</span>\n <h1 class=\"hero-title\">${esc(model.title)}</h1>\n <div class=\"hero-meta-line\">\n <span>Created ${esc(formatDate(model.createdAt))}</span>\n <span>Updated <strong data-session-updated>${esc(formatDate(model.updatedAt))}</strong></span>\n <span><strong data-session-entries>${esc(String(model.entryCount))}</strong> entries</span>\n </div>\n </div>\n <div class=\"hero-side\">\n <span class=\"hero-badge hero-badge-status${isRunning ? \" is-running\" : \"\"}\"><span class=\"hero-badge-dot\"></span><strong data-session-status>${esc(isRunning ? \"Running\" : \"Idle\")}</strong></span>\n <span class=\"hero-badge\">${esc(displayedSessionKey === conversationId ? \"Channel\" : \"Thread\")}</span>\n </div>\n </div>\n <div class=\"hero-detail-row\">\n <span class=\"hero-detail\"><span class=\"hero-detail-label\">Session</span><code>${esc(model.sessionId.slice(0, 8))}</code></span>\n <span class=\"hero-detail\"><span class=\"hero-detail-label\">File</span><code>${esc(model.fileName)}</code></span>\n <span class=\"hero-detail\"><span class=\"hero-detail-label\">Expires</span><span>${esc(formatDate(new Date(expiresAt).toISOString()))}</span></span>\n </div>\n </header>\n\n ${relatedSections}\n\n <main class=\"timeline-shell\">\n <div class=\"timeline-list\" data-timeline-list>\n ${items}\n </div>\n </main>\n\n <button class=\"jump-latest-btn\" type=\"button\" hidden data-jump-latest aria-label=\"Jump to latest\" title=\"Jump to latest\">↓</button>\n\n <section class=\"composer-card\">\n <div class=\"composer-copy\">\n <p class=\"eyebrow\">Interactive preview</p>\n <p>Ask mikan in this same session. Replies stay in Session View and do not post back to Slack.</p>\n </div>\n <form class=\"composer-form\" data-session-composer>\n <input type=\"hidden\" name=\"token\" value=\"${esc(token)}\">\n <input type=\"hidden\" name=\"session\" value=\"${esc(model.fileName)}\">\n <input type=\"hidden\" name=\"sessionKey\" value=\"${esc(displayedSessionKey)}\">\n <textarea name=\"text\" rows=\"1\" placeholder=\"Write a message…\" required></textarea>\n <div class=\"composer-actions\">\n <span class=\"composer-status\" data-composer-status></span>\n <button class=\"composer-send-btn\" type=\"submit\" aria-label=\"Send\" title=\"Send\">↑</button>\n </div>\n </form>\n </section>`,\n isRunning,\n );\n}\n\nfunction renderRelationCard(relation: SessionViewRelation, token: string): string {\n const href = `/session?token=${encodeURIComponent(token)}&session=${encodeURIComponent(relation.fileName)}`;\n const summary = relation.summary ? `<p class=\"related-summary\">${esc(relation.summary)}</p>` : \"\";\n return `<a class=\"related-link\" href=\"${href}\">\n <span class=\"related-copy\">\n <strong class=\"related-title\">${esc(relation.title)}</strong>\n ${summary}\n <span class=\"related-meta\">${esc(formatDate(relation.updatedAt))} · ${esc(String(relation.entryCount))} entries · ${esc(relation.fileName)}</span>\n </span>\n <span class=\"related-arrow\" aria-hidden=\"true\">→</span>\n </a>`;\n}\n\nfunction renderForkLinks(relations: SessionViewRelation[] | undefined, token: string): string {\n if (!relations || relations.length === 0) return \"\";\n return `<div class=\"fork-links\">${relations\n .map((relation) => {\n const href = `/session?token=${encodeURIComponent(token)}&session=${encodeURIComponent(relation.fileName)}`;\n return `<a class=\"fork-link\" href=\"${href}\" title=\"Open ${esc(relation.title)}\">\n <span class=\"fork-dot\" aria-hidden=\"true\"></span>\n <span class=\"fork-text\">Thread</span>\n </a>`;\n })\n .join(\"\")}</div>`;\n}\n\nexport function parseUserBody(raw: string): {\n timestamp: string | null;\n username: string | null;\n threadTs: string | null;\n header: string | null;\n content: string;\n} {\n // [timestamp] [username] [in-thread:ts]: content\n let m = raw.match(\n /^\\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{2}:[0-9]{2})\\]\\s*\\[([^\\]]+)\\](?:\\s*\\[in-thread:([^\\]]+)\\])?:\\s*([\\s\\S]*)$/,\n );\n if (m) {\n const header = [`[${m[1]}]`, `[${m[2]}]`, m[3] ? `[in-thread:${m[3]}]` : \"\"]\n .filter(Boolean)\n .join(\" \");\n return {\n timestamp: m[1],\n username: m[2],\n threadTs: m[3] ?? null,\n header,\n content: m[4],\n };\n }\n // [username] [in-thread:ts]: content\n m = raw.match(/^\\[([^\\]]+)\\](?:\\s*\\[in-thread:([^\\]]+)\\])?:\\s*([\\s\\S]*)$/);\n if (m) {\n const header = [`[${m[1]}]`, m[2] ? `[in-thread:${m[2]}]` : \"\"].filter(Boolean).join(\" \");\n return {\n timestamp: null,\n username: m[1],\n threadTs: m[2] ?? null,\n header,\n content: m[3],\n };\n }\n return { timestamp: null, username: null, threadTs: null, header: null, content: raw };\n}\n\ntype ParsedUserBody = ReturnType<typeof parseUserBody>;\n\nfunction renderCopyButton(label = \"Copy message\"): string {\n return `<div class=\"msg-actions\"><button class=\"copy-action-btn\" type=\"button\" data-copy-button data-copy-label=\"${esc(label)}\" aria-label=\"${esc(label)}\" title=\"${esc(label)}\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\" stroke=\"currentColor\" stroke-width=\"1.8\"></rect><path d=\"M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"></path></svg></button></div>`;\n}\n\nfunction renderItem(item: SessionViewItem, token?: string): string {\n if (item.kind === \"system\") {\n const parts = [item.title, item.body].filter((x): x is string => Boolean(x)).map(esc);\n const time = item.meta\n ? ` · <time class=\"event-time\">${esc(formatDate(item.meta))}</time>`\n : \"\";\n return `<div class=\"system-event\"><span class=\"event-dot\"></span><span class=\"event-text\">${parts.join(\" — \")}</span>${time}</div>`;\n }\n\n if (item.kind === \"tool\") {\n const toneClass = item.tone === \"err\" ? \" tone-err\" : item.tone === \"ok\" ? \" tone-ok\" : \"\";\n const body = item.body ? `<pre class=\"tool-output${toneClass}\">${esc(item.body)}</pre>` : \"\";\n const time = item.meta ? `<time class=\"tool-time\">${esc(formatDate(item.meta))}</time>` : \"\";\n return `<div class=\"tool-block\">\n <div class=\"tool-header\">\n <span class=\"tool-icon\"><svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\"><path d=\"M1.5 2L5 5.5 1.5 9\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><path d=\"M6 9h2.5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg></span>\n <span class=\"tool-name\">${esc(item.title)}</span>\n ${time}\n </div>\n ${body}\n</div>`;\n }\n\n const time = item.meta ? `<time class=\"msg-time\">${esc(formatDate(item.meta))}</time>` : \"\";\n\n if (item.kind === \"user\") {\n const parsed: ParsedUserBody = item.body\n ? parseUserBody(item.body)\n : { timestamp: null, username: null, threadTs: null, header: null, content: \"\" };\n const { username, threadTs, header, content } = parsed;\n const initial = username ? esc(username.slice(0, 2).toUpperCase()) : \"U\";\n const rawHeader = header ? `<div class=\"msg-raw-header\">${esc(header)}</div>` : \"\";\n const body = content ? renderMarkdownBlock(content, \"user\") : \"\";\n const threadBadge = threadTs\n ? `<div class=\"thread-badge\" title=\"Thread ${esc(threadTs)}\">Thread · <code>${esc(threadTs)}</code></div>`\n : \"\";\n const forks = renderForkLinks(item.forks, token ?? \"\");\n return `<div class=\"msg-row msg-user copy-host\">\n <div class=\"msg-main user-main\">\n <div class=\"user-bubble\">\n ${rawHeader}\n ${threadBadge}\n ${body}\n ${forks}\n ${time}\n </div>\n ${renderCopyButton()}\n </div>\n <div class=\"msg-avatar user-avatar\" title=\"${username ? esc(username) : \"User\"}\">${initial}</div>\n</div>`;\n }\n\n // assistant\n const body = item.body ? renderMarkdownBlock(item.body, \"assistant\") : \"\";\n const forks = renderForkLinks(item.forks, token ?? \"\");\n return `<div class=\"msg-row msg-assistant copy-host\">\n <div class=\"msg-avatar asst-avatar\" aria-hidden=\"true\">A</div>\n <div class=\"msg-main asst-main\">\n <div class=\"asst-card\">\n ${body}\n ${forks}\n ${time}\n </div>\n ${renderCopyButton()}\n </div>\n</div>`;\n}\n\nfunction renderMarkdownBlock(text: string, variant: \"user\" | \"assistant\"): string {\n return `<div class=\"msg-body markdown-body markdown-${variant}\">${markdown.render(text)}</div>`;\n}\n\nfunction renderLiveUserMessage(text: string, userName: string): string {\n const initial = esc(userName.slice(0, 2).toUpperCase());\n return `<div class=\"msg-row msg-user copy-host\" data-live-item>\n <div class=\"msg-main user-main\">\n <div class=\"user-bubble\">\n ${renderMarkdownBlock(text, \"user\")}\n </div>\n ${renderCopyButton()}\n </div>\n <div class=\"msg-avatar user-avatar\" title=\"${esc(userName)}\">${initial}</div>\n</div>`;\n}\n\nfunction renderLiveAssistantMessage(text: string): string {\n return `<div class=\"msg-row msg-assistant copy-host\" data-live-assistant>\n <div class=\"msg-avatar asst-avatar\" aria-hidden=\"true\">A</div>\n <div class=\"msg-main asst-main\">\n <div class=\"asst-card\">\n ${renderMarkdownBlock(text, \"assistant\")}\n </div>\n ${renderCopyButton()}\n </div>\n</div>`;\n}\n\nfunction renderLiveToolResult(result: {\n toolName: string;\n result: string;\n isError: boolean;\n}): string {\n const toneClass = result.isError ? \" tone-err\" : \" tone-ok\";\n return `<div class=\"tool-block\" data-live-item>\n <div class=\"tool-header\">\n <span class=\"tool-icon\"><svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\"><path d=\"M1.5 2L5 5.5 1.5 9\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><path d=\"M6 9h2.5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg></span>\n <span class=\"tool-name\">${esc(result.toolName)}</span>\n </div>\n <pre class=\"tool-output${toneClass}\">${esc(result.result)}</pre>\n</div>`;\n}\n\nfunction renderLiveSystemEvent(text: string, tone: \"default\" | \"err\" = \"default\"): string {\n const cls = tone === \"err\" ? \" system-event-err\" : \"\";\n return `<div class=\"system-event${cls}\" data-live-item><span class=\"event-dot\"></span><span class=\"event-text\">${esc(text)}</span></div>`;\n}\n\nasync function handleSessionStreamRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n sessionViewTokenStore?: InMemorySessionViewTokenStore,\n interactive?: SessionViewInteractiveOptions,\n): Promise<void> {\n const token = url.searchParams.get(\"token\")?.trim() ?? \"\";\n if (!token || !sessionViewTokenStore || !interactive) {\n res.writeHead(400, { \"Content-Type\": \"text/plain; charset=utf-8\" });\n res.end(\"Session stream unavailable\");\n return;\n }\n\n const entry = sessionViewTokenStore.peek(token);\n if (!entry) {\n res.writeHead(400, { \"Content-Type\": \"text/plain; charset=utf-8\" });\n res.end(\"Invalid session token\");\n return;\n }\n\n const requestedSession = url.searchParams.get(\"session\");\n let targetSessionFile: string | null;\n try {\n targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);\n } catch (error) {\n reportUserFacingError(error, {\n domain: \"session_view\",\n surface: \"session_view\",\n operation: \"session_stream\",\n severity: \"error\",\n platform: entry.platform,\n context: {\n conversationId: entry.conversationId,\n sessionKey: entry.sessionKey,\n sessionFile: basename(entry.sessionFile),\n requestedSession,\n },\n });\n res.writeHead(500, { \"Content-Type\": \"text/plain; charset=utf-8\" });\n res.end(\"Session stream unavailable\");\n return;\n }\n if (!targetSessionFile) {\n res.writeHead(400, { \"Content-Type\": \"text/plain; charset=utf-8\" });\n res.end(\"Invalid session file\");\n return;\n }\n const activeSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);\n const streamKey = sessionStreamKey({ ...entry, sessionKey: activeSessionKey });\n res.writeHead(200, {\n \"Content-Type\": \"text/event-stream; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n Connection: \"keep-alive\",\n });\n res.write(\n `data: ${JSON.stringify({ type: \"status\", running: interactive.handler.isRunning(activeSessionKey) })}\\n\\n`,\n );\n\n const unsubscribe = sessionViewStreamHub.subscribe(streamKey, (event) => {\n res.write(`data: ${JSON.stringify(event)}\\n\\n`);\n });\n const heartbeat = setInterval(() => {\n res.write(\": keep-alive\\n\\n\");\n }, 15000);\n\n req.on(\"close\", () => {\n clearInterval(heartbeat);\n unsubscribe();\n });\n}\n\nasync function handleSessionMessageRequest(\n req: IncomingMessage,\n res: ServerResponse,\n sessionViewTokenStore?: InMemorySessionViewTokenStore,\n interactive?: SessionViewInteractiveOptions,\n): Promise<void> {\n if (!sessionViewTokenStore || !interactive) {\n json(res, 503, { ok: false, error: \"Session chat is not configured.\" });\n return;\n }\n\n let body: { token?: string; text?: string; session?: string; sessionKey?: string };\n try {\n body = JSON.parse(await readRequestBody(req)) as {\n token?: string;\n text?: string;\n session?: string;\n sessionKey?: string;\n };\n } catch {\n json(res, 400, { ok: false, error: \"Invalid request body.\" });\n return;\n }\n\n const token = body.token?.trim() ?? \"\";\n const text = body.text?.trim() ?? \"\";\n const requestedSession = body.session?.trim() || null;\n const requestedSessionKey = body.sessionKey?.trim() || \"\";\n if (!token || !text) {\n json(res, 400, { ok: false, error: \"Missing token or text.\" });\n return;\n }\n\n const entry = sessionViewTokenStore.peek(token);\n if (!entry) {\n json(res, 400, { ok: false, error: \"This session link is invalid or has expired.\" });\n return;\n }\n\n let targetSessionFile: string | null;\n try {\n targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);\n } catch (error) {\n reportUserFacingError(error, {\n domain: \"session_view\",\n surface: \"session_view\",\n operation: \"session_message\",\n severity: \"error\",\n platform: entry.platform,\n context: {\n conversationId: entry.conversationId,\n sessionKey: entry.sessionKey,\n sessionFile: basename(entry.sessionFile),\n requestedSession,\n },\n });\n json(res, 500, { ok: false, error: \"Session file could not be loaded.\" });\n return;\n }\n if (!targetSessionFile) {\n json(res, 400, { ok: false, error: \"Invalid session file.\" });\n return;\n }\n const activeSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);\n if (requestedSessionKey && requestedSessionKey !== activeSessionKey) {\n json(res, 400, { ok: false, error: \"Session target mismatch.\" });\n return;\n }\n\n const bot = interactive.botsByPlatform[entry.platform];\n if (!bot) {\n json(res, 503, { ok: false, error: `No bot configured for ${entry.platform}.` });\n return;\n }\n\n const streamKey = sessionStreamKey({ ...entry, sessionKey: activeSessionKey });\n const conversationKind = inferConversationKind(entry.platform, entry.conversationId);\n const ts = (Date.now() / 1000).toFixed(6);\n const platformInfo = bot.getPlatformInfo();\n const platformUserName =\n entry.platformUserName ||\n platformInfo.users.find((user) => user.id === entry.platformUserId)?.userName ||\n platformInfo.users.find((user) => user.id === entry.platformUserId)?.displayName ||\n \"unknown\";\n const responseCtx = createSessionViewResponseContext((event) => {\n sessionViewStreamHub.publish(streamKey, event);\n });\n const event: BotEvent = {\n type: \"session_view\",\n conversationId: entry.conversationId,\n conversationKind,\n ts,\n user: entry.platformUserId,\n text,\n attachments: [],\n sessionKey: activeSessionKey,\n ...(activeSessionKey.includes(\":\")\n ? { thread_ts: activeSessionKey.split(\":\").slice(1).join(\":\") }\n : {}),\n };\n const adapters: BotAdapters = {\n message: {\n id: ts,\n sessionKey: activeSessionKey,\n conversationKind,\n userId: entry.platformUserId,\n userName: platformUserName,\n text,\n attachments: [],\n threadTs: event.thread_ts,\n },\n responseCtx,\n platform: { ...platformInfo, diagnostics: { showUsageSummary: false } },\n };\n\n sessionViewStreamHub.publish(streamKey, { type: \"status\", running: true });\n sessionViewStreamHub.publish(streamKey, {\n type: \"user\",\n html: renderLiveUserMessage(text, platformUserName),\n });\n\n void interactive.handler\n .handleEvent(event, bot, adapters)\n .then(() => {\n if (!targetSessionFile) {\n sessionViewStreamHub.publish(streamKey, { type: \"status\", running: false });\n return;\n }\n const model = loadSessionViewModel(targetSessionFile);\n sessionViewStreamHub.publish(streamKey, {\n type: \"refresh\",\n timelineHtml: renderTimelineItems(model.items, token),\n updatedAt: formatDate(model.updatedAt),\n entryCount: model.entryCount,\n running: false,\n });\n })\n .catch((error) => {\n log.logWarning(\n `[${entry.conversationId}] Session view message failed`,\n error instanceof Error ? error.message : String(error),\n );\n reportUserFacingError(error, {\n domain: \"session_view\",\n surface: \"session_view\",\n operation: \"interactive_message\",\n severity: \"error\",\n platform: entry.platform,\n context: {\n conversationId: entry.conversationId,\n sessionKey: activeSessionKey,\n messageId: ts,\n textLength: text.length,\n },\n });\n sessionViewStreamHub.publish(streamKey, {\n type: \"error\",\n message: error instanceof Error ? error.message : String(error),\n });\n sessionViewStreamHub.publish(streamKey, { type: \"status\", running: false });\n });\n\n json(res, 202, { ok: true, accepted: true });\n}\n\nfunction createSessionViewResponseContext(\n publish: (event: SessionStreamEvent) => void,\n): ChatResponseContext {\n let accumulatedText = \"\";\n\n return {\n respond: async (text: string) => {\n accumulatedText = accumulatedText ? `${accumulatedText}\\n${text}` : text;\n publish({ type: \"assistant\", html: renderLiveAssistantMessage(accumulatedText) });\n },\n replaceResponse: async (text: string) => {\n accumulatedText = text;\n publish({ type: \"assistant\", html: renderLiveAssistantMessage(accumulatedText) });\n },\n respondDiagnostic: async (text: string, options?: { style?: \"muted\" | \"error\" }) => {\n if (options?.style === \"error\") {\n publish({ type: \"system\", html: renderLiveSystemEvent(text, \"err\") });\n }\n },\n respondToolResult: async (result) => {\n publish({ type: \"tool\", html: renderLiveToolResult(result) });\n },\n setTyping: async () => {\n publish({ type: \"status\", running: true });\n },\n setWorking: async (working: boolean) => {\n publish({ type: \"status\", running: working });\n },\n uploadFile: async () => {},\n deleteResponse: async () => {\n accumulatedText = \"\";\n publish({ type: \"assistant_remove\" });\n },\n };\n}\n\nfunction readRequestBody(req: IncomingMessage): Promise<string> {\n return new Promise((resolve, reject) => {\n let data = \"\";\n req.setEncoding(\"utf8\");\n req.on(\"data\", (chunk) => {\n data += chunk;\n if (data.length > 1024 * 1024) {\n reject(new Error(\"Request body too large\"));\n req.destroy();\n }\n });\n req.on(\"end\", () => resolve(data));\n req.on(\"error\", reject);\n });\n}\n\nfunction json(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(JSON.stringify(body));\n}\n\nfunction renderStatusPage(title: string, message: string): string {\n return renderHtmlDocument(\n title,\n `<section class=\"card stack\">\n <p class=\"eyebrow\">mikan</p>\n <h1>${esc(title)}</h1>\n <div class=\"status err\">${esc(message)}</div>\n </section>`,\n false,\n );\n}\n\nfunction renderHtmlDocument(title: string, shellContent: string, isRunning: boolean): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>${esc(title)}</title>\n <style>${styles}</style>\n</head>\n<body data-session-running=\"${isRunning ? \"true\" : \"false\"}\">\n <main class=\"shell\">\n ${shellContent}\n </main>\n <script>\n const form = document.querySelector('[data-session-composer]');\n const timelineList = document.querySelector('[data-timeline-list]');\n const jumpLatestBtn = document.querySelector('[data-jump-latest]');\n const statusEl = document.querySelector('[data-session-status]');\n const updatedEl = document.querySelector('[data-session-updated]');\n const entriesEl = document.querySelector('[data-session-entries]');\n const composerStatus = form?.querySelector('[data-composer-status]');\n const textarea = form?.querySelector('textarea[name=\"text\"]');\n const submitButton = form?.querySelector('button[type=\"submit\"]');\n let liveAssistant = null;\n let running = document.body.dataset.sessionRunning === 'true';\n\n const isNearBottom = () => window.innerHeight + window.scrollY >= document.body.offsetHeight - 120;\n const scrollToLatest = (behavior = 'smooth') => window.scrollTo({ top: document.body.scrollHeight, behavior });\n const toggleJumpButton = () => {\n if (!jumpLatestBtn) return;\n jumpLatestBtn.hidden = isNearBottom();\n };\n const updateFollowState = () => {\n if (isNearBottom()) scrollToLatest('smooth');\n else toggleJumpButton();\n };\n const canSubmit = () => Boolean(textarea && textarea.value.trim()) && !running;\n const updateSubmitButtonState = () => {\n if (submitButton) submitButton.disabled = !canSubmit();\n };\n const setRunning = (value) => {\n running = value;\n document.body.dataset.sessionRunning = value ? 'true' : 'false';\n if (statusEl) statusEl.textContent = value ? 'Running' : 'Idle';\n updateSubmitButtonState();\n if (composerStatus && !value && composerStatus.textContent === 'Thinking…') {\n composerStatus.textContent = '';\n }\n };\n\n jumpLatestBtn?.addEventListener('click', () => {\n scrollToLatest('smooth');\n toggleJumpButton();\n });\n document.addEventListener('click', async (event) => {\n const button = event.target instanceof Element ? event.target.closest('[data-copy-button]') : null;\n if (!(button instanceof HTMLButtonElement)) return;\n const label = button.dataset.copyLabel || 'Copy message';\n const source = button.closest('.msg-actions')?.previousElementSibling;\n const text = source instanceof HTMLElement ? (source.innerText || source.textContent || '').trim() : '';\n if (!text) return;\n const setState = (state, transient) => {\n button.dataset.copyState = state;\n button.title = transient;\n button.setAttribute('aria-label', transient);\n window.setTimeout(() => {\n if (!button.isConnected) return;\n delete button.dataset.copyState;\n button.title = label;\n button.setAttribute('aria-label', label);\n }, 1200);\n };\n try {\n await navigator.clipboard.writeText(text);\n setState('done', 'Copied');\n } catch {\n setState('error', 'Copy failed');\n }\n });\n window.addEventListener('scroll', toggleJumpButton, { passive: true });\n\n if (textarea) {\n const resize = () => {\n textarea.style.height = 'auto';\n textarea.style.height = Math.min(textarea.scrollHeight, 240) + 'px';\n };\n textarea.addEventListener('input', () => {\n resize();\n updateSubmitButtonState();\n });\n textarea.addEventListener('keydown', (event) => {\n if (event.key !== 'Enter' || event.shiftKey) return;\n if (event.isComposing || event.keyCode === 229) return;\n event.preventDefault();\n if (!running) form?.requestSubmit();\n });\n resize();\n }\n\n setRunning(running);\n updateSubmitButtonState();\n\n const streamUrl = form\n ? '/session/stream?token=' + encodeURIComponent(form.token.value) + '&session=' + encodeURIComponent(form.session.value)\n : null;\n if (streamUrl) {\n const source = new EventSource(streamUrl);\n source.onmessage = (event) => {\n const payload = JSON.parse(event.data);\n switch (payload.type) {\n case 'status':\n setRunning(Boolean(payload.running));\n if (payload.running && composerStatus) composerStatus.textContent = 'Thinking…';\n break;\n case 'user':\n case 'tool':\n case 'system': {\n timelineList?.insertAdjacentHTML('beforeend', payload.html);\n updateFollowState();\n break;\n }\n case 'assistant': {\n if (!liveAssistant || !liveAssistant.isConnected) {\n timelineList?.insertAdjacentHTML('beforeend', payload.html);\n liveAssistant = timelineList?.querySelector('[data-live-assistant]:last-of-type') || null;\n } else {\n liveAssistant.outerHTML = payload.html;\n liveAssistant = timelineList?.querySelector('[data-live-assistant]:last-of-type') || null;\n }\n updateFollowState();\n break;\n }\n case 'assistant_remove':\n if (liveAssistant?.isConnected) liveAssistant.remove();\n liveAssistant = null;\n break;\n case 'refresh':\n if (timelineList) timelineList.innerHTML = payload.timelineHtml;\n liveAssistant = null;\n if (updatedEl) updatedEl.textContent = payload.updatedAt;\n if (entriesEl) entriesEl.textContent = String(payload.entryCount);\n setRunning(Boolean(payload.running));\n if (composerStatus) composerStatus.textContent = '';\n updateFollowState();\n break;\n case 'error':\n if (composerStatus) composerStatus.textContent = payload.message || 'Something went wrong';\n setRunning(false);\n break;\n }\n };\n }\n\n form?.addEventListener('submit', async (event) => {\n event.preventDefault();\n if (!textarea || !composerStatus) return;\n const text = textarea.value.trim();\n if (!text || running) return;\n composerStatus.textContent = 'Sending…';\n updateSubmitButtonState();\n try {\n const response = await fetch('/session/message', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: form.token.value, session: form.session.value, sessionKey: form.sessionKey.value, text }),\n });\n const payload = await response.json();\n if (!response.ok || !payload.ok) throw new Error(payload.error || 'Request failed');\n textarea.value = '';\n textarea.style.height = 'auto';\n composerStatus.textContent = 'Thinking…';\n setRunning(true);\n updateSubmitButtonState();\n scrollToLatest('smooth');\n } catch (err) {\n composerStatus.textContent = err && err.message ? err.message : String(err);\n submitButton.disabled = false;\n }\n });\n\n toggleJumpButton();\n </script>\n</body>\n</html>`;\n}\n\nfunction formatDate(value: string): string {\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return value;\n return date.toLocaleString();\n}\n\nfunction esc(value: string): string {\n return value\n .replaceAll(\"&\", \"&amp;\")\n .replaceAll(\"<\", \"&lt;\")\n .replaceAll(\">\", \"&gt;\")\n .replaceAll('\"', \"&quot;\")\n .replaceAll(\"'\", \"&#39;\");\n}\n\nconst styles = `\n @import url('https://fonts.googleapis.com/css2?family=Lora:wght@400;600&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');\n\n :root {\n --bg: #f0ece3;\n --surface: #ffffff;\n --border: rgba(0, 0, 0, 0.08);\n --text: #18181b;\n --muted: #71717a;\n --subtle: #a1a1aa;\n\n --user-bg: #18181b;\n --user-text: #fafafa;\n --user-time: rgba(250, 250, 250, 0.5);\n\n --asst-border: #22c55e;\n --asst-avatar-bg: #f0fdf4;\n --asst-avatar-text: #16a34a;\n\n --tool-bg: #0d1117;\n --tool-header: #161b22;\n --tool-text: #c9d1d9;\n --tool-accent: #58a6ff;\n --tool-ok: #3fb950;\n --tool-err: #f85149;\n --tool-time: #484f58;\n\n --ok-bg: #f0fdf4;\n --ok-text: #15803d;\n --err-bg: #fef2f2;\n --err-text: #b91c1c;\n }\n\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n min-height: 100vh;\n padding: 40px 20px calc(140px + env(safe-area-inset-bottom, 0px));\n display: flex;\n flex-direction: column;\n align-items: center;\n overflow-x: hidden;\n background-color: var(--bg);\n background-image:\n radial-gradient(ellipse 80% 40% at 50% -10%, rgba(255,255,255,0.6) 0%, transparent 70%);\n color: var(--text);\n font-family: 'DM Sans', 'Segoe UI', system-ui, sans-serif;\n font-size: 15px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n }\n\n .shell {\n width: 100%;\n max-width: 780px;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 12px;\n }\n\n /* ── Hero ─────────────────────────────────────────────────────────────── */\n\n .hero-card {\n padding: 28px 32px 24px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--surface);\n box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06);\n }\n\n .hero-top {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 20px;\n margin-bottom: 18px;\n }\n\n .hero-wordmark {\n display: block;\n margin-bottom: 6px;\n color: var(--subtle);\n font-size: 0.72rem;\n font-weight: 600;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n }\n\n .hero-title {\n font-family: 'Lora', Georgia, serif;\n font-size: clamp(1.4rem, 2.5vw, 1.75rem);\n font-weight: 600;\n line-height: 1.2;\n letter-spacing: -0.01em;\n color: var(--text);\n text-wrap: balance;\n margin-bottom: 8px;\n }\n\n .hero-meta-line {\n display: flex;\n flex-wrap: wrap;\n gap: 8px 14px;\n color: var(--muted);\n font-size: 0.82rem;\n line-height: 1.4;\n }\n\n .hero-meta-line strong {\n color: var(--text);\n font-weight: 600;\n }\n\n .hero-side {\n display: flex;\n flex-direction: column;\n align-items: flex-end;\n gap: 8px;\n flex-shrink: 0;\n }\n\n .hero-badge {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 6px 11px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: rgba(255,255,255,0.7);\n font-size: 0.78rem;\n color: var(--muted);\n line-height: 1;\n }\n\n .hero-badge strong {\n color: var(--text);\n font-weight: 600;\n }\n\n .hero-badge-status.is-running {\n background: #fff7ed;\n border-color: rgba(217, 119, 6, 0.18);\n color: #9a3412;\n }\n\n .hero-badge-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: #a1a1aa;\n flex-shrink: 0;\n }\n\n .hero-badge-status.is-running .hero-badge-dot {\n background: #d97706;\n box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.14);\n }\n\n .hero-detail-row {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n padding-top: 14px;\n border-top: 1px solid rgba(0, 0, 0, 0.06);\n }\n\n .hero-detail {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n padding: 6px 10px;\n border-radius: 12px;\n background: rgba(0, 0, 0, 0.025);\n color: var(--muted);\n font-size: 0.78rem;\n }\n\n .hero-detail-label {\n text-transform: uppercase;\n letter-spacing: 0.08em;\n font-size: 0.68rem;\n color: var(--subtle);\n }\n\n .hero-detail code {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.74rem;\n color: var(--text);\n }\n\n /* ── Timeline shell ───────────────────────────────────────────────────── */\n\n .fork-links {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n margin-top: 10px;\n }\n\n .fork-link {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 5px 10px;\n border-radius: 999px;\n border: 1px solid rgba(239, 68, 68, 0.18);\n background: rgba(254, 242, 242, 0.95);\n color: #b91c1c;\n text-decoration: none;\n font-size: 0.74rem;\n font-weight: 600;\n line-height: 1;\n transition: transform 120ms, background 120ms, border-color 120ms;\n }\n\n .fork-link:hover {\n transform: translateY(-1px);\n background: #fff1f2;\n border-color: rgba(239, 68, 68, 0.28);\n }\n\n .fork-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: #ef4444;\n box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);\n flex-shrink: 0;\n }\n\n .fork-text {\n white-space: nowrap;\n }\n\n .related-card {\n padding: 18px 20px;\n border: 1px solid var(--border);\n border-radius: 18px;\n background: rgba(255,255,255,0.78);\n box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);\n backdrop-filter: blur(12px);\n }\n\n .related-list {\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n\n .related-link {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 12px 14px;\n border-radius: 14px;\n border: 1px solid var(--border);\n background: rgba(255,255,255,0.82);\n color: inherit;\n text-decoration: none;\n transition: transform 120ms, border-color 120ms, box-shadow 120ms, background 120ms;\n }\n\n .related-link:hover {\n transform: translateY(-1px);\n border-color: rgba(0,0,0,0.16);\n background: #fff;\n box-shadow: 0 8px 18px rgba(0,0,0,0.05);\n }\n\n .related-copy {\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .related-title {\n color: var(--text);\n font-size: 0.94rem;\n line-height: 1.3;\n }\n\n .related-summary {\n color: var(--muted);\n font-size: 0.82rem;\n line-height: 1.45;\n }\n\n .related-meta {\n color: var(--subtle);\n font-size: 0.74rem;\n line-height: 1.4;\n }\n\n .related-arrow {\n flex-shrink: 0;\n color: var(--subtle);\n font-size: 1rem;\n }\n\n .timeline-shell {\n padding: 20px 0;\n }\n\n .timeline-list {\n display: flex;\n flex-direction: column;\n gap: 14px;\n min-width: 0;\n }\n\n .copy-host {\n position: relative;\n }\n\n .msg-actions {\n height: 32px;\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 8px;\n opacity: 0;\n visibility: hidden;\n transition: opacity 140ms ease, visibility 140ms ease;\n }\n\n .copy-host:hover .msg-actions,\n .copy-host .msg-actions:hover,\n .copy-host:focus-within .msg-actions,\n .timeline-list > .copy-host:last-child .msg-actions,\n .copy-action-btn[data-copy-state] {\n opacity: 1;\n visibility: visible;\n }\n\n .copy-action-btn {\n width: 24px;\n height: 24px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: rgba(63,63,70,0.8);\n transition: color 140ms ease, opacity 140ms ease;\n cursor: pointer;\n padding: 0;\n appearance: none;\n }\n\n .copy-action-btn:hover {\n background: transparent;\n color: rgba(24,24,27,0.96);\n border-color: transparent;\n }\n\n .copy-action-btn[data-copy-state='done'] {\n background: transparent;\n border-color: transparent;\n color: rgba(24,24,27,0.96);\n }\n\n .copy-action-btn[data-copy-state='done'] svg {\n position: absolute;\n opacity: 0;\n transform: scale(0.6);\n pointer-events: none;\n }\n\n .copy-action-btn svg {\n transition: opacity 140ms ease, transform 140ms ease;\n }\n\n .copy-action-btn[data-copy-state='done']::before {\n content: '';\n width: 14px;\n height: 14px;\n background-color: currentColor;\n -webkit-mask: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 12 10 18 20 6'/></svg>\") center / contain no-repeat;\n mask: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 12 10 18 20 6'/></svg>\") center / contain no-repeat;\n animation: copy-check-in 200ms ease-out both;\n }\n\n @keyframes copy-check-in {\n from { opacity: 0; transform: scale(0.6); }\n to { opacity: 1; transform: scale(1); }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .copy-action-btn svg,\n .copy-action-btn[data-copy-state='done']::before {\n transition: none;\n animation: none;\n }\n }\n\n .copy-action-btn[data-copy-state='error'] {\n background: transparent;\n border-color: transparent;\n color: #b91c1c;\n }\n\n /* ── Message rows ─────────────────────────────────────────────────────── */\n\n .msg-row {\n display: flex;\n align-items: flex-end;\n gap: 8px;\n padding: 4px 0;\n min-width: 0;\n }\n\n /* ── User messages ────────────────────────────────────────────────────── */\n\n .msg-user {\n justify-content: flex-end;\n }\n\n .msg-main {\n min-width: 0;\n }\n\n .user-main {\n max-width: 85%;\n display: flex;\n flex-direction: column;\n align-items: flex-end;\n }\n\n .user-bubble {\n max-width: 100%;\n min-width: 0;\n padding: 12px 16px;\n border-radius: 18px 18px 4px 18px;\n background: var(--user-bg);\n color: var(--user-text);\n box-shadow: 0 1px 2px rgba(0,0,0,0.12);\n }\n\n .msg-raw-header {\n margin-bottom: 8px;\n color: rgba(250, 250, 250, 0.72);\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.72rem;\n line-height: 1.5;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .thread-badge {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 8px;\n padding: 4px 10px;\n border-radius: 999px;\n background: rgba(255,255,255,0.22);\n color: var(--user-text);\n font-size: 0.68rem;\n font-weight: 700;\n letter-spacing: 0.01em;\n }\n\n .thread-badge code {\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.66rem;\n background: rgba(255,255,255,0.16);\n padding: 1px 6px;\n border-radius: 999px;\n color: inherit;\n }\n\n .msg-user .msg-body {\n color: var(--user-text);\n }\n\n .msg-user .msg-time {\n display: block;\n margin-top: 6px;\n font-size: 0.72rem;\n color: var(--user-time);\n text-align: right;\n }\n\n /* ── Avatars ──────────────────────────────────────────────────────────── */\n\n .msg-avatar {\n flex: 0 0 28px;\n width: 28px;\n height: 28px;\n border-radius: 50%;\n font-size: 0.68rem;\n font-weight: 700;\n display: flex;\n align-items: center;\n justify-content: center;\n letter-spacing: 0;\n flex-shrink: 0;\n }\n\n .user-avatar {\n background: #eff6ff;\n border: 1.5px solid #93c5fd;\n color: #1d4ed8;\n }\n\n .asst-avatar {\n background: var(--asst-avatar-bg);\n border: 1.5px solid var(--asst-border);\n color: var(--asst-avatar-text);\n margin-bottom: 2px;\n }\n\n /* ── Assistant messages ───────────────────────────────────────────────── */\n\n .msg-assistant {\n align-items: flex-end;\n gap: 8px;\n max-width: 85%;\n min-width: 0;\n }\n\n .asst-main {\n max-width: 100%;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n }\n\n .asst-card {\n min-width: 0;\n max-width: 100%;\n padding: 14px 18px;\n border: 1px solid var(--border);\n border-radius: 18px 18px 18px 4px;\n background: var(--surface);\n box-shadow: 0 1px 3px rgba(0,0,0,0.04);\n }\n\n .msg-assistant .msg-body {\n color: var(--text);\n }\n\n .msg-assistant .msg-time {\n display: block;\n margin-top: 8px;\n font-size: 0.72rem;\n color: var(--subtle);\n }\n\n /* ── Tool blocks ──────────────────────────────────────────────────────── */\n\n .tool-block {\n max-width: 92%;\n margin-left: 36px;\n border-radius: 10px;\n overflow: hidden;\n border: 1px solid rgba(255,255,255,0.06);\n box-shadow: 0 2px 8px rgba(0,0,0,0.16);\n margin: 2px 0;\n }\n\n .tool-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n background: var(--tool-header);\n border-bottom: 1px solid rgba(255,255,255,0.06);\n overflow: hidden;\n }\n\n .tool-icon {\n color: var(--tool-accent);\n flex-shrink: 0;\n display: flex;\n align-items: center;\n }\n\n .tool-name {\n flex: 1;\n font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;\n font-size: 0.75rem;\n font-weight: 500;\n color: var(--tool-accent);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .tool-time {\n flex-shrink: 0;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.7rem;\n color: var(--tool-time);\n }\n\n .tool-output {\n display: block;\n padding: 12px 14px;\n background: var(--tool-bg);\n color: var(--tool-text);\n font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;\n font-size: 0.78rem;\n line-height: 1.6;\n white-space: pre-wrap;\n word-break: break-word;\n overflow-x: auto;\n max-height: 400px;\n overflow-y: auto;\n }\n\n .tool-output.tone-ok { color: var(--tool-ok); }\n .tool-output.tone-err { color: var(--tool-err); }\n\n /* ── Markdown blocks ──────────────────────────────────────────────────── */\n\n .markdown-body {\n font-family: 'DM Sans', system-ui, sans-serif;\n font-size: 0.9rem;\n line-height: 1.65;\n word-break: break-word;\n }\n\n .markdown-body > *:first-child { margin-top: 0; }\n .markdown-body > *:last-child { margin-bottom: 0; }\n .markdown-body p,\n .markdown-body ul,\n .markdown-body ol,\n .markdown-body blockquote,\n .markdown-body pre,\n .markdown-body table,\n .markdown-body hr {\n margin: 0 0 0.85em;\n }\n\n .markdown-body h1,\n .markdown-body h2,\n .markdown-body h3,\n .markdown-body h4,\n .markdown-body h5,\n .markdown-body h6 {\n margin: 0 0 0.55em;\n line-height: 1.25;\n font-weight: 700;\n letter-spacing: -0.01em;\n }\n\n .markdown-body h1 { font-size: 1.4rem; }\n .markdown-body h2 { font-size: 1.22rem; }\n .markdown-body h3 { font-size: 1.08rem; }\n .markdown-body h4,\n .markdown-body h5,\n .markdown-body h6 { font-size: 0.95rem; }\n\n .markdown-body ul,\n .markdown-body ol {\n padding-left: 1.3em;\n }\n\n .markdown-body li + li {\n margin-top: 0.22em;\n }\n\n .markdown-body blockquote {\n padding-left: 12px;\n border-left: 3px solid rgba(34, 197, 94, 0.35);\n opacity: 0.95;\n }\n\n .markdown-body a {\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n }\n\n .markdown-body code {\n font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;\n font-size: 0.82em;\n padding: 0.16em 0.38em;\n border-radius: 6px;\n }\n\n .markdown-body pre {\n overflow-x: auto;\n border-radius: 12px;\n padding: 12px 14px;\n }\n\n .markdown-body pre code {\n display: block;\n padding: 0;\n border-radius: 0;\n background: transparent;\n font-size: 0.82rem;\n line-height: 1.6;\n }\n\n .markdown-body table {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.85rem;\n }\n\n .markdown-body th,\n .markdown-body td {\n padding: 8px 10px;\n border: 1px solid rgba(0, 0, 0, 0.08);\n text-align: left;\n vertical-align: top;\n }\n\n .markdown-body img {\n max-width: 100%;\n border-radius: 12px;\n }\n\n .markdown-user code {\n background: rgba(255,255,255,0.14);\n color: var(--user-text);\n }\n\n .markdown-user pre {\n background: rgba(255,255,255,0.08);\n border: 1px solid rgba(255,255,255,0.08);\n }\n\n .markdown-user table th,\n .markdown-user table td {\n border-color: rgba(255,255,255,0.16);\n }\n\n .markdown-assistant code {\n background: #f4f4f5;\n color: #27272a;\n }\n\n .markdown-assistant pre {\n background: #0f172a;\n color: #e5e7eb;\n }\n\n .markdown-assistant pre code {\n background: transparent;\n color: inherit;\n }\n\n .markdown-assistant table th,\n .markdown-assistant table td {\n border-color: rgba(0, 0, 0, 0.08);\n }\n\n /* ── System events ────────────────────────────────────────────────────── */\n\n .system-event {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 10px 0;\n color: var(--subtle);\n font-size: 0.775rem;\n }\n\n .event-dot {\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: var(--subtle);\n flex-shrink: 0;\n opacity: 0.6;\n }\n\n .event-text {\n color: var(--muted);\n }\n\n .system-event-err .event-text {\n color: var(--err-text);\n }\n\n .event-time {\n color: var(--subtle);\n font-style: normal;\n }\n\n /* ── Status page ──────────────────────────────────────────────────────── */\n\n .card {\n padding: 28px 32px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--surface);\n box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06);\n }\n\n .stack > * + * { margin-top: 14px; }\n\n .eyebrow {\n color: var(--subtle);\n font-size: 0.72rem;\n font-weight: 600;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n }\n\n h1 {\n font-family: 'Lora', Georgia, serif;\n font-size: clamp(1.4rem, 2.5vw, 1.75rem);\n font-weight: 600;\n letter-spacing: -0.01em;\n line-height: 1.2;\n }\n\n p { color: var(--muted); font-size: 0.9rem; line-height: 1.5; }\n\n .status {\n padding: 12px 16px;\n border-radius: 10px;\n font-size: 0.9rem;\n }\n\n .status.err {\n background: var(--err-bg);\n color: var(--err-text);\n border: 1px solid rgba(185, 28, 28, 0.12);\n }\n\n /* ── Composer ─────────────────────────────────────────────────────────── */\n\n .composer-card {\n position: fixed;\n left: 50%;\n bottom: calc(16px + env(safe-area-inset-bottom, 0px));\n transform: translateX(-50%);\n width: calc(100% - 32px);\n max-width: 780px;\n padding: 10px 12px 10px 14px;\n border: 1px solid var(--border);\n border-radius: 22px;\n background: rgba(250, 248, 244, 0.92);\n box-shadow: 0 12px 36px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.04);\n backdrop-filter: blur(14px);\n -webkit-backdrop-filter: blur(14px);\n z-index: 20;\n }\n\n .composer-card .composer-copy { display: none; }\n\n .composer-form {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n\n .jump-latest-btn {\n position: fixed;\n left: 50%;\n bottom: calc(env(safe-area-inset-bottom, 0px) + 120px);\n z-index: 25;\n width: 42px;\n height: 42px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: var(--bg);\n color: var(--text);\n font: 700 1rem/1 'DM Sans', sans-serif;\n box-shadow: 0 10px 30px rgba(0,0,0,0.12);\n cursor: pointer;\n backdrop-filter: blur(10px);\n transform: translateX(-50%);\n outline: none;\n appearance: none;\n -webkit-tap-highlight-color: transparent;\n }\n\n .jump-latest-btn:hover {\n transform: translateX(-50%) translateY(-1px);\n background: #e8e3d9;\n }\n\n .jump-latest-btn:focus,\n .jump-latest-btn:active {\n outline: none;\n }\n\n .jump-latest-btn:focus-visible {\n box-shadow: 0 10px 30px rgba(0,0,0,0.12), 0 0 0 3px rgba(0,0,0,0.08);\n }\n\n .composer-copy { margin-bottom: 12px; color: var(--muted); }\n\n .composer-form textarea {\n width: 100%;\n resize: none;\n overflow-y: auto;\n min-height: 28px;\n max-height: 200px;\n padding: 6px 6px 2px;\n border: 0;\n border-radius: 0;\n font: inherit;\n color: var(--text);\n background: transparent;\n }\n\n .composer-form textarea::placeholder {\n color: rgba(63,63,70,0.55);\n }\n\n .composer-form textarea:focus {\n outline: none;\n border: 0;\n }\n\n .composer-actions {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n margin-top: 0;\n }\n\n .composer-status { color: var(--muted); font-size: 13px; }\n .composer-actions button:disabled { opacity: 0.55; cursor: wait; }\n\n .composer-send-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n border: none;\n border-radius: 999px;\n background: #d97706;\n color: #ffffff;\n font: 700 1rem/1 'DM Sans', sans-serif;\n cursor: pointer;\n box-shadow: 0 10px 24px rgba(217, 119, 6, 0.26);\n transition: transform 120ms, filter 120ms, box-shadow 120ms, background 120ms;\n }\n\n .composer-send-btn:hover:not(:disabled) {\n transform: translateY(-1px);\n filter: saturate(1.06) brightness(0.98);\n box-shadow: 0 12px 28px rgba(217, 119, 6, 0.32);\n }\n\n .composer-send-btn:focus-visible {\n outline: 2px solid rgba(217, 119, 6, 0.28);\n outline-offset: 3px;\n }\n\n .composer-send-btn:disabled {\n background: #d4d4d8;\n color: rgba(24, 24, 27, 0.45);\n box-shadow: none;\n transform: none;\n filter: none;\n cursor: not-allowed;\n opacity: 1;\n }\n\n /* ── Responsive ───────────────────────────────────────────────────────── */\n\n @media (max-width: 600px) {\n body { padding: 20px 12px calc(130px + env(safe-area-inset-bottom, 0px)); }\n\n .composer-card { width: calc(100% - 16px); bottom: calc(8px + env(safe-area-inset-bottom, 0px)); padding: 8px 10px; border-radius: 18px; }\n\n .hero-card, .card { padding: 20px; border-radius: 16px; }\n\n .hero-top { flex-direction: column; gap: 12px; }\n .hero-side { align-items: flex-start; }\n .hero-detail-row { gap: 8px; }\n\n .user-bubble,\n .msg-assistant,\n .tool-block { max-width: 100%; }\n\n .asst-avatar { display: none; }\n\n .asst-card { border-radius: 4px 14px 14px 14px; }\n }\n`;\n"]}