@geminixiang/mama 0.2.0-beta.7 → 0.2.0-beta.9

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 (140) hide show
  1. package/README.md +3 -5
  2. package/dist/adapter.d.ts +2 -2
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +1 -0
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +7 -4
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts +1 -1
  10. package/dist/adapters/discord/context.d.ts.map +1 -1
  11. package/dist/adapters/discord/context.js +1 -2
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/slack/bot.d.ts.map +1 -1
  14. package/dist/adapters/slack/bot.js +20 -6
  15. package/dist/adapters/slack/bot.js.map +1 -1
  16. package/dist/adapters/slack/branch-manager.d.ts +1 -0
  17. package/dist/adapters/slack/branch-manager.d.ts.map +1 -1
  18. package/dist/adapters/slack/branch-manager.js +9 -8
  19. package/dist/adapters/slack/branch-manager.js.map +1 -1
  20. package/dist/adapters/slack/context.d.ts +1 -1
  21. package/dist/adapters/slack/context.d.ts.map +1 -1
  22. package/dist/adapters/slack/context.js +10 -13
  23. package/dist/adapters/slack/context.js.map +1 -1
  24. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  25. package/dist/adapters/telegram/bot.js +2 -2
  26. package/dist/adapters/telegram/bot.js.map +1 -1
  27. package/dist/agent.d.ts +1 -2
  28. package/dist/agent.d.ts.map +1 -1
  29. package/dist/agent.js +477 -408
  30. package/dist/agent.js.map +1 -1
  31. package/dist/commands/login.d.ts.map +1 -1
  32. package/dist/commands/login.js +41 -2
  33. package/dist/commands/login.js.map +1 -1
  34. package/dist/commands/new.d.ts.map +1 -1
  35. package/dist/commands/new.js +1 -1
  36. package/dist/commands/new.js.map +1 -1
  37. package/dist/commands/sandbox.d.ts +1 -1
  38. package/dist/commands/sandbox.d.ts.map +1 -1
  39. package/dist/commands/sandbox.js +25 -2
  40. package/dist/commands/sandbox.js.map +1 -1
  41. package/dist/commands/session-view.d.ts.map +1 -1
  42. package/dist/commands/session-view.js +5 -1
  43. package/dist/commands/session-view.js.map +1 -1
  44. package/dist/commands/types.d.ts +1 -3
  45. package/dist/commands/types.d.ts.map +1 -1
  46. package/dist/commands/types.js.map +1 -1
  47. package/dist/config.d.ts +4 -0
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js +35 -23
  50. package/dist/config.js.map +1 -1
  51. package/dist/context.d.ts +2 -44
  52. package/dist/context.d.ts.map +1 -1
  53. package/dist/context.js +7 -225
  54. package/dist/context.js.map +1 -1
  55. package/dist/events.d.ts.map +1 -1
  56. package/dist/events.js +15 -14
  57. package/dist/events.js.map +1 -1
  58. package/dist/execution-resolver.d.ts +3 -2
  59. package/dist/execution-resolver.d.ts.map +1 -1
  60. package/dist/execution-resolver.js +40 -7
  61. package/dist/execution-resolver.js.map +1 -1
  62. package/dist/file-guards.d.ts +6 -0
  63. package/dist/file-guards.d.ts.map +1 -0
  64. package/dist/file-guards.js +48 -0
  65. package/dist/file-guards.js.map +1 -0
  66. package/dist/log.d.ts +1 -5
  67. package/dist/log.d.ts.map +1 -1
  68. package/dist/log.js +13 -38
  69. package/dist/log.js.map +1 -1
  70. package/dist/login/index.d.ts +14 -2
  71. package/dist/login/index.d.ts.map +1 -1
  72. package/dist/login/index.js +40 -13
  73. package/dist/login/index.js.map +1 -1
  74. package/dist/login/portal.d.ts +2 -1
  75. package/dist/login/portal.d.ts.map +1 -1
  76. package/dist/login/portal.js +12 -12
  77. package/dist/login/portal.js.map +1 -1
  78. package/dist/main.d.ts.map +1 -1
  79. package/dist/main.js +26 -27
  80. package/dist/main.js.map +1 -1
  81. package/dist/provisioner.d.ts +0 -2
  82. package/dist/provisioner.d.ts.map +1 -1
  83. package/dist/provisioner.js +2 -4
  84. package/dist/provisioner.js.map +1 -1
  85. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  86. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  87. package/dist/runtime/conversation-orchestrator.js +150 -0
  88. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  89. package/dist/runtime/session-runtime.d.ts +1 -1
  90. package/dist/runtime/session-runtime.d.ts.map +1 -1
  91. package/dist/runtime/session-runtime.js +49 -148
  92. package/dist/runtime/session-runtime.js.map +1 -1
  93. package/dist/sandbox/cloudflare.d.ts.map +1 -1
  94. package/dist/sandbox/cloudflare.js +2 -2
  95. package/dist/sandbox/cloudflare.js.map +1 -1
  96. package/dist/sandbox/container.d.ts.map +1 -1
  97. package/dist/sandbox/container.js +1 -1
  98. package/dist/sandbox/container.js.map +1 -1
  99. package/dist/sandbox/index.d.ts.map +1 -1
  100. package/dist/sandbox/index.js +4 -4
  101. package/dist/sandbox/index.js.map +1 -1
  102. package/dist/sentry.d.ts +1 -1
  103. package/dist/sentry.d.ts.map +1 -1
  104. package/dist/sentry.js +2 -2
  105. package/dist/sentry.js.map +1 -1
  106. package/dist/session-store.d.ts +1 -0
  107. package/dist/session-store.d.ts.map +1 -1
  108. package/dist/session-store.js +18 -14
  109. package/dist/session-store.js.map +1 -1
  110. package/dist/session-view/portal.d.ts +6 -1
  111. package/dist/session-view/portal.d.ts.map +1 -1
  112. package/dist/session-view/portal.js +1027 -89
  113. package/dist/session-view/portal.js.map +1 -1
  114. package/dist/session-view/service.d.ts.map +1 -1
  115. package/dist/session-view/service.js +4 -3
  116. package/dist/session-view/service.js.map +1 -1
  117. package/dist/session-view/store.d.ts +2 -1
  118. package/dist/session-view/store.d.ts.map +1 -1
  119. package/dist/session-view/store.js +2 -1
  120. package/dist/session-view/store.js.map +1 -1
  121. package/dist/store.d.ts.map +1 -1
  122. package/dist/store.js +7 -13
  123. package/dist/store.js.map +1 -1
  124. package/dist/tool-diagnostics.d.ts +2 -0
  125. package/dist/tool-diagnostics.d.ts.map +1 -0
  126. package/dist/tool-diagnostics.js +7 -0
  127. package/dist/tool-diagnostics.js.map +1 -0
  128. package/dist/vault-routing.d.ts +0 -3
  129. package/dist/vault-routing.d.ts.map +1 -1
  130. package/dist/vault-routing.js +0 -24
  131. package/dist/vault-routing.js.map +1 -1
  132. package/dist/vault.d.ts +21 -57
  133. package/dist/vault.d.ts.map +1 -1
  134. package/dist/vault.js +114 -246
  135. package/dist/vault.js.map +1 -1
  136. package/package.json +3 -1
  137. package/dist/bindings.d.ts +0 -45
  138. package/dist/bindings.d.ts.map +0 -1
  139. package/dist/bindings.js +0 -75
  140. package/dist/bindings.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAGjE,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,YAAY,WAAW,EAAE,MAAM,EAG9B;CACF;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,iCAAiC;IAChD,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAcD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAIjF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAS/D;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAQhF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,GACV,cAAc,CAShB;AAQD;;GAEG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAGvF;AAeD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEnF;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,oBAAoB,CAoBtB;AA4HD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI1E;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE1E;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE3E;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,iBAAiB,EAAE,MAAM,EACzB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,GACV,MAAM,CAWR;AAED,wBAAgB,sCAAsC,CACpD,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,iBAAiB,EAC9B,aAAa,CAAC,EAAE,MAAM,GACrB,MAAM,CAiCR;AAED,wBAAgB,oCAAoC,CAClD,iBAAiB,EAAE,MAAM,EACzB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,iBAAiB,GAC7B,MAAM,CAqBR","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@earendil-works/pi-coding-agent\";\nimport { atomicWritePrivateFile } from \"./fs-atomic.js\";\n\nexport class ThreadRootNotFoundError extends Error {\n constructor(sessionFile: string) {\n super(`Thread root message not found in source session: ${sessionFile}`);\n this.name = \"ThreadRootNotFoundError\";\n }\n}\n\nexport interface ThreadRootMessage {\n text?: string;\n userName?: string;\n user?: string;\n loggedAt?: number;\n}\n\nexport interface ResolvedSessionScope {\n sessionDir: string;\n contextFile: string;\n threadRootMessage: ThreadRootMessage | null;\n}\n\nexport interface ResolveGenericSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n}\n\ninterface SessionMessageEntryLike {\n type: string;\n id: string;\n parentId: string | null;\n timestamp: string;\n message?: {\n role?: string;\n timestamp?: number;\n content?: Array<{ type?: string; text?: string }> | string;\n };\n}\n\n/**\n * Returns the shared session directory for a conversation.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n return sessionKey.includes(\":\") ? sessionKey.split(\":\").pop()! : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n *\n * Order matters: write the session file first, then atomic-rename the pointer\n * last so a crash mid-create never leaves \"current\" pointing at a missing file.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n atomicWritePrivateFile(filePath, \"\");\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer. Header is written before the pointer flips so a\n * partial create cannot leave \"current\" pointing at a missing file.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n if (shouldRecreatePreinitializedSession(sessionFile)) {\n rmSync(sessionFile, { force: true });\n }\n\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n atomicWritePrivateFile(sessionFile, `${JSON.stringify(header)}\\n`);\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\n/**\n * Resolve the default session scope for platforms without Slack-style branch forking.\n * Top-level/private sessions use the conversation's current pointer. Threaded or\n * per-message sessions use a fixed file derived from the session key suffix.\n */\nexport function resolveGenericSessionScope(\n options: ResolveGenericSessionScopeOptions,\n): ResolvedSessionScope {\n const { conversationDir, sessionKey } = options;\n const sessionDir = getChannelSessionDir(conversationDir);\n\n if (!sessionKey.includes(\":\")) {\n return {\n sessionDir,\n contextFile: resolveManagedSessionFile(sessionDir, conversationDir),\n threadRootMessage: null,\n };\n }\n\n const threadFile = getThreadSessionFile(conversationDir, sessionKey);\n return {\n sessionDir,\n contextFile:\n tryResolveThreadSession(threadFile) ??\n createManagedSessionFileAtPath(threadFile, conversationDir),\n threadRootMessage: null,\n };\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const lines = readFileSync(sessionFile, \"utf-8\").split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = JSON.parse(trimmed) as { type?: string };\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction shouldRecreatePreinitializedSession(sessionFile: string): boolean {\n if (!existsSync(sessionFile)) return false;\n\n try {\n const entries = readFileSync(sessionFile, \"utf-8\")\n .split(\"\\n\")\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => JSON.parse(line) as { type?: string });\n\n return entries.length === 1 && entries[0]?.type === \"session\";\n } catch {\n return false;\n }\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction resolveThreadSnapshotEntries(\n sourceSessionFile: string,\n rootMessage: ThreadRootMessage,\n): SessionMessageEntryLike[] | null {\n const targetText = buildComparableRootMessageText(rootMessage);\n if (!targetText) return null;\n\n const entries = SessionManager.open(sourceSessionFile).getEntries() as SessionMessageEntryLike[];\n const matchIndex = findRootMessageIndex(entries, targetText, rootMessage.loggedAt);\n if (matchIndex === -1) return null;\n\n const nextTopLevelUserIndex = entries.findIndex(\n (entry, index) => index > matchIndex && isUserMessageEntry(entry),\n );\n const endIndex = nextTopLevelUserIndex === -1 ? entries.length : nextTopLevelUserIndex;\n return entries.slice(0, endIndex);\n}\n\nfunction findRootMessageIndex(\n entries: SessionMessageEntryLike[],\n targetText: string,\n loggedAt?: number,\n): number {\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (!isUserMessageEntry(entry)) continue;\n\n const comparableText = normalizeComparableUserText(getMessageText(entry));\n if (comparableText !== targetText) continue;\n\n const messageTimestamp = entry.message?.timestamp;\n if (\n loggedAt !== undefined &&\n typeof messageTimestamp === \"number\" &&\n messageTimestamp < loggedAt\n ) {\n continue;\n }\n\n return i;\n }\n\n return -1;\n}\n\nfunction isUserMessageEntry(entry: SessionMessageEntryLike): boolean {\n return entry.type === \"message\" && entry.message?.role === \"user\";\n}\n\nfunction getMessageText(entry: SessionMessageEntryLike): string {\n const content = entry.message?.content;\n if (typeof content === \"string\") return content;\n if (!Array.isArray(content)) return \"\";\n\n return content\n .filter((part): part is { type?: string; text?: string } => part.type === \"text\")\n .map((part) => part.text ?? \"\")\n .join(\"\\n\\n\");\n}\n\nfunction buildComparableRootMessageText(rootMessage: ThreadRootMessage): string | null {\n const userLabel = rootMessage.userName || rootMessage.user || \"unknown\";\n const text = rootMessage.text?.trim();\n if (!text) return null;\n return normalizeComparableUserText(`[${userLabel}]: ${text}`);\n}\n\nfunction stripSlackAttachmentBlock(text: string): string {\n return text.replace(/\\n*<slack_attachments>\\n[\\s\\S]*?\\n<\\/slack_attachments>\\s*$/g, \"\");\n}\n\nfunction normalizeComparableUserText(text: string): string {\n const withoutTimestamp = text.replace(\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)/,\n \"\",\n );\n return stripSlackAttachmentBlock(withoutTimestamp).trim();\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n if (!existsSync(pointerFile)) return null;\n const filename = readFileSync(pointerFile, \"utf-8\").trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const fullPath = getCurrentSessionPath(sessionDir);\n if (fullPath && existsSync(fullPath) && hasSessionHeader(fullPath)) return fullPath;\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n return tryResolveCurrentSession(getChannelSessionDir(channelDir));\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n\nexport function createThreadSessionFileFromRootMessage(\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n parentSession?: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n ...(parentSession ? { parentSession } : {}),\n };\n const rootText = buildComparableRootMessageText(rootMessage);\n if (!rootText) {\n atomicWritePrivateFile(targetSessionFile, `${JSON.stringify(header)}\\n`);\n return targetSessionFile;\n }\n\n const rootEntry = {\n type: \"message\",\n id: randomUUID().slice(0, 8),\n parentId: null,\n timestamp: new Date().toISOString(),\n message: {\n role: \"user\",\n content: [{ type: \"text\", text: rootText }],\n ...(rootMessage.loggedAt !== undefined ? { timestamp: rootMessage.loggedAt } : {}),\n },\n };\n const content = [header, rootEntry].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n\nexport function forkThreadSessionFileFromRootMessage(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n): string {\n const snapshotEntries = resolveThreadSnapshotEntries(sourceSessionFile, rootMessage);\n if (!snapshotEntries) {\n throw new ThreadRootNotFoundError(sourceSessionFile);\n }\n\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n parentSession: sourceSessionFile,\n };\n const content = [header, ...snapshotEntries].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n"]}
1
+ {"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAIjE,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,YAAY,WAAW,EAAE,MAAM,EAG9B;CACF;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,iCAAiC;IAChD,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAcD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAIjF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAG/D;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAS/D;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAQhF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,GACV,cAAc,CAShB;AAQD;;GAEG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAGvF;AAeD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEnF;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,oBAAoB,CAoBtB;AAuID;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI1E;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE1E;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE3E;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,iBAAiB,EAAE,MAAM,EACzB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,GACV,MAAM,CAWR;AAED,wBAAgB,sCAAsC,CACpD,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,iBAAiB,EAC9B,aAAa,CAAC,EAAE,MAAM,GACrB,MAAM,CAiCR;AAED,wBAAgB,oCAAoC,CAClD,iBAAiB,EAAE,MAAM,EACzB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,iBAAiB,GAC7B,MAAM,CAqBR","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, renameSync, rmSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@earendil-works/pi-coding-agent\";\nimport { isRecord, parseJsonValue, readTextFileIfExists } from \"./file-guards.js\";\nimport { atomicWritePrivateFile } from \"./fs-atomic.js\";\n\nexport class ThreadRootNotFoundError extends Error {\n constructor(sessionFile: string) {\n super(`Thread root message not found in source session: ${sessionFile}`);\n this.name = \"ThreadRootNotFoundError\";\n }\n}\n\nexport interface ThreadRootMessage {\n text?: string;\n userName?: string;\n user?: string;\n loggedAt?: number;\n}\n\nexport interface ResolvedSessionScope {\n sessionDir: string;\n contextFile: string;\n threadRootMessage: ThreadRootMessage | null;\n}\n\nexport interface ResolveGenericSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n cwd?: string;\n}\n\ninterface SessionMessageEntryLike {\n type: string;\n id: string;\n parentId: string | null;\n timestamp: string;\n message?: {\n role?: string;\n timestamp?: number;\n content?: Array<{ type?: string; text?: string }> | string;\n };\n}\n\n/**\n * Returns the shared session directory for a conversation.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n const parts = sessionKey.split(\":\");\n return parts.length > 1 ? parts[parts.length - 1] : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n *\n * Order matters: write the session file first, then atomic-rename the pointer\n * last so a crash mid-create never leaves \"current\" pointing at a missing file.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n atomicWritePrivateFile(filePath, \"\");\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer. Header is written before the pointer flips so a\n * partial create cannot leave \"current\" pointing at a missing file.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n if (shouldRecreatePreinitializedSession(sessionFile)) {\n rmSync(sessionFile, { force: true });\n }\n\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n atomicWritePrivateFile(sessionFile, `${JSON.stringify(header)}\\n`);\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\n/**\n * Resolve the default session scope for platforms without Slack-style branch forking.\n * Top-level/private sessions use the conversation's current pointer. Threaded or\n * per-message sessions use a fixed file derived from the session key suffix.\n */\nexport function resolveGenericSessionScope(\n options: ResolveGenericSessionScopeOptions,\n): ResolvedSessionScope {\n const { conversationDir, sessionKey } = options;\n const cwd = options.cwd ?? conversationDir;\n const sessionDir = getChannelSessionDir(conversationDir);\n\n if (!sessionKey.includes(\":\")) {\n return {\n sessionDir,\n contextFile: resolveManagedSessionFile(sessionDir, cwd),\n threadRootMessage: null,\n };\n }\n\n const threadFile = getThreadSessionFile(conversationDir, sessionKey);\n return {\n sessionDir,\n contextFile:\n tryResolveThreadSession(threadFile) ?? createManagedSessionFileAtPath(threadFile, cwd),\n threadRootMessage: null,\n };\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const raw = readTextFileIfExists(sessionFile);\n if (raw === undefined) return false;\n const lines = raw.split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = parseJsonValue(\n trimmed,\n (value): value is { type?: string } => isRecord(value),\n (detail) => (detail === \"unexpected JSON shape\" ? \"expected a JSON object\" : detail),\n );\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction shouldRecreatePreinitializedSession(sessionFile: string): boolean {\n try {\n const raw = readTextFileIfExists(sessionFile);\n if (raw === undefined) return false;\n const entries = raw\n .split(\"\\n\")\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) =>\n parseJsonValue(\n line,\n (value): value is { type?: string } => isRecord(value),\n (detail) => (detail === \"unexpected JSON shape\" ? \"expected a JSON object\" : detail),\n ),\n );\n\n return entries.length === 1 && entries[0]?.type === \"session\";\n } catch {\n return false;\n }\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction resolveThreadSnapshotEntries(\n sourceSessionFile: string,\n rootMessage: ThreadRootMessage,\n): SessionMessageEntryLike[] | null {\n const targetText = buildComparableRootMessageText(rootMessage);\n if (!targetText) return null;\n\n const entries = SessionManager.open(sourceSessionFile).getEntries() as SessionMessageEntryLike[];\n const matchIndex = findRootMessageIndex(entries, targetText, rootMessage.loggedAt);\n if (matchIndex === -1) return null;\n\n const nextTopLevelUserIndex = entries.findIndex(\n (entry, index) => index > matchIndex && isUserMessageEntry(entry),\n );\n const endIndex = nextTopLevelUserIndex === -1 ? entries.length : nextTopLevelUserIndex;\n return entries.slice(0, endIndex);\n}\n\nfunction findRootMessageIndex(\n entries: SessionMessageEntryLike[],\n targetText: string,\n loggedAt?: number,\n): number {\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (!isUserMessageEntry(entry)) continue;\n\n const comparableText = normalizeComparableUserText(getMessageText(entry));\n if (comparableText !== targetText) continue;\n\n const messageTimestamp = entry.message?.timestamp;\n if (\n loggedAt !== undefined &&\n typeof messageTimestamp === \"number\" &&\n messageTimestamp < loggedAt\n ) {\n continue;\n }\n\n return i;\n }\n\n return -1;\n}\n\nfunction isUserMessageEntry(entry: SessionMessageEntryLike): boolean {\n return entry.type === \"message\" && entry.message?.role === \"user\";\n}\n\nfunction getMessageText(entry: SessionMessageEntryLike): string {\n const content = entry.message?.content;\n if (typeof content === \"string\") return content;\n if (!Array.isArray(content)) return \"\";\n\n return content\n .filter((part): part is { type?: string; text?: string } => part.type === \"text\")\n .map((part) => part.text ?? \"\")\n .join(\"\\n\\n\");\n}\n\nfunction buildComparableRootMessageText(rootMessage: ThreadRootMessage): string | null {\n const userLabel = rootMessage.userName || rootMessage.user || \"unknown\";\n const text = rootMessage.text?.trim();\n if (!text) return null;\n return normalizeComparableUserText(`[${userLabel}]: ${text}`);\n}\n\nfunction stripSlackAttachmentBlock(text: string): string {\n return text.replace(/\\n*<slack_attachments>\\n[\\s\\S]*?\\n<\\/slack_attachments>\\s*$/g, \"\");\n}\n\nfunction normalizeComparableUserText(text: string): string {\n const withoutTimestamp = text.replace(\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)/,\n \"\",\n );\n return stripSlackAttachmentBlock(withoutTimestamp).trim();\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n const filename = readTextFileIfExists(pointerFile)?.trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const fullPath = getCurrentSessionPath(sessionDir);\n if (fullPath && existsSync(fullPath) && hasSessionHeader(fullPath)) return fullPath;\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n return tryResolveCurrentSession(getChannelSessionDir(channelDir));\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n\nexport function createThreadSessionFileFromRootMessage(\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n parentSession?: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n ...(parentSession ? { parentSession } : {}),\n };\n const rootText = buildComparableRootMessageText(rootMessage);\n if (!rootText) {\n atomicWritePrivateFile(targetSessionFile, `${JSON.stringify(header)}\\n`);\n return targetSessionFile;\n }\n\n const rootEntry = {\n type: \"message\",\n id: randomUUID().slice(0, 8),\n parentId: null,\n timestamp: new Date().toISOString(),\n message: {\n role: \"user\",\n content: [{ type: \"text\", text: rootText }],\n ...(rootMessage.loggedAt !== undefined ? { timestamp: rootMessage.loggedAt } : {}),\n },\n };\n const content = [header, rootEntry].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n\nexport function forkThreadSessionFileFromRootMessage(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n): string {\n const snapshotEntries = resolveThreadSnapshotEntries(sourceSessionFile, rootMessage);\n if (!snapshotEntries) {\n throw new ThreadRootNotFoundError(sourceSessionFile);\n }\n\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n parentSession: sourceSessionFile,\n };\n const content = [header, ...snapshotEntries].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n"]}
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "fs";
2
+ import { existsSync, mkdirSync, renameSync, rmSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import { isRecord, parseJsonValue, readTextFileIfExists } from "./file-guards.js";
5
6
  import { atomicWritePrivateFile } from "./fs-atomic.js";
6
7
  export class ThreadRootNotFoundError extends Error {
7
8
  constructor(sessionFile) {
@@ -51,7 +52,8 @@ export function extractSessionUuid(sessionFile) {
51
52
  * "channelId:threadId" → "threadId", "channelId" → "channelId"
52
53
  */
53
54
  export function extractSessionSuffix(sessionKey) {
54
- return sessionKey.includes(":") ? sessionKey.split(":").pop() : sessionKey;
55
+ const parts = sessionKey.split(":");
56
+ return parts.length > 1 ? parts[parts.length - 1] : sessionKey;
55
57
  }
56
58
  /**
57
59
  * Creates an empty timestamped file and updates the "current" pointer.
@@ -132,30 +134,33 @@ export function getThreadSessionFile(channelDir, sessionKey) {
132
134
  */
133
135
  export function resolveGenericSessionScope(options) {
134
136
  const { conversationDir, sessionKey } = options;
137
+ const cwd = options.cwd ?? conversationDir;
135
138
  const sessionDir = getChannelSessionDir(conversationDir);
136
139
  if (!sessionKey.includes(":")) {
137
140
  return {
138
141
  sessionDir,
139
- contextFile: resolveManagedSessionFile(sessionDir, conversationDir),
142
+ contextFile: resolveManagedSessionFile(sessionDir, cwd),
140
143
  threadRootMessage: null,
141
144
  };
142
145
  }
143
146
  const threadFile = getThreadSessionFile(conversationDir, sessionKey);
144
147
  return {
145
148
  sessionDir,
146
- contextFile: tryResolveThreadSession(threadFile) ??
147
- createManagedSessionFileAtPath(threadFile, conversationDir),
149
+ contextFile: tryResolveThreadSession(threadFile) ?? createManagedSessionFileAtPath(threadFile, cwd),
148
150
  threadRootMessage: null,
149
151
  };
150
152
  }
151
153
  function hasSessionHeader(sessionFile) {
152
154
  try {
153
- const lines = readFileSync(sessionFile, "utf-8").split("\n");
155
+ const raw = readTextFileIfExists(sessionFile);
156
+ if (raw === undefined)
157
+ return false;
158
+ const lines = raw.split("\n");
154
159
  for (const line of lines) {
155
160
  const trimmed = line.trim();
156
161
  if (!trimmed)
157
162
  continue;
158
- const entry = JSON.parse(trimmed);
163
+ const entry = parseJsonValue(trimmed, (value) => isRecord(value), (detail) => (detail === "unexpected JSON shape" ? "expected a JSON object" : detail));
159
164
  return entry.type === "session";
160
165
  }
161
166
  }
@@ -165,14 +170,15 @@ function hasSessionHeader(sessionFile) {
165
170
  return false;
166
171
  }
167
172
  function shouldRecreatePreinitializedSession(sessionFile) {
168
- if (!existsSync(sessionFile))
169
- return false;
170
173
  try {
171
- const entries = readFileSync(sessionFile, "utf-8")
174
+ const raw = readTextFileIfExists(sessionFile);
175
+ if (raw === undefined)
176
+ return false;
177
+ const entries = raw
172
178
  .split("\n")
173
179
  .map((line) => line.trim())
174
180
  .filter(Boolean)
175
- .map((line) => JSON.parse(line));
181
+ .map((line) => parseJsonValue(line, (value) => isRecord(value), (detail) => (detail === "unexpected JSON shape" ? "expected a JSON object" : detail)));
176
182
  return entries.length === 1 && entries[0]?.type === "session";
177
183
  }
178
184
  catch {
@@ -242,9 +248,7 @@ function normalizeComparableUserText(text) {
242
248
  }
243
249
  function getCurrentSessionPath(sessionDir) {
244
250
  const pointerFile = join(sessionDir, "current");
245
- if (!existsSync(pointerFile))
246
- return null;
247
- const filename = readFileSync(pointerFile, "utf-8").trim();
251
+ const filename = readTextFileIfExists(pointerFile)?.trim();
248
252
  if (!filename)
249
253
  return null;
250
254
  return join(sessionDir, filename);
@@ -1 +1 @@
1
- {"version":3,"file":"session-store.js","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@earendil-works/pi-coding-agent\";\nimport { atomicWritePrivateFile } from \"./fs-atomic.js\";\n\nexport class ThreadRootNotFoundError extends Error {\n constructor(sessionFile: string) {\n super(`Thread root message not found in source session: ${sessionFile}`);\n this.name = \"ThreadRootNotFoundError\";\n }\n}\n\nexport interface ThreadRootMessage {\n text?: string;\n userName?: string;\n user?: string;\n loggedAt?: number;\n}\n\nexport interface ResolvedSessionScope {\n sessionDir: string;\n contextFile: string;\n threadRootMessage: ThreadRootMessage | null;\n}\n\nexport interface ResolveGenericSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n}\n\ninterface SessionMessageEntryLike {\n type: string;\n id: string;\n parentId: string | null;\n timestamp: string;\n message?: {\n role?: string;\n timestamp?: number;\n content?: Array<{ type?: string; text?: string }> | string;\n };\n}\n\n/**\n * Returns the shared session directory for a conversation.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n return sessionKey.includes(\":\") ? sessionKey.split(\":\").pop()! : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n *\n * Order matters: write the session file first, then atomic-rename the pointer\n * last so a crash mid-create never leaves \"current\" pointing at a missing file.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n atomicWritePrivateFile(filePath, \"\");\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer. Header is written before the pointer flips so a\n * partial create cannot leave \"current\" pointing at a missing file.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n if (shouldRecreatePreinitializedSession(sessionFile)) {\n rmSync(sessionFile, { force: true });\n }\n\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n atomicWritePrivateFile(sessionFile, `${JSON.stringify(header)}\\n`);\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\n/**\n * Resolve the default session scope for platforms without Slack-style branch forking.\n * Top-level/private sessions use the conversation's current pointer. Threaded or\n * per-message sessions use a fixed file derived from the session key suffix.\n */\nexport function resolveGenericSessionScope(\n options: ResolveGenericSessionScopeOptions,\n): ResolvedSessionScope {\n const { conversationDir, sessionKey } = options;\n const sessionDir = getChannelSessionDir(conversationDir);\n\n if (!sessionKey.includes(\":\")) {\n return {\n sessionDir,\n contextFile: resolveManagedSessionFile(sessionDir, conversationDir),\n threadRootMessage: null,\n };\n }\n\n const threadFile = getThreadSessionFile(conversationDir, sessionKey);\n return {\n sessionDir,\n contextFile:\n tryResolveThreadSession(threadFile) ??\n createManagedSessionFileAtPath(threadFile, conversationDir),\n threadRootMessage: null,\n };\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const lines = readFileSync(sessionFile, \"utf-8\").split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = JSON.parse(trimmed) as { type?: string };\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction shouldRecreatePreinitializedSession(sessionFile: string): boolean {\n if (!existsSync(sessionFile)) return false;\n\n try {\n const entries = readFileSync(sessionFile, \"utf-8\")\n .split(\"\\n\")\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => JSON.parse(line) as { type?: string });\n\n return entries.length === 1 && entries[0]?.type === \"session\";\n } catch {\n return false;\n }\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction resolveThreadSnapshotEntries(\n sourceSessionFile: string,\n rootMessage: ThreadRootMessage,\n): SessionMessageEntryLike[] | null {\n const targetText = buildComparableRootMessageText(rootMessage);\n if (!targetText) return null;\n\n const entries = SessionManager.open(sourceSessionFile).getEntries() as SessionMessageEntryLike[];\n const matchIndex = findRootMessageIndex(entries, targetText, rootMessage.loggedAt);\n if (matchIndex === -1) return null;\n\n const nextTopLevelUserIndex = entries.findIndex(\n (entry, index) => index > matchIndex && isUserMessageEntry(entry),\n );\n const endIndex = nextTopLevelUserIndex === -1 ? entries.length : nextTopLevelUserIndex;\n return entries.slice(0, endIndex);\n}\n\nfunction findRootMessageIndex(\n entries: SessionMessageEntryLike[],\n targetText: string,\n loggedAt?: number,\n): number {\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (!isUserMessageEntry(entry)) continue;\n\n const comparableText = normalizeComparableUserText(getMessageText(entry));\n if (comparableText !== targetText) continue;\n\n const messageTimestamp = entry.message?.timestamp;\n if (\n loggedAt !== undefined &&\n typeof messageTimestamp === \"number\" &&\n messageTimestamp < loggedAt\n ) {\n continue;\n }\n\n return i;\n }\n\n return -1;\n}\n\nfunction isUserMessageEntry(entry: SessionMessageEntryLike): boolean {\n return entry.type === \"message\" && entry.message?.role === \"user\";\n}\n\nfunction getMessageText(entry: SessionMessageEntryLike): string {\n const content = entry.message?.content;\n if (typeof content === \"string\") return content;\n if (!Array.isArray(content)) return \"\";\n\n return content\n .filter((part): part is { type?: string; text?: string } => part.type === \"text\")\n .map((part) => part.text ?? \"\")\n .join(\"\\n\\n\");\n}\n\nfunction buildComparableRootMessageText(rootMessage: ThreadRootMessage): string | null {\n const userLabel = rootMessage.userName || rootMessage.user || \"unknown\";\n const text = rootMessage.text?.trim();\n if (!text) return null;\n return normalizeComparableUserText(`[${userLabel}]: ${text}`);\n}\n\nfunction stripSlackAttachmentBlock(text: string): string {\n return text.replace(/\\n*<slack_attachments>\\n[\\s\\S]*?\\n<\\/slack_attachments>\\s*$/g, \"\");\n}\n\nfunction normalizeComparableUserText(text: string): string {\n const withoutTimestamp = text.replace(\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)/,\n \"\",\n );\n return stripSlackAttachmentBlock(withoutTimestamp).trim();\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n if (!existsSync(pointerFile)) return null;\n const filename = readFileSync(pointerFile, \"utf-8\").trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const fullPath = getCurrentSessionPath(sessionDir);\n if (fullPath && existsSync(fullPath) && hasSessionHeader(fullPath)) return fullPath;\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n return tryResolveCurrentSession(getChannelSessionDir(channelDir));\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n\nexport function createThreadSessionFileFromRootMessage(\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n parentSession?: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n ...(parentSession ? { parentSession } : {}),\n };\n const rootText = buildComparableRootMessageText(rootMessage);\n if (!rootText) {\n atomicWritePrivateFile(targetSessionFile, `${JSON.stringify(header)}\\n`);\n return targetSessionFile;\n }\n\n const rootEntry = {\n type: \"message\",\n id: randomUUID().slice(0, 8),\n parentId: null,\n timestamp: new Date().toISOString(),\n message: {\n role: \"user\",\n content: [{ type: \"text\", text: rootText }],\n ...(rootMessage.loggedAt !== undefined ? { timestamp: rootMessage.loggedAt } : {}),\n },\n };\n const content = [header, rootEntry].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n\nexport function forkThreadSessionFileFromRootMessage(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n): string {\n const snapshotEntries = resolveThreadSnapshotEntries(sourceSessionFile, rootMessage);\n if (!snapshotEntries) {\n throw new ThreadRootNotFoundError(sourceSessionFile);\n }\n\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n parentSession: sourceSessionFile,\n };\n const content = [header, ...snapshotEntries].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n"]}
1
+ {"version":3,"file":"session-store.js","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, renameSync, rmSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@earendil-works/pi-coding-agent\";\nimport { isRecord, parseJsonValue, readTextFileIfExists } from \"./file-guards.js\";\nimport { atomicWritePrivateFile } from \"./fs-atomic.js\";\n\nexport class ThreadRootNotFoundError extends Error {\n constructor(sessionFile: string) {\n super(`Thread root message not found in source session: ${sessionFile}`);\n this.name = \"ThreadRootNotFoundError\";\n }\n}\n\nexport interface ThreadRootMessage {\n text?: string;\n userName?: string;\n user?: string;\n loggedAt?: number;\n}\n\nexport interface ResolvedSessionScope {\n sessionDir: string;\n contextFile: string;\n threadRootMessage: ThreadRootMessage | null;\n}\n\nexport interface ResolveGenericSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n cwd?: string;\n}\n\ninterface SessionMessageEntryLike {\n type: string;\n id: string;\n parentId: string | null;\n timestamp: string;\n message?: {\n role?: string;\n timestamp?: number;\n content?: Array<{ type?: string; text?: string }> | string;\n };\n}\n\n/**\n * Returns the shared session directory for a conversation.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n const parts = sessionKey.split(\":\");\n return parts.length > 1 ? parts[parts.length - 1] : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n *\n * Order matters: write the session file first, then atomic-rename the pointer\n * last so a crash mid-create never leaves \"current\" pointing at a missing file.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n atomicWritePrivateFile(filePath, \"\");\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer. Header is written before the pointer flips so a\n * partial create cannot leave \"current\" pointing at a missing file.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n if (shouldRecreatePreinitializedSession(sessionFile)) {\n rmSync(sessionFile, { force: true });\n }\n\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n atomicWritePrivateFile(join(sessionDir, \"current\"), filename);\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n atomicWritePrivateFile(sessionFile, `${JSON.stringify(header)}\\n`);\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\n/**\n * Resolve the default session scope for platforms without Slack-style branch forking.\n * Top-level/private sessions use the conversation's current pointer. Threaded or\n * per-message sessions use a fixed file derived from the session key suffix.\n */\nexport function resolveGenericSessionScope(\n options: ResolveGenericSessionScopeOptions,\n): ResolvedSessionScope {\n const { conversationDir, sessionKey } = options;\n const cwd = options.cwd ?? conversationDir;\n const sessionDir = getChannelSessionDir(conversationDir);\n\n if (!sessionKey.includes(\":\")) {\n return {\n sessionDir,\n contextFile: resolveManagedSessionFile(sessionDir, cwd),\n threadRootMessage: null,\n };\n }\n\n const threadFile = getThreadSessionFile(conversationDir, sessionKey);\n return {\n sessionDir,\n contextFile:\n tryResolveThreadSession(threadFile) ?? createManagedSessionFileAtPath(threadFile, cwd),\n threadRootMessage: null,\n };\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const raw = readTextFileIfExists(sessionFile);\n if (raw === undefined) return false;\n const lines = raw.split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = parseJsonValue(\n trimmed,\n (value): value is { type?: string } => isRecord(value),\n (detail) => (detail === \"unexpected JSON shape\" ? \"expected a JSON object\" : detail),\n );\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction shouldRecreatePreinitializedSession(sessionFile: string): boolean {\n try {\n const raw = readTextFileIfExists(sessionFile);\n if (raw === undefined) return false;\n const entries = raw\n .split(\"\\n\")\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) =>\n parseJsonValue(\n line,\n (value): value is { type?: string } => isRecord(value),\n (detail) => (detail === \"unexpected JSON shape\" ? \"expected a JSON object\" : detail),\n ),\n );\n\n return entries.length === 1 && entries[0]?.type === \"session\";\n } catch {\n return false;\n }\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction resolveThreadSnapshotEntries(\n sourceSessionFile: string,\n rootMessage: ThreadRootMessage,\n): SessionMessageEntryLike[] | null {\n const targetText = buildComparableRootMessageText(rootMessage);\n if (!targetText) return null;\n\n const entries = SessionManager.open(sourceSessionFile).getEntries() as SessionMessageEntryLike[];\n const matchIndex = findRootMessageIndex(entries, targetText, rootMessage.loggedAt);\n if (matchIndex === -1) return null;\n\n const nextTopLevelUserIndex = entries.findIndex(\n (entry, index) => index > matchIndex && isUserMessageEntry(entry),\n );\n const endIndex = nextTopLevelUserIndex === -1 ? entries.length : nextTopLevelUserIndex;\n return entries.slice(0, endIndex);\n}\n\nfunction findRootMessageIndex(\n entries: SessionMessageEntryLike[],\n targetText: string,\n loggedAt?: number,\n): number {\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (!isUserMessageEntry(entry)) continue;\n\n const comparableText = normalizeComparableUserText(getMessageText(entry));\n if (comparableText !== targetText) continue;\n\n const messageTimestamp = entry.message?.timestamp;\n if (\n loggedAt !== undefined &&\n typeof messageTimestamp === \"number\" &&\n messageTimestamp < loggedAt\n ) {\n continue;\n }\n\n return i;\n }\n\n return -1;\n}\n\nfunction isUserMessageEntry(entry: SessionMessageEntryLike): boolean {\n return entry.type === \"message\" && entry.message?.role === \"user\";\n}\n\nfunction getMessageText(entry: SessionMessageEntryLike): string {\n const content = entry.message?.content;\n if (typeof content === \"string\") return content;\n if (!Array.isArray(content)) return \"\";\n\n return content\n .filter((part): part is { type?: string; text?: string } => part.type === \"text\")\n .map((part) => part.text ?? \"\")\n .join(\"\\n\\n\");\n}\n\nfunction buildComparableRootMessageText(rootMessage: ThreadRootMessage): string | null {\n const userLabel = rootMessage.userName || rootMessage.user || \"unknown\";\n const text = rootMessage.text?.trim();\n if (!text) return null;\n return normalizeComparableUserText(`[${userLabel}]: ${text}`);\n}\n\nfunction stripSlackAttachmentBlock(text: string): string {\n return text.replace(/\\n*<slack_attachments>\\n[\\s\\S]*?\\n<\\/slack_attachments>\\s*$/g, \"\");\n}\n\nfunction normalizeComparableUserText(text: string): string {\n const withoutTimestamp = text.replace(\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)/,\n \"\",\n );\n return stripSlackAttachmentBlock(withoutTimestamp).trim();\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n const filename = readTextFileIfExists(pointerFile)?.trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const fullPath = getCurrentSessionPath(sessionDir);\n if (fullPath && existsSync(fullPath) && hasSessionHeader(fullPath)) return fullPath;\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n return tryResolveCurrentSession(getChannelSessionDir(channelDir));\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n\nexport function createThreadSessionFileFromRootMessage(\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n parentSession?: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n ...(parentSession ? { parentSession } : {}),\n };\n const rootText = buildComparableRootMessageText(rootMessage);\n if (!rootText) {\n atomicWritePrivateFile(targetSessionFile, `${JSON.stringify(header)}\\n`);\n return targetSessionFile;\n }\n\n const rootEntry = {\n type: \"message\",\n id: randomUUID().slice(0, 8),\n parentId: null,\n timestamp: new Date().toISOString(),\n message: {\n role: \"user\",\n content: [{ type: \"text\", text: rootText }],\n ...(rootMessage.loggedAt !== undefined ? { timestamp: rootMessage.loggedAt } : {}),\n },\n };\n const content = [header, rootEntry].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n\nexport function forkThreadSessionFileFromRootMessage(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n): string {\n const snapshotEntries = resolveThreadSnapshotEntries(sourceSessionFile, rootMessage);\n if (!snapshotEntries) {\n throw new ThreadRootNotFoundError(sourceSessionFile);\n }\n\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n parentSession: sourceSessionFile,\n };\n const content = [header, ...snapshotEntries].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n atomicWritePrivateFile(targetSessionFile, `${content}\\n`);\n return targetSessionFile;\n}\n"]}
@@ -1,6 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { Bot, BotHandler } from "../adapter.js";
2
3
  import type { InMemorySessionViewTokenStore } from "./store.js";
3
- export declare function handleSessionViewRequest(req: IncomingMessage, res: ServerResponse, url: URL, sessionViewTokenStore?: InMemorySessionViewTokenStore): Promise<boolean>;
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>;
4
9
  export declare function parseUserBody(raw: string): {
5
10
  timestamp: string | null;
6
11
  username: string | null;
@@ -1 +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;AAQ5D,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,YAAY,CAAC;AAEhE,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,GAAG,EACR,qBAAqB,CAAC,EAAE,6BAA6B,GACpD,OAAO,CAAC,OAAO,CAAC,CA6DlB;AA4FD,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 * as log from \"../log.js\";\nimport {\n loadSessionViewModel,\n resolveRequestedSessionFile,\n type SessionViewItem,\n type SessionViewRelation,\n} from \"./service.js\";\nimport type { InMemorySessionViewTokenStore } from \"./store.js\";\n\nexport async function handleSessionViewRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n sessionViewTokenStore?: InMemorySessionViewTokenStore,\n): Promise<boolean> {\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 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 res.writeHead(200, {\n \"Content-Type\": \"text/html; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(renderSessionPage(model, entry.token, entry.expiresAt));\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 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 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): string {\n const items =\n model.items.length > 0\n ? model.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 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\">mama</span>\n <h1 class=\"hero-title\">${esc(model.title)}</h1>\n </div>\n <button class=\"refresh-btn\" onclick=\"window.location.reload()\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\"><path d=\"M12.5 2.5A6 6 0 1 0 13 7\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/><path d=\"M10 2.5h2.5V5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>\n Refresh\n </button>\n </div>\n <div class=\"stat-row\">\n ${renderSummaryItem(\"ID\", model.sessionId.slice(0, 8))}\n ${renderSummaryItem(\"File\", model.fileName)}\n ${renderSummaryItem(\"Created\", formatDate(model.createdAt))}\n ${renderSummaryItem(\"Updated\", formatDate(model.updatedAt))}\n ${renderSummaryItem(\"Entries\", String(model.entryCount))}\n ${renderSummaryItem(\"Expires\", formatDate(new Date(expiresAt).toISOString()))}\n </div>\n </header>\n\n ${relatedSections}\n\n <main class=\"timeline-shell\">\n <div class=\"timeline-list\">\n ${items}\n </div>\n </main>`,\n );\n}\n\nfunction renderSummaryItem(label: string, value: string): string {\n return `<span class=\"stat-chip\"><span class=\"stat-label\">${esc(label)}</span><strong class=\"stat-value\">${esc(value)}</strong></span>`;\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 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 ? `<pre class=\"msg-body\">${esc(content)}</pre>` : \"\";\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\">\n <div class=\"user-bubble\">\n ${rawHeader}\n ${threadBadge}\n ${body}\n ${forks}\n ${time}\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 ? `<pre class=\"msg-body\">${esc(item.body)}</pre>` : \"\";\n const forks = renderForkLinks(item.forks, token ?? \"\");\n return `<div class=\"msg-row msg-assistant\">\n <div class=\"msg-avatar asst-avatar\" aria-hidden=\"true\">A</div>\n <div class=\"asst-card\">\n ${body}\n ${forks}\n ${time}\n </div>\n</div>`;\n}\n\nfunction renderStatusPage(title: string, message: string): string {\n return renderHtmlDocument(\n title,\n `<section class=\"card stack\">\n <p class=\"eyebrow\">mama</p>\n <h1>${esc(title)}</h1>\n <div class=\"status err\">${esc(message)}</div>\n </section>`,\n );\n}\n\nfunction renderHtmlDocument(title: string, shellContent: string): 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>\n <main class=\"shell\">\n ${shellContent}\n </main>\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 80px;\n display: flex;\n flex-direction: column;\n align-items: center;\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 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: 16px;\n margin-bottom: 20px;\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 }\n\n .refresh-btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n padding: 7px 14px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: transparent;\n color: var(--muted);\n font: 500 0.8rem/1 'DM Sans', sans-serif;\n cursor: pointer;\n transition: color 120ms, border-color 120ms, background 120ms;\n white-space: nowrap;\n }\n\n .refresh-btn:hover {\n color: var(--text);\n border-color: rgba(0,0,0,0.2);\n background: rgba(0,0,0,0.03);\n }\n\n .refresh-btn:focus-visible {\n outline: 2px solid var(--text);\n outline-offset: 2px;\n }\n\n .stat-row {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .stat-chip {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 4px 10px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: #f4f4f5;\n font-size: 0.775rem;\n line-height: 1;\n }\n\n .stat-label {\n color: var(--muted);\n font-weight: 500;\n }\n\n .stat-value {\n color: var(--text);\n font-weight: 600;\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: 4px;\n }\n\n /* ── Message rows ─────────────────────────────────────────────────────── */\n\n .msg-row {\n display: flex;\n align-items: flex-end;\n gap: 8px;\n padding: 2px 0;\n }\n\n /* ── User messages ────────────────────────────────────────────────────── */\n\n .msg-user {\n justify-content: flex-end;\n }\n\n .user-bubble {\n max-width: 85%;\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 font-family: 'DM Sans', system-ui, sans-serif;\n font-size: 0.9rem;\n line-height: 1.6;\n white-space: pre-wrap;\n word-break: break-word;\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 }\n\n .asst-card {\n min-width: 0;\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 font-family: 'DM Sans', system-ui, sans-serif;\n font-size: 0.9rem;\n line-height: 1.65;\n white-space: pre-wrap;\n word-break: break-word;\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: 6px 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 /* ── 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 .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 /* ── Responsive ───────────────────────────────────────────────────────── */\n\n @media (max-width: 600px) {\n body { padding: 20px 12px 60px; }\n\n .hero-card, .card { padding: 20px; border-radius: 16px; }\n\n .hero-top { flex-direction: column; gap: 12px; }\n\n .refresh-btn { align-self: flex-start; }\n\n .user-bubble { max-width: 88%; }\n\n .asst-avatar { display: none; }\n\n .asst-card { border-radius: 4px 14px 14px 14px; }\n }\n`;\n"]}
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;AASjG,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,CAkFlB;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 { inferConversationKind } from \"../session-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 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 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\">mama</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 mama 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 const targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);\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 const targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);\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, false)\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 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\">mama</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"]}