@geminixiang/mikan 0.3.0-beta.0 → 0.3.1

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 (134) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/adapter.d.ts +5 -0
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/context.d.ts +0 -1
  6. package/dist/adapters/discord/context.d.ts.map +1 -1
  7. package/dist/adapters/discord/context.js +62 -84
  8. package/dist/adapters/discord/context.js.map +1 -1
  9. package/dist/adapters/shared.d.ts +1 -2
  10. package/dist/adapters/shared.d.ts.map +1 -1
  11. package/dist/adapters/shared.js +3 -2
  12. package/dist/adapters/shared.js.map +1 -1
  13. package/dist/adapters/slack/bot.d.ts +9 -34
  14. package/dist/adapters/slack/bot.d.ts.map +1 -1
  15. package/dist/adapters/slack/bot.js +374 -358
  16. package/dist/adapters/slack/bot.js.map +1 -1
  17. package/dist/adapters/slack/context.d.ts +0 -1
  18. package/dist/adapters/slack/context.d.ts.map +1 -1
  19. package/dist/adapters/slack/context.js +110 -113
  20. package/dist/adapters/slack/context.js.map +1 -1
  21. package/dist/adapters/slack/session.d.ts +0 -3
  22. package/dist/adapters/slack/session.d.ts.map +1 -1
  23. package/dist/adapters/slack/session.js +2 -8
  24. package/dist/adapters/slack/session.js.map +1 -1
  25. package/dist/adapters/slack/thread-manager.d.ts +0 -1
  26. package/dist/adapters/slack/thread-manager.d.ts.map +1 -1
  27. package/dist/adapters/slack/thread-manager.js +1 -4
  28. package/dist/adapters/slack/thread-manager.js.map +1 -1
  29. package/dist/adapters/slack/tools/block-kit.d.ts +16 -0
  30. package/dist/adapters/slack/tools/block-kit.d.ts.map +1 -0
  31. package/dist/adapters/slack/tools/block-kit.js +105 -0
  32. package/dist/adapters/slack/tools/block-kit.js.map +1 -0
  33. package/dist/adapters/telegram/context.d.ts +0 -1
  34. package/dist/adapters/telegram/context.d.ts.map +1 -1
  35. package/dist/adapters/telegram/context.js +44 -54
  36. package/dist/adapters/telegram/context.js.map +1 -1
  37. package/dist/admin/portal.d.ts.map +1 -1
  38. package/dist/admin/portal.js +2 -3
  39. package/dist/admin/portal.js.map +1 -1
  40. package/dist/agent.d.ts +0 -1
  41. package/dist/agent.d.ts.map +1 -1
  42. package/dist/agent.js +47 -80
  43. package/dist/agent.js.map +1 -1
  44. package/dist/commands/admin.d.ts +0 -3
  45. package/dist/commands/admin.d.ts.map +1 -1
  46. package/dist/commands/admin.js +5 -30
  47. package/dist/commands/admin.js.map +1 -1
  48. package/dist/commands/session-view.d.ts.map +1 -1
  49. package/dist/commands/session-view.js +4 -17
  50. package/dist/commands/session-view.js.map +1 -1
  51. package/dist/commands/types.d.ts +3 -2
  52. package/dist/commands/types.d.ts.map +1 -1
  53. package/dist/commands/types.js.map +1 -1
  54. package/dist/commands/utils.d.ts +3 -1
  55. package/dist/commands/utils.d.ts.map +1 -1
  56. package/dist/commands/utils.js +15 -5
  57. package/dist/commands/utils.js.map +1 -1
  58. package/dist/context.d.ts +0 -1
  59. package/dist/context.d.ts.map +1 -1
  60. package/dist/context.js +1 -23
  61. package/dist/context.js.map +1 -1
  62. package/dist/html.d.ts +2 -0
  63. package/dist/html.d.ts.map +1 -0
  64. package/dist/html.js +4 -0
  65. package/dist/html.js.map +1 -0
  66. package/dist/login/index.d.ts +2 -1
  67. package/dist/login/index.d.ts.map +1 -1
  68. package/dist/login/index.js.map +1 -1
  69. package/dist/login/portal.d.ts +1 -1
  70. package/dist/login/portal.d.ts.map +1 -1
  71. package/dist/login/portal.js +2 -3
  72. package/dist/login/portal.js.map +1 -1
  73. package/dist/login/{session.d.ts → store.d.ts} +1 -1
  74. package/dist/login/store.d.ts.map +1 -0
  75. package/dist/login/{session.js → store.js} +1 -1
  76. package/dist/login/store.js.map +1 -0
  77. package/dist/main.d.ts.map +1 -1
  78. package/dist/main.js +1 -1
  79. package/dist/main.js.map +1 -1
  80. package/dist/portal-shell.d.ts +2 -2
  81. package/dist/portal-shell.d.ts.map +1 -1
  82. package/dist/portal-shell.js +11 -16
  83. package/dist/portal-shell.js.map +1 -1
  84. package/dist/sandbox/cloudflare.d.ts +0 -2
  85. package/dist/sandbox/cloudflare.d.ts.map +1 -1
  86. package/dist/sandbox/cloudflare.js +2 -2
  87. package/dist/sandbox/cloudflare.js.map +1 -1
  88. package/dist/sandbox/container.d.ts +0 -3
  89. package/dist/sandbox/container.d.ts.map +1 -1
  90. package/dist/sandbox/container.js +3 -3
  91. package/dist/sandbox/container.js.map +1 -1
  92. package/dist/sandbox/firecracker.d.ts +0 -2
  93. package/dist/sandbox/firecracker.d.ts.map +1 -1
  94. package/dist/sandbox/firecracker.js +2 -2
  95. package/dist/sandbox/firecracker.js.map +1 -1
  96. package/dist/sandbox/host.d.ts +0 -2
  97. package/dist/sandbox/host.d.ts.map +1 -1
  98. package/dist/sandbox/host.js +2 -2
  99. package/dist/sandbox/host.js.map +1 -1
  100. package/dist/sandbox/image.d.ts +0 -2
  101. package/dist/sandbox/image.d.ts.map +1 -1
  102. package/dist/sandbox/image.js +2 -2
  103. package/dist/sandbox/image.js.map +1 -1
  104. package/dist/sandbox/index.d.ts +1 -6
  105. package/dist/sandbox/index.d.ts.map +1 -1
  106. package/dist/sandbox/index.js +0 -5
  107. package/dist/sandbox/index.js.map +1 -1
  108. package/dist/sandbox/path-context.d.ts +0 -1
  109. package/dist/sandbox/path-context.d.ts.map +1 -1
  110. package/dist/sandbox/path-context.js +1 -1
  111. package/dist/sandbox/path-context.js.map +1 -1
  112. package/dist/sentry.d.ts +2 -2
  113. package/dist/sentry.d.ts.map +1 -1
  114. package/dist/sentry.js.map +1 -1
  115. package/dist/session-view/portal.d.ts.map +1 -1
  116. package/dist/session-view/portal.js +2 -8
  117. package/dist/session-view/portal.js.map +1 -1
  118. package/dist/sessions/chat-session-manager.d.ts.map +1 -1
  119. package/dist/sessions/chat-session-manager.js +5 -9
  120. package/dist/sessions/chat-session-manager.js.map +1 -1
  121. package/dist/tools/index.d.ts +2 -0
  122. package/dist/tools/index.d.ts.map +1 -1
  123. package/dist/tools/index.js +4 -0
  124. package/dist/tools/index.js.map +1 -1
  125. package/dist/vault-routing.d.ts +0 -1
  126. package/dist/vault-routing.d.ts.map +1 -1
  127. package/dist/vault-routing.js +1 -4
  128. package/dist/vault-routing.js.map +1 -1
  129. package/dist/vault.d.ts +2 -1
  130. package/dist/vault.d.ts.map +1 -1
  131. package/dist/vault.js.map +1 -1
  132. package/package.json +3 -1
  133. package/dist/login/session.d.ts.map +0 -1
  134. package/dist/login/session.js.map +0 -1
package/dist/agent.d.ts CHANGED
@@ -18,7 +18,6 @@ export interface AgentRunner {
18
18
  label?: string;
19
19
  } | undefined;
20
20
  }
21
- export declare function buildEventFilesystemInstructions(sandboxType: SandboxConfig["type"], workspaceRoot: string): string;
22
21
  export declare function resolveTriggerAttribution(message: Pick<ChatMessage, "id" | "text" | "userName">): string | undefined;
23
22
  export declare function getUnresolvedSandboxPathContext(sandboxConfig: SandboxConfig, hostWorkspaceRoot: string): RuntimePathContext;
24
23
  export declare function buildPromptPayload(message: ChatMessage, workspacePath: string, pathContext?: RuntimePathContext): {
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AACA,OAAO,EAAsB,KAAK,YAAY,EAAc,MAAM,uBAAuB,CAAC;AAkB1F,OAAO,KAAK,EACV,WAAW,EACX,mBAAmB,EAGnB,YAAY,EAEb,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AAKrE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,EAGL,KAAK,kBAAkB,EACvB,KAAK,aAAa,EACnB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,OAAO,EAGL,KAAK,oBAAoB,EAE1B,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,WAAW;IAC1B,eAAe,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjD,GAAG,CACD,OAAO,EAAE,WAAW,EACpB,WAAW,EAAE,mBAAmB,EAChC,QAAQ,EAAE,YAAY,GACrB,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,KAAK,IAAI,IAAI,CAAC;IACd,6DAA6D;IAC7D,cAAc,IAAI;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACrE;AAyID,wBAAgB,gCAAgC,CAC9C,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,EAClC,aAAa,EAAE,MAAM,GACpB,MAAM,CAyBR;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,MAAM,GAAG,UAAU,CAAC,GACrD,MAAM,GAAG,SAAS,CAOpB;AAsOD,wBAAgB,+BAA+B,CAC7C,aAAa,EAAE,aAAa,EAC5B,iBAAiB,EAAE,MAAM,GACxB,kBAAkB,CAMpB;AAoTD,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EACpB,aAAa,EAAE,MAAM,EACrB,WAAW,CAAC,EAAE,kBAAkB,GAC/B;IACD,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,YAAY,EAAE,CAAC;CAClC,CAaA;AA+BD,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,MAAM,CAKR;AAgjBD;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,aAAa,EAC5B,UAAU,EAAE,MAAM,EAClB,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,oBAAoB,EAClC,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,sBAAsB,EACpC,WAAW,CAAC,EAAE;IACZ,UAAU,EAAE,yBAAyB,CAAC;IACtC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACA,OAAO,CAAC,WAAW,CAAC,CAmMtB;AAED,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,kBAAkB,GAC9B,MAAM,CAER;AAED,wBAAgB,8BAA8B,CAC5C,aAAa,EAAE,aAAa,EAC5B,iBAAiB,EAAE,MAAM,GACxB,kBAAkB,CAEpB","sourcesContent":["import { Agent, type ThinkingLevel } from \"@earendil-works/pi-agent-core\";\nimport { getModel, type Api, type ImageContent, type Model } from \"@earendil-works/pi-ai\";\nimport {\n AgentSession,\n AuthStorage,\n convertToLlm,\n DefaultResourceLoader,\n formatSkillsForPrompt,\n getAgentDir,\n loadSkillsFromDir,\n ModelRegistry,\n SessionManager,\n SettingsManager,\n type Skill,\n} from \"@earendil-works/pi-coding-agent\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { mkdir, readFile, writeFile } from \"fs/promises\";\nimport { homedir } from \"os\";\nimport { join, posix } from \"path\";\nimport type {\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n ConversationKind,\n PlatformInfo,\n PlatformName,\n} from \"./adapter.js\";\nimport type { SessionViewTokenStoreLike } from \"./commands/types.js\";\nimport { loadAgentConfigForConversation } from \"./config.js\";\nimport { ActorExecutionResolver } from \"./execution-resolver.js\";\nimport * as log from \"./log.js\";\nimport { reportUserFacingError } from \"./sentry.js\";\nimport type { DockerContainerManager } from \"./provisioner.js\";\nimport {\n createExecutor,\n type Executor,\n type RuntimePathContext,\n type SandboxConfig,\n} from \"./sandbox/index.js\";\nimport { createMountedRuntimePathContext } from \"./sandbox/path-context.js\";\nimport { addLifecycleBreadcrumb, metricAttributes } from \"./sentry.js\";\nimport type { VaultManager } from \"./vault.js\";\nimport { ChatSessionManager } from \"./sessions/chat-session-manager.js\";\nimport {\n extractSessionUuid,\n openManagedSession,\n type ResolvedSessionScope,\n type ThreadRootMessage,\n} from \"./sessions/store.js\";\nimport { shouldSurfaceToolDiagnostic } from \"./tool-diagnostics.js\";\nimport { createMikanTools } from \"./tools/index.js\";\nimport * as Sentry from \"@sentry/node\";\n\nexport interface AgentRunner {\n syncChatHistory(currentMessageId?: string): void;\n run(\n message: ChatMessage,\n responseCtx: ChatResponseContext,\n platform: PlatformInfo,\n ): Promise<{ stopReason: string; errorMessage?: string }>;\n abort(): void;\n /** Get current step info (tool name, label) for debugging */\n getCurrentStep(): { toolName?: string; label?: string } | undefined;\n}\n\nconst IMAGE_MIME_TYPES: Record<string, string> = {\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n webp: \"image/webp\",\n};\n\nfunction getImageMimeType(filename: string): string | undefined {\n return IMAGE_MIME_TYPES[filename.toLowerCase().split(\".\").pop() || \"\"];\n}\n\nfunction buildThreadSessionName(message: ThreadRootMessage | null): string | undefined {\n const text = message?.text?.trim();\n if (!text) return undefined;\n const userLabel = message?.userName || message?.user || \"unknown\";\n return `[${userLabel}]: ${text}`;\n}\n\nasync function getMemory(conversationDir: string): Promise<string> {\n const parts: string[] = [];\n\n // Read workspace-level memory (shared across all conversations)\n const workspaceMemoryPath = join(conversationDir, \"..\", \"MEMORY.md\");\n if (existsSync(workspaceMemoryPath)) {\n try {\n const content = (await readFile(workspaceMemoryPath, \"utf-8\")).trim();\n if (content) {\n parts.push(`### Global Workspace Memory\\n${content}`);\n }\n } catch (error) {\n log.logWarning(\"Failed to read workspace memory\", `${workspaceMemoryPath}: ${error}`);\n }\n }\n\n // Read conversation-specific memory\n const conversationMemoryPath = join(conversationDir, \"MEMORY.md\");\n if (existsSync(conversationMemoryPath)) {\n try {\n const content = (await readFile(conversationMemoryPath, \"utf-8\")).trim();\n if (content) {\n parts.push(`### Conversation-Specific Memory\\n${content}`);\n }\n } catch (error) {\n log.logWarning(\"Failed to read conversation memory\", `${conversationMemoryPath}: ${error}`);\n }\n }\n\n if (parts.length === 0) {\n return \"(no working memory yet)\";\n }\n\n return parts.join(\"\\n\\n\");\n}\n\nfunction loadMikanSkills(conversationDir: string, workspacePath: string): Skill[] {\n const skillMap = new Map<string, Skill>();\n\n // conversationDir is the host path (e.g., /Users/.../data/C0A34FL8PMH)\n // hostWorkspacePath is the parent directory on host\n // workspacePath is the container path (e.g., /workspace)\n const hostWorkspacePath = join(conversationDir, \"..\");\n\n // Helper to translate host paths to container paths\n const translatePath = (hostPath: string): string => {\n if (hostPath.startsWith(hostWorkspacePath)) {\n return workspacePath + hostPath.slice(hostWorkspacePath.length);\n }\n return hostPath;\n };\n\n // Load workspace-level skills (global)\n const workspaceSkillsDir = join(hostWorkspacePath, \"skills\");\n for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: \"workspace\" }).skills) {\n // Translate paths to container paths for system prompt\n skill.filePath = translatePath(skill.filePath);\n skill.baseDir = translatePath(skill.baseDir);\n skillMap.set(skill.name, skill);\n }\n\n // Load conversation-specific skills (override workspace skills on collision)\n const conversationSkillsDir = join(conversationDir, \"skills\");\n for (const skill of loadSkillsFromDir({ dir: conversationSkillsDir, source: \"channel\" }).skills) {\n skill.filePath = translatePath(skill.filePath);\n skill.baseDir = translatePath(skill.baseDir);\n skillMap.set(skill.name, skill);\n }\n\n return Array.from(skillMap.values());\n}\n\nfunction buildRuntimePaths(runtimeWorkspaceRoot: string, conversationId: string) {\n const workspaceRoot = runtimeWorkspaceRoot.replace(/\\/+$/, \"\") || \"/\";\n const conversationPath = posix.join(workspaceRoot, conversationId);\n return {\n workspaceRoot,\n conversationPath,\n scratchPath: posix.join(conversationPath, \"scratch\"),\n };\n}\n\nfunction buildEnvDescription(sandboxType: SandboxConfig[\"type\"], workspaceRoot: string): string {\n switch (sandboxType) {\n case \"image\":\n return `You are running inside a managed per-user container.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${workspaceRoot}\n- Install tools with the image's package manager\n- Your changes persist for this user's container until it is recreated`;\n case \"container\":\n return `You are running inside a shared container.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${workspaceRoot}\n- Install tools with the container's package manager\n- Your changes persist across sessions`;\n case \"firecracker\":\n return `You are running inside a Firecracker microVM.\n- Runtime workspace root: ${workspaceRoot}\n- Use cd or absolute paths; project files are under ${workspaceRoot}\n- Install tools with: apt-get install <package> (Debian-based)\n- Your changes persist across sessions`;\n case \"cloudflare\":\n return `You are running through a Cloudflare Sandbox bridge.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${workspaceRoot}\n- Your commands run in a remote container managed by Cloudflare\n- Important: the remote filesystem is not automatically synced back to the host workspace`;\n default:\n return `You are running directly on the host machine.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${process.cwd()}\n- Be careful with system modifications`;\n }\n}\n\nexport function buildEventFilesystemInstructions(\n sandboxType: SandboxConfig[\"type\"],\n workspaceRoot: string,\n): string {\n if (sandboxType === \"host\" || sandboxType === \"container\" || sandboxType === \"image\") {\n return `Events live in the host-side mikan control plane and are mounted at \\`${workspaceRoot}/events/\\` in this runtime.\n\nPrefer the \\`event\\` tool over manually writing JSON files; it fills \\`platform\\`, \\`conversationId\\`, \\`conversationKind\\`, and \\`userId\\` for the current conversation automatically.\n\n### Creating Events Manually\nOnly do this when you need to create events from a script. Use unique filenames to avoid overwriting existing events. Include a timestamp or random suffix:\n\\`\\`\\`bash\ncat > ${workspaceRoot}/events/dentist-reminder-$(date +%s).json << 'EOF'\n{\"type\": \"one-shot\", \"platform\": \"<platform>\", \"conversationId\": \"<conversationId>\", \"conversationKind\": \"<direct|shared>\", \"userId\": \"<requester userId>\", \"text\": \"Dentist tomorrow\", \"at\": \"2025-12-14T09:00:00+01:00\"}\nEOF\n\\`\\`\\`\n\n### Managing Events\n- List: \\`ls ${workspaceRoot}/events/\\`\n- View: \\`cat ${workspaceRoot}/events/foo.json\\`\n- Delete/cancel: \\`rm ${workspaceRoot}/events/foo.json\\``;\n }\n\n return `Events live in the host-side mikan control plane, not necessarily in this runtime filesystem.\n\nUse the \\`event\\` tool to create events. It writes to the correct host-side events directory and fills \\`platform\\`, \\`conversationId\\`, \\`conversationKind\\`, and \\`userId\\` for the current conversation automatically.\n\nDo not create event files with bash in \\`${workspaceRoot}/events/\\` from this sandbox unless you have explicitly verified that path is mounted back to the host-side mikan events directory.`;\n}\n\nexport function resolveTriggerAttribution(\n message: Pick<ChatMessage, \"id\" | \"text\" | \"userName\">,\n): string | undefined {\n const eventTextMatch = message.text.match(/^\\[EVENT:([^:]+):/);\n if (eventTextMatch) return `[event: ${eventTextMatch[1]}]`;\n const eventIdMatch = message.id.match(/^event:([^:]+)/);\n if (eventIdMatch) return `[event: ${eventIdMatch[1]}]`;\n if (message.userName) return `@${message.userName}`;\n return undefined;\n}\n\nfunction buildSystemPrompt(\n workspacePath: string,\n conversationId: string,\n conversationKind: ConversationKind,\n currentUserId: string | undefined,\n memory: string,\n sandboxConfig: SandboxConfig,\n platform: PlatformInfo,\n skills: Skill[],\n isEventTrigger = false,\n triggerAttribution?: string,\n): string {\n const { workspaceRoot, conversationPath, scratchPath } = buildRuntimePaths(\n workspacePath,\n conversationId,\n );\n const sandboxType = sandboxConfig.type;\n const isContainerLike = sandboxType === \"container\" || sandboxType === \"image\";\n const isFirecracker = sandboxType === \"firecracker\";\n\n // Format channel mappings\n const channelMappings =\n platform.channels.length > 0\n ? platform.channels.map((c) => `${c.id}\\t#${c.name}`).join(\"\\n\")\n : \"(no channels loaded)\";\n\n // Format user mappings\n const userMappings =\n platform.users.length > 0\n ? platform.users.map((u) => `${u.id}\\t@${u.userName}\\t${u.displayName}`).join(\"\\n\")\n : \"(no users loaded)\";\n\n const envDescription = buildEnvDescription(sandboxType, workspaceRoot);\n const eventFilesystemInstructions = buildEventFilesystemInstructions(sandboxType, workspaceRoot);\n const eventTriggerInstructions = isEventTrigger\n ? `\n## Event Trigger Mode\n- You are handling a scheduled/background event, not opening a brand new chat with a stranger.\n- Treat the incoming user message as a self-contained task prepared by an earlier run.\n- Complete the task directly. Avoid generic greetings, self-introductions, or boilerplate offers to help.\n- For reminders/follow-ups, prefer a short direct response that sounds like a continuation of prior intent.\n- If the event text includes tone, brevity, or language instructions, follow them literally.\n`\n : \"\";\n const attributionInstructions = triggerAttribution\n ? `\n## Attribution\nAlways end your final ${platform.name} response and any GitHub issue/PR comments or descriptions you write via tools with:\n_Triggered by ${triggerAttribution}_\n\nDo not add this to \\`[SILENT]\\` responses.\n`\n : \"\";\n\n return `You are mikan, a ${platform.name} bot assistant. Be concise. No emojis.\n\n## Context\n- For current date/time, use: date\n- You have access to previous conversation context including tool results from prior turns.\n- For older human-readable history beyond your context, search \\`log.jsonl\\` (contains user messages and your final responses, but not tool results).\n- Structured session history with tool results lives in \\`${conversationPath}/sessions/\\`.\n- The active top-level session is selected by \\`${conversationPath}/sessions/current\\`, which points to a timestamped \\`.jsonl\\` file in the same directory.\n- Scoped/thread sessions use fixed files at \\`${conversationPath}/sessions/<scope_id>.jsonl\\` (for example \\`${conversationPath}/sessions/1777386320.800769.jsonl\\`).\n- If a user asks about something that should exist in conversation history but is not found in the current context window, do not answer \"I don't know\" or \"I don't have that\". Instead, search the thread session, top-level session, and \\`log.jsonl\\` before responding.\n- User messages include a \\`[in-thread:TS]\\` marker when sent from within a platform thread/reply (TS is the thread or parent message identifier). Without this marker, the message is a top-level conversation message.\n${eventTriggerInstructions}\n${platform.formattingGuide}\n\n## Platform IDs\nChannels: ${channelMappings}\n\nUsers: ${userMappings}\n\nWhen mentioning users, use <@username> format (e.g., <@mario>).\n\n## Environment\n${envDescription}\n- Default place for clones, downloads, and experiments: ${scratchPath}\n- Do not use host-only paths unless you are running in host mode and verified they exist.\n\n## Workspace Layout\n${workspaceRoot}/\n├── MEMORY.md # Global memory (all conversations)\n├── skills/ # Global CLI tools you create\n└── ${conversationId}/ # This conversation\n ├── MEMORY.md # Conversation-specific memory\n ├── log.jsonl # Human-readable message history (no tool results)\n ├── sessions/ # Structured session history used for context reconstruction\n │ ├── current # Active top-level session pointer\n │ ├── <timestamp>_<id>.jsonl # Top-level session files\n │ └── <scope_id>.jsonl # Scoped thread/reply session files\n ├── attachments/ # User-shared files\n ├── scratch/ # Working directory for clones/downloads/experiments: ${scratchPath}\n └── skills/ # Conversation-specific tools\n\n## Skills (Custom CLI Tools)\nYou can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).\n\n### Creating Skills\nStore in \\`${workspaceRoot}/skills/<name>/\\` (global) or \\`${conversationPath}/skills/<name>/\\` (conversation-specific).\nEach skill directory needs a \\`SKILL.md\\` with YAML frontmatter:\n\n\\`\\`\\`markdown\n---\nname: skill-name\ndescription: Short description of what this skill does\n---\n\n# Skill Name\n\nUsage instructions, examples, etc.\nScripts are in: {baseDir}/\n\\`\\`\\`\n\n\\`name\\` and \\`description\\` are required. Use \\`{baseDir}\\` as placeholder for the skill's directory path.\n\n### Available Skills\n${skills.length > 0 ? formatSkillsForPrompt(skills) : \"(no skills installed yet)\"}\n\n## Events\nYou can schedule events that wake you up at specific times or when external things happen.\n${eventFilesystemInstructions}\n\n### Event Types\n\n**Immediate** - Triggers as soon as harness sees the file. Use in scripts/webhooks to signal external events.\n\\`\\`\\`json\n{\"type\": \"immediate\", \"platform\": \"${platform.name}\", \"conversationId\": \"${conversationId}\", \"conversationKind\": \"${conversationKind}\", \"userId\": \"${currentUserId ?? \"<requester userId>\"}\", \"text\": \"New GitHub issue opened\"}\n\\`\\`\\`\n\n**One-shot** - Triggers once at a specific time. Use for reminders.\n\\`\\`\\`json\n{\"type\": \"one-shot\", \"platform\": \"${platform.name}\", \"conversationId\": \"${conversationId}\", \"conversationKind\": \"${conversationKind}\", \"userId\": \"${currentUserId ?? \"<requester userId>\"}\", \"text\": \"Remind Mario about dentist\", \"at\": \"2025-12-15T09:00:00+01:00\"}\n\\`\\`\\`\n\n**Periodic** - Triggers on a cron schedule. Use for recurring tasks.\n\\`\\`\\`json\n{\"type\": \"periodic\", \"platform\": \"${platform.name}\", \"conversationId\": \"${conversationId}\", \"conversationKind\": \"${conversationKind}\", \"userId\": \"${currentUserId ?? \"<requester userId>\"}\", \"text\": \"Check inbox and summarize\", \"schedule\": \"0 9 * * 1-5\", \"timezone\": \"${Intl.DateTimeFormat().resolvedOptions().timeZone}\"}\n\\`\\`\\`\n\n### Cron Format\n\\`minute hour day-of-month month day-of-week\\`\n- \\`0 9 * * *\\` = daily at 9:00\n- \\`0 9 * * 1-5\\` = weekdays at 9:00\n- \\`30 14 * * 1\\` = Mondays at 14:30\n- \\`0 0 1 * *\\` = first of each month at midnight\n\n### Timezones\nAll \\`at\\` timestamps must include offset (e.g., \\`+01:00\\`). Periodic events use IANA timezone names. The harness runs in ${Intl.DateTimeFormat().resolvedOptions().timeZone}. When users mention times without timezone, assume ${Intl.DateTimeFormat().resolvedOptions().timeZone}.\n\n### Platform and Credential Routing\nSet \\`platform\\` to the target bot platform (\\`${platform.name}\\` for this conversation). Include it explicitly to avoid ambiguity.\n\nSet \\`userId\\` to the platform userId of whoever asked for the event. When the event fires, tool execution routes using that user's vault selection in per-user modes. In \\`container:<name>\\`, events use the container's single shared vault.\n\nWhen scheduling an event, write \\`text\\` as a self-contained task for your future self. Include the minimum necessary context, tone, and constraints in the text itself because events do not inherit normal conversation history. Good: \\`Please remind the user that break time is over and it is time to return to class. Keep it brief, in Traditional Chinese, and do not ask follow-up questions.\\` Bad: \\`back to class\\`.\n\n### When Events Trigger\nYou receive a message like:\n\\`\\`\\`\n[EVENT:dentist-reminder.json:one-shot:2025-12-14T09:00:00+01:00] Dentist tomorrow\n\\`\\`\\`\nImmediate and one-shot events auto-delete after triggering. Periodic events persist until you delete them.\n\n### Silent Completion\nFor periodic events where there's nothing to report, respond with just \\`[SILENT]\\` (no other text). This deletes the status message and posts nothing to the platform. Use this to avoid spamming the channel when periodic checks find nothing actionable.\n\n### Debouncing\nWhen writing programs that create immediate events (email watchers, webhook handlers, etc.), always debounce. If 50 emails arrive in a minute, don't create 50 immediate events. Instead collect events over a window and create ONE immediate event summarizing what happened, or just signal \"new activity, check inbox\" rather than per-item events. Or simpler: use a periodic event to check for new items every N minutes instead of immediate events.\n\n### Limits\nMaximum 5 events can be queued. Don't create excessive immediate or periodic events.\n\n## Memory\nWrite to MEMORY.md files to persist context across conversations.\n- Global (${workspaceRoot}/MEMORY.md): skills, preferences, project info\n- Conversation (${conversationPath}/MEMORY.md): conversation-specific decisions, ongoing work\nUpdate when you learn something important or when asked to remember something.\n\n### Current Memory\n${memory}\n\n## System Configuration Log\nMaintain ${workspaceRoot}/SYSTEM.md to log all environment modifications:\n- Installed packages (apt install, npm install, uv pip install)\n- Environment variables set\n- Config files modified (~/.gitconfig, cron jobs, etc.)\n- Skill dependencies installed\n\nUpdate this file whenever you modify the environment. On fresh container, read it first to restore your setup.\n\n## Log Queries (for older history)\nFormat: \\`{\"date\":\"...\",\"ts\":\"...\",\"user\":\"...\",\"userName\":\"...\",\"text\":\"...\",\"isBot\":false}\\`\nThe log contains user messages and your final responses (not tool calls/results).\nUse \\`log.jsonl\\` for quick grep-style history. Use \\`${conversationPath}/sessions/\\` when you need structured turns, tool outputs, or thread/session lineage.\n${isContainerLike || isFirecracker ? \"Install jq: apt-get install jq\" : \"\"}\n${attributionInstructions}\n\\`\\`\\`bash\n# Recent messages\ntail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\n# Search for specific topic\ngrep -i \"topic\" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\n# Messages from specific user\ngrep '\"userName\":\"mario\"' log.jsonl | tail -20 | jq -c '{date: .date[0:19], text}'\n\n# Inspect top-level session pointer and available session files\ncat sessions/current\nls -1 sessions/\n\\`\\`\\`\n\n## Tools\n- bash: Run shell commands (primary tool). Install packages as needed.\n- read: Read files\n- write: Create/overwrite files\n- edit: Surgical file edits\n- attach: Share files to the platform\n\nEach tool requires a \"label\" parameter (shown to user).\n`;\n}\n\nfunction truncate(text: string, maxLen: number): string {\n if (text.length <= maxLen) return text;\n return `${text.substring(0, maxLen - 3)}...`;\n}\n\nexport function getUnresolvedSandboxPathContext(\n sandboxConfig: SandboxConfig,\n hostWorkspaceRoot: string,\n): RuntimePathContext {\n if (sandboxConfig.type === \"image\") {\n return createMountedRuntimePathContext(hostWorkspaceRoot, \"/workspace\");\n }\n\n return createExecutor(sandboxConfig).getPathContext(hostWorkspaceRoot);\n}\n\ninterface RunnerExecutionContext {\n executionResolver?: ActorExecutionResolver;\n executor: Executor;\n getPathContext: () => RuntimePathContext;\n resolveExecutorForRun(context: {\n platform: string;\n userId: string;\n conversationId: string;\n }): Promise<void>;\n}\n\ninterface RunnerSessionState {\n responseCtx: ChatResponseContext | null;\n logCtx: {\n conversationId: string;\n userName?: string;\n conversationName?: string;\n sessionId?: string;\n } | null;\n queue: {\n enqueue(fn: () => Promise<void>, errorContext: string): void;\n } | null;\n pendingTools: Map<string, { toolName: string; args: unknown; startTime: number }>;\n totalUsage: {\n input: number;\n output: number;\n cacheRead: number;\n cacheWrite: number;\n cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n };\n llmCallCount: number;\n stopReason: string;\n errorMessage: string | undefined;\n reportedLlmError: boolean;\n}\n\ninterface PreparedRunContext {\n sessionConversation: string;\n runQueue: ReturnType<typeof createRunQueue>;\n userMessage: string;\n imageAttachments: ImageContent[];\n triggerAttribution?: string;\n}\n\ninterface ConfiguredAgentSession {\n agent: Agent;\n session: AgentSession;\n}\n\nfunction createRunnerExecutionContext(\n sandboxConfig: SandboxConfig,\n vaultManager: VaultManager | undefined,\n provisioner: DockerContainerManager | undefined,\n workspaceDir: string,\n hostWorkspacePath: string,\n): RunnerExecutionContext {\n const executionResolver =\n vaultManager &&\n sandboxConfig.type !== \"host\" &&\n (vaultManager.isEnabled() ||\n sandboxConfig.type === \"container\" ||\n sandboxConfig.type === \"image\" ||\n sandboxConfig.type === \"cloudflare\" ||\n sandboxConfig.type === \"firecracker\")\n ? new ActorExecutionResolver(sandboxConfig, vaultManager, provisioner, workspaceDir)\n : undefined;\n\n // activeExecutor is replaced at the start of each run() call when executionResolver\n // is present, so the stable `executor` wrapper always delegates to the latest resolved value.\n let activeExecutor: Executor =\n executionResolver !== undefined\n ? createExecutor({ type: \"host\" })\n : createExecutor(sandboxConfig);\n const executor: Executor = {\n exec(command, options) {\n return activeExecutor.exec(command, options);\n },\n getWorkspacePath(hostPath) {\n return activeExecutor.getWorkspacePath(hostPath);\n },\n getSandboxConfig() {\n return activeExecutor.getSandboxConfig();\n },\n getPathContext(hostWorkspaceRoot) {\n return activeExecutor.getPathContext(hostWorkspaceRoot);\n },\n };\n\n return {\n executionResolver,\n executor,\n getPathContext: () => executor.getPathContext(hostWorkspacePath),\n async resolveExecutorForRun(context): Promise<void> {\n if (!executionResolver) return;\n activeExecutor = await executionResolver.resolve(context);\n },\n };\n}\n\nasync function createConfiguredAgentSession(params: {\n conversationId: string;\n workspaceDir: string;\n runtimeWorkspaceRoot: string;\n systemPrompt: string;\n model: Model<Api>;\n thinkingLevel: ThinkingLevel;\n tools: Awaited<ReturnType<typeof createMikanTools>>[\"tools\"];\n sessionManager: SessionManager;\n settingsManager: SettingsManager;\n}): Promise<ConfiguredAgentSession> {\n const {\n conversationId,\n workspaceDir,\n runtimeWorkspaceRoot,\n systemPrompt,\n model,\n thinkingLevel,\n tools,\n sessionManager,\n settingsManager,\n } = params;\n\n const authStorage = AuthStorage.create(join(homedir(), \".pi\", \"mikan\", \"auth.json\"));\n const modelRegistry = ModelRegistry.create(authStorage);\n const agent = new Agent({\n initialState: {\n systemPrompt,\n model,\n thinkingLevel,\n tools,\n },\n convertToLlm,\n getApiKey: async () => {\n const key = await modelRegistry.getApiKeyForProvider(model.provider);\n if (!key) {\n throw new Error(\n `No API key for provider \"${model.provider}\". Set the appropriate environment variable or configure via auth.json`,\n );\n }\n return key;\n },\n });\n\n const loadedSession = sessionManager.buildSessionContext();\n if (loadedSession.messages.length > 0) {\n agent.state.messages = loadedSession.messages;\n log.logInfo(\n `[${conversationId}] Reloaded ${loadedSession.messages.length} messages from session context`,\n );\n }\n\n const resourceLoader = new DefaultResourceLoader({\n cwd: workspaceDir,\n agentDir: getAgentDir(),\n systemPrompt,\n });\n try {\n await resourceLoader.reload();\n const extResult = resourceLoader.getExtensions();\n if (extResult.errors.length > 0) {\n for (const err of extResult.errors) {\n log.logWarning(`[${conversationId}] Extension load error: ${err.path}`, err.error);\n }\n }\n log.logInfo(\n `[${conversationId}] Loaded ${extResult.extensions.length} extension(s): ${extResult.extensions.map((extension) => extension.path).join(\", \")}`,\n );\n } catch (error) {\n log.logWarning(`[${conversationId}] Failed to load resources`, String(error));\n }\n\n const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n const session = new AgentSession({\n agent,\n sessionManager,\n settingsManager,\n cwd: runtimeWorkspaceRoot,\n modelRegistry,\n resourceLoader,\n baseToolsOverride,\n });\n return { agent, session };\n}\n\nfunction createEmptyUsageTotals() {\n return {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n };\n}\n\nfunction createRunState(): RunnerSessionState {\n return {\n responseCtx: null,\n logCtx: null,\n queue: null,\n pendingTools: new Map<string, { toolName: string; args: unknown; startTime: number }>(),\n totalUsage: createEmptyUsageTotals(),\n llmCallCount: 0,\n stopReason: \"stop\",\n errorMessage: undefined,\n reportedLlmError: false,\n };\n}\n\nfunction resetRunState(\n runState: RunnerSessionState,\n responseCtx: ChatResponseContext,\n sessionConversation: string,\n userName: string | undefined,\n sessionUuid: string,\n): void {\n runState.responseCtx = responseCtx;\n runState.logCtx = {\n conversationId: sessionConversation,\n userName,\n conversationName: undefined,\n sessionId: sessionUuid,\n };\n runState.pendingTools.clear();\n runState.totalUsage = createEmptyUsageTotals();\n runState.llmCallCount = 0;\n runState.stopReason = \"stop\";\n runState.errorMessage = undefined;\n runState.reportedLlmError = false;\n}\n\nfunction createRunQueue(responseCtx: ChatResponseContext): {\n queue: { enqueue(fn: () => Promise<void>, errorContext: string): void };\n wait: () => Promise<void>;\n} {\n let queueChain = Promise.resolve();\n return {\n queue: {\n enqueue(fn: () => Promise<void>, errorContext: string): void {\n queueChain = queueChain.then(async () => {\n try {\n await fn();\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(`API error (${errorContext})`, errMsg);\n try {\n await responseCtx.respondDiagnostic(`Error: ${errMsg}`, { style: \"error\" });\n } catch {\n // Ignore\n }\n }\n });\n },\n },\n wait: () => queueChain,\n };\n}\n\nfunction padTwoDigits(n: number): string {\n return n.toString().padStart(2, \"0\");\n}\n\nfunction formatTimestampedUserMessage(message: ChatMessage): string {\n const now = new Date();\n const offset = -now.getTimezoneOffset();\n const offsetSign = offset >= 0 ? \"+\" : \"-\";\n const offsetHours = padTwoDigits(Math.floor(Math.abs(offset) / 60));\n const offsetMins = padTwoDigits(Math.abs(offset) % 60);\n const timestamp =\n `${now.getFullYear()}-${padTwoDigits(now.getMonth() + 1)}-${padTwoDigits(now.getDate())} ` +\n `${padTwoDigits(now.getHours())}:${padTwoDigits(now.getMinutes())}:${padTwoDigits(now.getSeconds())}` +\n `${offsetSign}${offsetHours}:${offsetMins}`;\n const threadContext = message.threadTs ? ` [in-thread:${message.threadTs}]` : \"\";\n return `[${timestamp}] [${message.userName || \"unknown\"}]${threadContext}: ${message.text}`;\n}\n\nfunction collectMessageAttachments(\n message: ChatMessage,\n workspacePath: string,\n pathContext?: RuntimePathContext,\n): { imageAttachments: ImageContent[]; nonImagePaths: string[] } {\n const imageAttachments: ImageContent[] = [];\n const nonImagePaths: string[] = [];\n\n for (const attachment of message.attachments || []) {\n const runtimePath = `${workspacePath}/${attachment.localPath}`;\n const hostPath = pathContext?.runtimeToHostPath?.(runtimePath) ?? runtimePath;\n const mimeType = getImageMimeType(attachment.localPath);\n\n if (mimeType && existsSync(hostPath)) {\n try {\n imageAttachments.push({\n type: \"image\",\n mimeType,\n data: readFileSync(hostPath).toString(\"base64\"),\n });\n } catch {\n nonImagePaths.push(runtimePath);\n }\n } else {\n nonImagePaths.push(runtimePath);\n }\n }\n\n return { imageAttachments, nonImagePaths };\n}\n\nexport function buildPromptPayload(\n message: ChatMessage,\n workspacePath: string,\n pathContext?: RuntimePathContext,\n): {\n userMessage: string;\n imageAttachments: ImageContent[];\n} {\n let userMessage = formatTimestampedUserMessage(message);\n const { imageAttachments, nonImagePaths } = collectMessageAttachments(\n message,\n workspacePath,\n pathContext,\n );\n\n if (nonImagePaths.length > 0) {\n userMessage += `\\n\\n<slack_attachments>\\n${nonImagePaths.join(\"\\n\")}\\n</slack_attachments>`;\n }\n\n return { userMessage, imageAttachments };\n}\n\nasync function writePromptDebugContext(\n conversationDir: string,\n systemPrompt: string,\n session: AgentSession,\n userMessage: string,\n imageAttachmentCount: number,\n): Promise<void> {\n const debugContext = {\n systemPrompt,\n messages: session.messages,\n newUserMessage: userMessage,\n imageAttachmentCount,\n };\n await writeFile(\n join(conversationDir, \"last_prompt.jsonl\"),\n JSON.stringify(debugContext, null, 2),\n );\n}\n\nfunction getFinalAssistantText(session: AgentSession): string {\n const lastAssistant = session.messages.filter((message) => message.role === \"assistant\").pop();\n return (\n lastAssistant?.content\n .filter((content): content is { type: \"text\"; text: string } => content.type === \"text\")\n .map((content) => content.text)\n .join(\"\\n\") || \"\"\n );\n}\n\nexport function appendTriggerAttribution(\n text: string,\n triggerAttribution: string | undefined,\n): string {\n if (!triggerAttribution) return text;\n const suffix = `_Triggered by ${triggerAttribution}_`;\n if (text.trimEnd().endsWith(suffix)) return text;\n return `${text.trimEnd()}\\n\\n${suffix}`;\n}\n\nasync function finalizeRunResponse(\n responseCtx: ChatResponseContext,\n session: AgentSession,\n runState: RunnerSessionState,\n options?: {\n triggerAttribution?: string;\n createOverflowLink?: () => string;\n platform?: string;\n model?: Model<Api>;\n sessionConversation?: string;\n sessionUuid?: string;\n },\n): Promise<void> {\n if (runState.stopReason === \"error\" && runState.errorMessage) {\n if (!runState.reportedLlmError) {\n runState.reportedLlmError = true;\n reportUserFacingError(new Error(\"LLM run completed with error stop reason\"), {\n domain: \"llm\",\n surface: \"assistant_response\",\n operation: \"llm_turn\",\n severity: \"error\",\n platform: options?.platform,\n provider: options?.model?.provider,\n model: options?.model?.name,\n stopReason: runState.stopReason,\n context: {\n sessionConversation: options?.sessionConversation,\n sessionUuid: options?.sessionUuid,\n hasErrorMessage: true,\n llmCallCount: runState.llmCallCount,\n },\n });\n }\n try {\n await responseCtx.replaceResponse(\"_Sorry, something went wrong_\");\n await responseCtx.respondDiagnostic(`Error: ${runState.errorMessage}`, {\n style: \"error\",\n });\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(\"Failed to post error message\", errMsg);\n reportUserFacingError(err, {\n domain: \"chat_platform\",\n surface: \"final_response\",\n operation: \"finalize_error_response\",\n severity: \"error\",\n platform: options?.platform,\n context: {\n sessionConversation: options?.sessionConversation,\n sessionUuid: options?.sessionUuid,\n stopReason: runState.stopReason,\n },\n });\n }\n return;\n }\n\n const finalText = getFinalAssistantText(session);\n if (finalText.trim() === \"[SILENT]\" || finalText.trim().startsWith(\"[SILENT]\")) {\n try {\n await responseCtx.deleteResponse();\n log.logInfo(\"Silent response - deleted message and thread\");\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(\"Failed to delete message for silent response\", errMsg);\n }\n return;\n }\n\n if (!finalText.trim()) return;\n\n try {\n await responseCtx.replaceResponse(\n appendTriggerAttribution(finalText, options?.triggerAttribution),\n { createOverflowLink: options?.createOverflowLink },\n );\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(\"Failed to replace message with final text\", errMsg);\n reportUserFacingError(err, {\n domain: \"chat_platform\",\n surface: \"final_response\",\n operation: \"replace_final_response\",\n severity: \"error\",\n platform: options?.platform,\n context: {\n sessionConversation: options?.sessionConversation,\n sessionUuid: options?.sessionUuid,\n finalTextLength: finalText.length,\n },\n });\n }\n}\n\ninterface UsageReportContext {\n session: AgentSession;\n runState: RunnerSessionState;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n model: Model<Api>;\n agentConfig: ReturnType<typeof loadAgentConfigForConversation>;\n sessionConversation: string;\n sessionUuid: string;\n waitForQueue: () => Promise<void>;\n}\n\nasync function reportUsageSummary(ctx: UsageReportContext): Promise<void> {\n const {\n session,\n runState,\n responseCtx,\n platform,\n model,\n agentConfig,\n sessionConversation,\n sessionUuid,\n waitForQueue,\n } = ctx;\n if (runState.totalUsage.cost.total <= 0) return;\n\n const lastAssistantMessage = session.messages\n .slice()\n .toReversed()\n .find(\n (message): message is Extract<typeof message, { role: \"assistant\" }> =>\n message.role === \"assistant\" && message.stopReason !== \"aborted\",\n );\n\n const contextTokens = lastAssistantMessage\n ? lastAssistantMessage.usage.input +\n lastAssistantMessage.usage.output +\n lastAssistantMessage.usage.cacheRead +\n lastAssistantMessage.usage.cacheWrite\n : 0;\n const contextWindow = model.contextWindow || 200000;\n\n const { totalUsage } = runState;\n const runMetricAttributes = metricAttributes({\n provider: model.provider,\n model: agentConfig.model,\n channel_id: sessionConversation,\n session_id: sessionUuid,\n stop_reason: runState.stopReason,\n llm_calls: runState.llmCallCount,\n });\n Sentry.metrics.distribution(\"agent.run.tokens_in\", totalUsage.input, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.tokens_out\", totalUsage.output, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.cache_read\", totalUsage.cacheRead, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.cache_write\", totalUsage.cacheWrite, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.cost\", totalUsage.cost.total, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.gauge(\"agent.context.utilization\", contextTokens / contextWindow, {\n unit: \"ratio\",\n attributes: runMetricAttributes,\n });\n\n const summary = log.logUsageSummary(\n runState.logCtx!,\n runState.totalUsage,\n contextTokens,\n contextWindow,\n );\n if (platform.diagnostics?.showUsageSummary === true) {\n runState.queue!.enqueue(\n () => responseCtx.respondDiagnostic(summary, { style: \"muted\" }),\n \"usage summary\",\n );\n await waitForQueue();\n }\n}\n\nfunction reloadSessionMessages(\n sessionManager: SessionManager,\n conversationId: string,\n agent: Agent,\n): void {\n const messages = sessionManager.buildSessionContext().messages;\n if (messages.length > 0) {\n agent.state.messages = messages;\n log.logInfo(`[${conversationId}] Reloaded ${messages.length} messages from context`);\n }\n}\n\nasync function prepareRunContext(params: {\n message: ChatMessage;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n conversationId: string;\n conversationDir: string;\n sessionUuid: string;\n runState: RunnerSessionState;\n executor: Executor;\n executionResolver?: ActorExecutionResolver;\n resolveExecutorForRun: RunnerExecutionContext[\"resolveExecutorForRun\"];\n getPathContext: () => RuntimePathContext;\n sessionManager: SessionManager;\n session: AgentSession;\n agent: Agent;\n setEventContext: (context: {\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId: string;\n }) => void;\n setSandboxContext: (context: { conversationId: string; userId: string }) => void;\n setUploadFunction: (fn: (filePath: string, title?: string) => Promise<void>) => void;\n pathContext: RuntimePathContext;\n}): Promise<PreparedRunContext & { pathContext: RuntimePathContext }> {\n const {\n message,\n responseCtx,\n platform,\n conversationId,\n conversationDir,\n sessionUuid,\n runState,\n executor,\n executionResolver,\n resolveExecutorForRun,\n getPathContext,\n sessionManager,\n session,\n agent,\n setEventContext,\n setSandboxContext,\n setUploadFunction,\n } = params;\n let pathContext = params.pathContext;\n const sessionConversation = message.sessionKey.split(\":\")[0];\n\n await mkdir(join(conversationDir, \"scratch\"), { recursive: true });\n\n if (executionResolver) {\n await resolveExecutorForRun({\n platform: platform.name,\n userId: message.userId,\n conversationId,\n });\n pathContext = getPathContext();\n }\n\n reloadSessionMessages(sessionManager, conversationId, agent);\n\n const memory = await getMemory(conversationDir);\n const skills = loadMikanSkills(conversationDir, pathContext.runtimeWorkspaceRoot);\n const triggerAttribution = resolveTriggerAttribution(message);\n const systemPrompt = buildSystemPrompt(\n pathContext.runtimeWorkspaceRoot,\n conversationId,\n message.conversationKind,\n message.userId,\n memory,\n executor.getSandboxConfig(),\n platform,\n skills,\n message.id.startsWith(\"event:\"),\n triggerAttribution,\n );\n session.agent.state.systemPrompt = systemPrompt;\n\n setEventContext({\n platform: platform.name,\n conversationId,\n conversationKind: message.conversationKind,\n userId: message.userId,\n });\n setSandboxContext({ conversationId, userId: message.userId });\n\n setUploadFunction(async (filePath: string, title?: string) => {\n const hostPath = translateRuntimePathToHost(filePath, pathContext);\n await responseCtx.uploadFile(hostPath, title);\n });\n\n resetRunState(runState, responseCtx, sessionConversation, message.userName, sessionUuid);\n const runQueue = createRunQueue(responseCtx);\n runState.queue = runQueue.queue;\n\n log.logInfo(\n `Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`,\n );\n log.logInfo(`Channels: ${platform.channels.length}, Users: ${platform.users.length}`);\n\n const { userMessage, imageAttachments } = buildPromptPayload(\n message,\n pathContext.runtimeWorkspaceRoot,\n pathContext,\n );\n await writePromptDebugContext(\n conversationDir,\n systemPrompt,\n session,\n userMessage,\n imageAttachments.length,\n );\n\n return {\n sessionConversation,\n runQueue,\n userMessage,\n imageAttachments,\n triggerAttribution,\n pathContext,\n };\n}\n\nfunction attachSessionEventHandlers(params: {\n session: AgentSession;\n runState: RunnerSessionState;\n model: Model<Api>;\n agentConfig: ReturnType<typeof loadAgentConfigForConversation>;\n}): void {\n const { session, runState, model, agentConfig } = params;\n session.subscribe(async (event) => {\n if (!runState.responseCtx || !runState.logCtx || !runState.queue) return;\n\n const { responseCtx, logCtx, queue, pendingTools } = runState;\n const baseAttrs = { channel_id: logCtx.conversationId, session_id: logCtx.sessionId };\n\n if (event.type === \"tool_execution_start\") {\n const args = (event.args ?? {}) as { label?: string };\n const label = args.label || event.toolName;\n\n pendingTools.set(event.toolCallId, {\n toolName: event.toolName,\n args: event.args,\n startTime: Date.now(),\n });\n addLifecycleBreadcrumb(\"agent.tool.started\", {\n tool: event.toolName,\n ...baseAttrs,\n });\n\n log.logToolStart(logCtx, event.toolName, label, event.args as Record<string, unknown>);\n return;\n }\n\n if (event.type === \"tool_execution_end\") {\n const resultStr = extractToolResultText(event.result);\n const pending = pendingTools.get(event.toolCallId);\n pendingTools.delete(event.toolCallId);\n const durationMs = pending ? Date.now() - pending.startTime : 0;\n\n Sentry.metrics.count(\"agent.tool.calls\", 1, {\n attributes: metricAttributes({\n tool: event.toolName,\n error: String(event.isError),\n ...baseAttrs,\n }),\n });\n Sentry.metrics.distribution(\"agent.tool.duration\", durationMs, {\n unit: \"millisecond\",\n attributes: metricAttributes({\n tool: event.toolName,\n ...baseAttrs,\n }),\n });\n addLifecycleBreadcrumb(\"agent.tool.completed\", {\n tool: event.toolName,\n error: event.isError,\n duration_ms: durationMs,\n ...baseAttrs,\n });\n\n if (event.isError) {\n log.logToolError(logCtx, event.toolName, durationMs, resultStr);\n } else {\n log.logToolSuccess(logCtx, event.toolName, durationMs, resultStr);\n }\n\n if (shouldSurfaceToolDiagnostic(event.toolName)) {\n const toolResult: ChatToolResult = {\n toolName: event.toolName,\n label: pending?.args ? (pending.args as { label?: string }).label : undefined,\n args: pending?.args as Record<string, unknown> | undefined,\n result: truncate(resultStr, TOOL_RESULT_DIAGNOSTIC_CAP),\n isError: event.isError,\n durationMs,\n };\n queue.enqueue(() => responseCtx.respondToolResult(toolResult), \"tool result diagnostic\");\n }\n\n if (event.isError && shouldSurfaceToolDiagnostic(event.toolName)) {\n queue.enqueue(\n () => responseCtx.respond(`_Error: ${truncate(resultStr, 200)}_`),\n \"tool error\",\n );\n }\n return;\n }\n\n if (event.type === \"message_start\") {\n if (event.message.role === \"assistant\") {\n runState.llmCallCount += 1;\n addLifecycleBreadcrumb(\"agent.llm.call.started\", {\n call_index: runState.llmCallCount,\n provider: model.provider,\n model: agentConfig.model,\n ...baseAttrs,\n });\n log.logResponseStart(logCtx);\n }\n return;\n }\n\n if (event.type === \"message_end\") {\n if (event.message.role === \"assistant\") {\n const assistantMsg = event.message;\n\n if (assistantMsg.stopReason) {\n runState.stopReason = assistantMsg.stopReason;\n }\n if (assistantMsg.errorMessage) {\n runState.errorMessage = assistantMsg.errorMessage;\n }\n\n if (assistantMsg.usage) {\n runState.totalUsage.input += assistantMsg.usage.input;\n runState.totalUsage.output += assistantMsg.usage.output;\n runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n runState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n runState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n runState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\n const llmAttributes = metricAttributes({\n provider: model.provider,\n model: agentConfig.model,\n ...baseAttrs,\n stop_reason: assistantMsg.stopReason,\n error: Boolean(assistantMsg.errorMessage),\n });\n Sentry.metrics.count(\"agent.llm.calls\", 1, { attributes: llmAttributes });\n Sentry.metrics.distribution(\"agent.llm.tokens_in\", assistantMsg.usage.input, {\n attributes: llmAttributes,\n });\n Sentry.metrics.distribution(\"agent.llm.tokens_out\", assistantMsg.usage.output, {\n attributes: llmAttributes,\n });\n if (assistantMsg.usage.cacheRead > 0) {\n Sentry.metrics.distribution(\"agent.llm.cache_read\", assistantMsg.usage.cacheRead, {\n attributes: llmAttributes,\n });\n }\n if (assistantMsg.usage.cacheWrite > 0) {\n Sentry.metrics.distribution(\"agent.llm.cache_write\", assistantMsg.usage.cacheWrite, {\n attributes: llmAttributes,\n });\n }\n Sentry.metrics.distribution(\"agent.llm.cost_per_turn\", assistantMsg.usage.cost.total, {\n attributes: llmAttributes,\n });\n addLifecycleBreadcrumb(\"agent.llm.call.completed\", {\n call_index: runState.llmCallCount,\n provider: model.provider,\n model: agentConfig.model,\n stop_reason: assistantMsg.stopReason,\n error: Boolean(assistantMsg.errorMessage),\n input_tokens: assistantMsg.usage.input,\n output_tokens: assistantMsg.usage.output,\n cost_total_usd: assistantMsg.usage.cost.total,\n });\n }\n\n const thinkingParts: string[] = [];\n const textParts: string[] = [];\n for (const part of assistantMsg.content) {\n if (part.type === \"thinking\") {\n thinkingParts.push(part.thinking);\n } else if (part.type === \"text\") {\n textParts.push(part.text);\n }\n }\n\n const text = textParts.join(\"\\n\");\n\n for (const thinking of thinkingParts) {\n log.logThinking(logCtx, thinking);\n queue.enqueue(() => responseCtx.respond(`_${thinking}_`), \"thinking main\");\n queue.enqueue(\n () => responseCtx.respondDiagnostic(`_${thinking}_`),\n \"thinking diagnostic\",\n );\n }\n\n if (text.trim()) {\n log.logResponse(logCtx, text);\n queue.enqueue(() => responseCtx.respond(text), \"response main\");\n }\n }\n return;\n }\n\n if (event.type === \"compaction_start\") {\n log.logInfo(`Auto-compaction started (reason: ${event.reason})`);\n queue.enqueue(() => responseCtx.respond(\"_Compacting context..._\"), \"compaction start\");\n return;\n }\n\n if (event.type === \"compaction_end\") {\n if (event.result) {\n log.logInfo(`Auto-compaction complete: ${event.result.tokensBefore} tokens compacted`);\n } else if (event.aborted) {\n log.logInfo(\"Auto-compaction aborted\");\n }\n return;\n }\n\n if (event.type === \"auto_retry_start\") {\n log.logWarning(`Retrying (${event.attempt}/${event.maxAttempts})`, event.errorMessage);\n queue.enqueue(\n () => responseCtx.respond(`_Retrying (${event.attempt}/${event.maxAttempts})..._`),\n \"retry\",\n );\n }\n });\n}\n\n// Cap raw tool output before handing it to adapters. Bash output can be MB; without\n// this each adapter's splitter would fan it out into many sequential platform posts.\nconst TOOL_RESULT_DIAGNOSTIC_CAP = 8000;\n\nfunction extractToolResultText(result: unknown): string {\n if (typeof result === \"string\") {\n return result;\n }\n\n if (\n result &&\n typeof result === \"object\" &&\n \"content\" in result &&\n Array.isArray((result as { content: unknown }).content)\n ) {\n const content = (result as { content: Array<{ type: string; text?: string }> }).content;\n const textParts: string[] = [];\n for (const part of content) {\n if (part.type === \"text\" && part.text) {\n textParts.push(part.text);\n }\n }\n if (textParts.length > 0) {\n return textParts.join(\"\\n\");\n }\n }\n\n return JSON.stringify(result);\n}\n\n/**\n * Create a new AgentRunner for a channel.\n * Sets up the session and subscribes to events once.\n *\n * Runner caching is handled by the caller (channelStates in main.ts).\n * This is a stateless factory function.\n */\nexport async function createRunner(\n sandboxConfig: SandboxConfig,\n sessionKey: string,\n conversationId: string,\n conversationDir: string,\n workspaceDir: string,\n sessionScope: ResolvedSessionScope,\n vaultManager?: VaultManager,\n provisioner?: DockerContainerManager,\n sessionView?: {\n tokenStore: SessionViewTokenStoreLike;\n portalBaseUrl?: string;\n },\n): Promise<AgentRunner> {\n const agentConfig = loadAgentConfigForConversation(conversationDir);\n\n const workspaceBase = join(conversationDir, \"..\");\n const { executionResolver, executor, getPathContext, resolveExecutorForRun } =\n createRunnerExecutionContext(\n sandboxConfig,\n vaultManager,\n provisioner,\n workspaceDir,\n workspaceBase,\n );\n let pathContext = getUnresolvedSandboxPathContext(sandboxConfig, workspaceBase);\n\n // Create tools (per-runner, with per-runner upload function setter)\n const { tools, setUploadFunction, setEventContext, setSandboxContext } = createMikanTools(\n executor,\n workspaceDir,\n { sandbox: sandboxConfig, provisioner },\n );\n\n // Resolve model from config. Config stores provider/model as user-provided strings,\n // while getModel's public overload is narrowed to generated known providers.\n const model = getModel(agentConfig.provider as never, agentConfig.model as never) as Model<Api>;\n\n // Initial system prompt (will be updated each run with fresh memory/channels/users/skills)\n const memory = await getMemory(conversationDir);\n const skills = loadMikanSkills(conversationDir, pathContext.runtimeWorkspaceRoot);\n const emptyPlatform: PlatformInfo = {\n name: \"chat\",\n formattingGuide: \"\",\n channels: [],\n users: [],\n };\n const systemPrompt = buildSystemPrompt(\n pathContext.runtimeWorkspaceRoot,\n conversationId,\n \"shared\",\n undefined,\n memory,\n sandboxConfig,\n emptyPlatform,\n skills,\n );\n\n // Create session manager and settings manager. Top-level/private sessions\n // use the conversation's current pointer; scoped sessions use fixed files.\n // Platform-specific scope behavior is resolved before runner creation.\n const isThread = sessionKey.includes(\":\");\n const { sessionDir, contextFile, threadRootMessage } = sessionScope;\n const sessionManager = openManagedSession(\n contextFile,\n sessionDir,\n pathContext.runtimeWorkspaceRoot,\n );\n const threadSessionName = buildThreadSessionName(threadRootMessage);\n if (isThread && threadSessionName && sessionManager.getSessionName() !== threadSessionName) {\n sessionManager.appendSessionInfo(threadSessionName);\n }\n\n const sessionUuid = extractSessionUuid(contextFile);\n const chatSessionManager = new ChatSessionManager();\n const settingsManager = SettingsManager.inMemory();\n const { agent, session } = await createConfiguredAgentSession({\n conversationId,\n workspaceDir,\n runtimeWorkspaceRoot: pathContext.runtimeWorkspaceRoot,\n systemPrompt,\n model,\n thinkingLevel: agentConfig.thinkingLevel,\n tools,\n sessionManager,\n settingsManager,\n });\n\n // Mutable per-run state - event handler references this\n const runState = createRunState();\n attachSessionEventHandlers({ session, runState, model, agentConfig });\n\n return {\n syncChatHistory(currentMessageId?: string): void {\n chatSessionManager.syncSessionManager({\n conversationDir,\n sessionKey,\n sessionManager,\n currentMessageId,\n });\n },\n\n async run(\n message: ChatMessage,\n responseCtx: ChatResponseContext,\n platform: PlatformInfo,\n ): Promise<{ stopReason: string; errorMessage?: string }> {\n const prepared = await prepareRunContext({\n message,\n responseCtx,\n platform,\n conversationId,\n conversationDir,\n sessionUuid,\n runState,\n executor,\n executionResolver,\n resolveExecutorForRun,\n getPathContext,\n sessionManager,\n session,\n agent,\n setEventContext,\n setSandboxContext,\n setUploadFunction,\n pathContext,\n });\n pathContext = prepared.pathContext;\n\n addLifecycleBreadcrumb(\"agent.prompt.sent\", {\n provider: model.provider,\n model: agentConfig.model,\n channel_id: prepared.sessionConversation,\n session_id: sessionUuid,\n attachment_count: message.attachments?.length ?? 0,\n image_attachment_count: prepared.imageAttachments.length,\n });\n\n await session.prompt(\n prepared.userMessage,\n prepared.imageAttachments.length > 0 ? { images: prepared.imageAttachments } : undefined,\n );\n\n // Wait for queued messages\n await prepared.runQueue.wait();\n\n const sessionViewTokenStore = sessionView?.tokenStore;\n const sessionViewPortalBaseUrl = sessionView?.portalBaseUrl;\n const createOverflowLink =\n sessionViewTokenStore && sessionViewPortalBaseUrl\n ? () => {\n const token = sessionViewTokenStore.create(\n platform.name as PlatformName,\n message.userId,\n conversationId,\n message.sessionKey,\n contextFile,\n message.userName,\n );\n return `${sessionViewPortalBaseUrl}/session?token=${token.token}`;\n }\n : undefined;\n\n await finalizeRunResponse(responseCtx, session, runState, {\n triggerAttribution: prepared.triggerAttribution,\n createOverflowLink,\n platform: platform.name,\n model,\n sessionConversation: prepared.sessionConversation,\n sessionUuid,\n });\n\n await reportUsageSummary({\n session,\n runState,\n responseCtx,\n platform,\n model,\n agentConfig,\n sessionConversation: prepared.sessionConversation,\n sessionUuid,\n waitForQueue: () => prepared.runQueue.wait(),\n });\n\n // Clear run state\n runState.responseCtx = null;\n runState.logCtx = null;\n runState.queue = null;\n\n return { stopReason: runState.stopReason, errorMessage: runState.errorMessage };\n },\n\n abort(): void {\n session.abort();\n },\n\n getCurrentStep(): { toolName?: string; label?: string } | undefined {\n const pending = runState.pendingTools;\n if (pending.size === 0) return undefined;\n // Get the first pending tool\n const first = pending.values().next().value;\n if (!first) return undefined;\n return {\n toolName: first.toolName,\n label: (first.args as { label?: string })?.label,\n };\n },\n };\n}\n\nexport function translateRuntimePathToHost(\n runtimePath: string,\n pathContext: RuntimePathContext,\n): string {\n return pathContext.runtimeToHostPath?.(runtimePath) ?? runtimePath;\n}\n\nexport function buildInitialPathContextForTest(\n sandboxConfig: SandboxConfig,\n hostWorkspaceRoot: string,\n): RuntimePathContext {\n return getUnresolvedSandboxPathContext(sandboxConfig, hostWorkspaceRoot);\n}\n"]}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AACA,OAAO,EAAsB,KAAK,YAAY,EAAc,MAAM,uBAAuB,CAAC;AAkB1F,OAAO,KAAK,EACV,WAAW,EACX,mBAAmB,EAGnB,YAAY,EAEb,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AAKrE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,EAGL,KAAK,kBAAkB,EACvB,KAAK,aAAa,EACnB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,OAAO,EAGL,KAAK,oBAAoB,EAE1B,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,WAAW;IAC1B,eAAe,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjD,GAAG,CACD,OAAO,EAAE,WAAW,EACpB,WAAW,EAAE,mBAAmB,EAChC,QAAQ,EAAE,YAAY,GACrB,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,KAAK,IAAI,IAAI,CAAC;IACd,6DAA6D;IAC7D,cAAc,IAAI;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACrE;AAyID,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,MAAM,GAAG,UAAU,CAAC,GACrD,MAAM,GAAG,SAAS,CAOpB;AA0MD,wBAAgB,+BAA+B,CAC7C,aAAa,EAAE,aAAa,EAC5B,iBAAiB,EAAE,MAAM,GACxB,kBAAkB,CAMpB;AA2TD,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EACpB,aAAa,EAAE,MAAM,EACrB,WAAW,CAAC,EAAE,kBAAkB,GAC/B;IACD,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,YAAY,EAAE,CAAC;CAClC,CAaA;AA+BD,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,MAAM,CAKR;AAokBD;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,aAAa,EAC5B,UAAU,EAAE,MAAM,EAClB,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,oBAAoB,EAClC,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,sBAAsB,EACpC,WAAW,CAAC,EAAE;IACZ,UAAU,EAAE,yBAAyB,CAAC;IACtC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACA,OAAO,CAAC,WAAW,CAAC,CAsMtB;AAED,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,kBAAkB,GAC9B,MAAM,CAER;AAED,wBAAgB,8BAA8B,CAC5C,aAAa,EAAE,aAAa,EAC5B,iBAAiB,EAAE,MAAM,GACxB,kBAAkB,CAEpB","sourcesContent":["import { Agent, type ThinkingLevel } from \"@earendil-works/pi-agent-core\";\nimport { getModel, type Api, type ImageContent, type Model } from \"@earendil-works/pi-ai\";\nimport {\n AgentSession,\n AuthStorage,\n convertToLlm,\n DefaultResourceLoader,\n formatSkillsForPrompt,\n getAgentDir,\n loadSkillsFromDir,\n ModelRegistry,\n SessionManager,\n SettingsManager,\n type Skill,\n} from \"@earendil-works/pi-coding-agent\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { mkdir, readFile, writeFile } from \"fs/promises\";\nimport { homedir } from \"os\";\nimport { join, posix } from \"path\";\nimport type {\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n ConversationKind,\n PlatformInfo,\n PlatformName,\n} from \"./adapter.js\";\nimport type { SessionViewTokenStoreLike } from \"./commands/types.js\";\nimport { loadAgentConfigForConversation } from \"./config.js\";\nimport { ActorExecutionResolver } from \"./execution-resolver.js\";\nimport * as log from \"./log.js\";\nimport { reportUserFacingError } from \"./sentry.js\";\nimport type { DockerContainerManager } from \"./provisioner.js\";\nimport {\n createExecutor,\n type Executor,\n type RuntimePathContext,\n type SandboxConfig,\n} from \"./sandbox/index.js\";\nimport { createMountedRuntimePathContext } from \"./sandbox/path-context.js\";\nimport { addLifecycleBreadcrumb, metricAttributes } from \"./sentry.js\";\nimport type { VaultManager } from \"./vault.js\";\nimport { ChatSessionManager } from \"./sessions/chat-session-manager.js\";\nimport {\n extractSessionUuid,\n openManagedSession,\n type ResolvedSessionScope,\n type ThreadRootMessage,\n} from \"./sessions/store.js\";\nimport { shouldSurfaceToolDiagnostic } from \"./tool-diagnostics.js\";\nimport { createMikanTools } from \"./tools/index.js\";\nimport * as Sentry from \"@sentry/node\";\n\nexport interface AgentRunner {\n syncChatHistory(currentMessageId?: string): void;\n run(\n message: ChatMessage,\n responseCtx: ChatResponseContext,\n platform: PlatformInfo,\n ): Promise<{ stopReason: string; errorMessage?: string }>;\n abort(): void;\n /** Get current step info (tool name, label) for debugging */\n getCurrentStep(): { toolName?: string; label?: string } | undefined;\n}\n\nconst IMAGE_MIME_TYPES: Record<string, string> = {\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n webp: \"image/webp\",\n};\n\nfunction getImageMimeType(filename: string): string | undefined {\n return IMAGE_MIME_TYPES[filename.toLowerCase().split(\".\").pop() || \"\"];\n}\n\nfunction buildThreadSessionName(message: ThreadRootMessage | null): string | undefined {\n const text = message?.text?.trim();\n if (!text) return undefined;\n const userLabel = message?.userName || message?.user || \"unknown\";\n return `[${userLabel}]: ${text}`;\n}\n\nasync function getMemory(conversationDir: string): Promise<string> {\n const parts: string[] = [];\n\n // Read workspace-level memory (shared across all conversations)\n const workspaceMemoryPath = join(conversationDir, \"..\", \"MEMORY.md\");\n if (existsSync(workspaceMemoryPath)) {\n try {\n const content = (await readFile(workspaceMemoryPath, \"utf-8\")).trim();\n if (content) {\n parts.push(`### Global Workspace Memory\\n${content}`);\n }\n } catch (error) {\n log.logWarning(\"Failed to read workspace memory\", `${workspaceMemoryPath}: ${error}`);\n }\n }\n\n // Read conversation-specific memory\n const conversationMemoryPath = join(conversationDir, \"MEMORY.md\");\n if (existsSync(conversationMemoryPath)) {\n try {\n const content = (await readFile(conversationMemoryPath, \"utf-8\")).trim();\n if (content) {\n parts.push(`### Conversation-Specific Memory\\n${content}`);\n }\n } catch (error) {\n log.logWarning(\"Failed to read conversation memory\", `${conversationMemoryPath}: ${error}`);\n }\n }\n\n if (parts.length === 0) {\n return \"(no working memory yet)\";\n }\n\n return parts.join(\"\\n\\n\");\n}\n\nfunction loadMikanSkills(conversationDir: string, workspacePath: string): Skill[] {\n const skillMap = new Map<string, Skill>();\n\n // conversationDir is the host path (e.g., /Users/.../data/C0A34FL8PMH)\n // hostWorkspacePath is the parent directory on host\n // workspacePath is the container path (e.g., /workspace)\n const hostWorkspacePath = join(conversationDir, \"..\");\n\n // Helper to translate host paths to container paths\n const translatePath = (hostPath: string): string => {\n if (hostPath.startsWith(hostWorkspacePath)) {\n return workspacePath + hostPath.slice(hostWorkspacePath.length);\n }\n return hostPath;\n };\n\n // Load workspace-level skills (global)\n const workspaceSkillsDir = join(hostWorkspacePath, \"skills\");\n for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: \"workspace\" }).skills) {\n // Translate paths to container paths for system prompt\n skill.filePath = translatePath(skill.filePath);\n skill.baseDir = translatePath(skill.baseDir);\n skillMap.set(skill.name, skill);\n }\n\n // Load conversation-specific skills (override workspace skills on collision)\n const conversationSkillsDir = join(conversationDir, \"skills\");\n for (const skill of loadSkillsFromDir({ dir: conversationSkillsDir, source: \"channel\" }).skills) {\n skill.filePath = translatePath(skill.filePath);\n skill.baseDir = translatePath(skill.baseDir);\n skillMap.set(skill.name, skill);\n }\n\n return Array.from(skillMap.values());\n}\n\nfunction buildRuntimePaths(runtimeWorkspaceRoot: string, conversationId: string) {\n const workspaceRoot = runtimeWorkspaceRoot.replace(/\\/+$/, \"\") || \"/\";\n const conversationPath = posix.join(workspaceRoot, conversationId);\n return {\n workspaceRoot,\n conversationPath,\n scratchPath: posix.join(conversationPath, \"scratch\"),\n };\n}\n\nfunction buildEnvDescription(sandboxType: SandboxConfig[\"type\"], workspaceRoot: string): string {\n switch (sandboxType) {\n case \"image\":\n return `You are running inside a managed per-user container.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${workspaceRoot}\n- Install tools with the image's package manager\n- Your changes persist for this user's container until it is recreated`;\n case \"container\":\n return `You are running inside a shared container.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${workspaceRoot}\n- Install tools with the container's package manager\n- Your changes persist across sessions`;\n case \"firecracker\":\n return `You are running inside a Firecracker microVM.\n- Runtime workspace root: ${workspaceRoot}\n- Use cd or absolute paths; project files are under ${workspaceRoot}\n- Install tools with: apt-get install <package> (Debian-based)\n- Your changes persist across sessions`;\n case \"cloudflare\":\n return `You are running through a Cloudflare Sandbox bridge.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${workspaceRoot}\n- Your commands run in a remote container managed by Cloudflare\n- Important: the remote filesystem is not automatically synced back to the host workspace`;\n default:\n return `You are running directly on the host machine.\n- Runtime workspace root: ${workspaceRoot}\n- Bash commands start in: ${process.cwd()}\n- Be careful with system modifications`;\n }\n}\n\nexport function resolveTriggerAttribution(\n message: Pick<ChatMessage, \"id\" | \"text\" | \"userName\">,\n): string | undefined {\n const eventTextMatch = message.text.match(/^\\[EVENT:([^:]+):/);\n if (eventTextMatch) return `[event: ${eventTextMatch[1]}]`;\n const eventIdMatch = message.id.match(/^event:([^:]+)/);\n if (eventIdMatch) return `[event: ${eventIdMatch[1]}]`;\n if (message.userName) return `@${message.userName}`;\n return undefined;\n}\n\nfunction buildSystemPrompt(\n workspacePath: string,\n conversationId: string,\n conversationKind: ConversationKind,\n currentUserId: string | undefined,\n memory: string,\n sandboxConfig: SandboxConfig,\n platform: PlatformInfo,\n skills: Skill[],\n isEventTrigger = false,\n triggerAttribution?: string,\n): string {\n const { workspaceRoot, conversationPath, scratchPath } = buildRuntimePaths(\n workspacePath,\n conversationId,\n );\n const sandboxType = sandboxConfig.type;\n const isContainerLike = sandboxType === \"container\" || sandboxType === \"image\";\n const isFirecracker = sandboxType === \"firecracker\";\n\n // Format channel mappings\n const channelMappings =\n platform.channels.length > 0\n ? platform.channels.map((c) => `${c.id}\\t#${c.name}`).join(\"\\n\")\n : \"(no channels loaded)\";\n\n // Format user mappings\n const userMappings =\n platform.users.length > 0\n ? platform.users.map((u) => `${u.id}\\t@${u.userName}\\t${u.displayName}`).join(\"\\n\")\n : \"(no users loaded)\";\n\n const envDescription = buildEnvDescription(sandboxType, workspaceRoot);\n const eventTriggerInstructions = isEventTrigger\n ? `\n## Event Trigger Mode\n- You are handling a scheduled/background event, not opening a brand new chat with a stranger.\n- Treat the incoming user message as a self-contained task prepared by an earlier run.\n- Complete the task directly. Avoid generic greetings, self-introductions, or boilerplate offers to help.\n- For reminders/follow-ups, prefer a short direct response that sounds like a continuation of prior intent.\n- If the event text includes tone, brevity, or language instructions, follow them literally.\n`\n : \"\";\n const attributionInstructions = triggerAttribution\n ? `\n## Attribution\nAlways end your final ${platform.name} response and any GitHub issue/PR comments or descriptions you write via tools with:\n_Triggered by ${triggerAttribution}_\n\nDo not add this to \\`[SILENT]\\` responses.\n`\n : \"\";\n const slackBlockKitInstructions =\n platform.name === \"slack\"\n ? `\n## Slack Block Kit\n- On Slack, use the \\`slack_blockkit\\` tool when a response benefits from interaction or structured presentation.\n- Good uses: choices, confirmations, clarifying questions, quizzes, status reports, comparisons, summaries, and step lists.\n- Do not use it for normal short replies or simple factual answers.\n- Supported blocks: section, context, divider, header, actions.\n- Put buttons in actions.elements. Put static_select and multi_static_select in section.accessory.\n- Every interactive element must include action_id. Buttons must include value.\n- Always provide a plain-text fallback in text that matches the visible Block Kit content.\n- When using \\`slack_blockkit\\`, do not also send a normal text response in the same assistant turn; put the fallback in the tool's text field.\n`\n : \"\";\n\n return `You are mikan, a ${platform.name} bot assistant. Be concise. No emojis.\n\n## Context\n- For current date/time, use: date\n- You have access to previous conversation context including tool results from prior turns.\n- For older human-readable history beyond your context, search \\`log.jsonl\\` (contains user messages and your final responses, but not tool results).\n- Structured session history with tool results lives in \\`${conversationPath}/sessions/\\`.\n- The active top-level session is selected by \\`${conversationPath}/sessions/current\\`, which points to a timestamped \\`.jsonl\\` file in the same directory.\n- Scoped/thread sessions use fixed files at \\`${conversationPath}/sessions/<scope_id>.jsonl\\` (for example \\`${conversationPath}/sessions/1777386320.800769.jsonl\\`).\n- If a user asks about something that should exist in conversation history but is not found in the current context window, do not answer \"I don't know\" or \"I don't have that\". Instead, search the thread session, top-level session, and \\`log.jsonl\\` before responding.\n- User messages include a \\`[in-thread:TS]\\` marker when sent from within a platform thread/reply (TS is the thread or parent message identifier). Without this marker, the message is a top-level conversation message.\n${eventTriggerInstructions}\n${platform.formattingGuide}${slackBlockKitInstructions}\n\n## Platform IDs\nChannels: ${channelMappings}\n\nUsers: ${userMappings}\n\nWhen mentioning users, use <@username> format (e.g., <@mario>).\n\n## Environment\n${envDescription}\n- Default place for clones, downloads, and experiments: ${scratchPath}\n- Do not use host-only paths unless you are running in host mode and verified they exist.\n\n## Workspace Layout\n${workspaceRoot}/\n├── MEMORY.md # Global memory (all conversations)\n├── skills/ # Global CLI tools you create\n└── ${conversationId}/ # This conversation\n ├── MEMORY.md # Conversation-specific memory\n ├── log.jsonl # Human-readable message history (no tool results)\n ├── sessions/ # Structured session history used for context reconstruction\n │ ├── current # Active top-level session pointer\n │ ├── <timestamp>_<id>.jsonl # Top-level session files\n │ └── <scope_id>.jsonl # Scoped thread/reply session files\n ├── attachments/ # User-shared files\n ├── scratch/ # Working directory for clones/downloads/experiments: ${scratchPath}\n └── skills/ # Conversation-specific tools\n\n## Skills (Custom CLI Tools)\nYou can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).\n\n### Creating Skills\nStore in \\`${workspaceRoot}/skills/<name>/\\` (global) or \\`${conversationPath}/skills/<name>/\\` (conversation-specific).\nEach skill directory needs a \\`SKILL.md\\` with YAML frontmatter:\n\n\\`\\`\\`markdown\n---\nname: skill-name\ndescription: Short description of what this skill does\n---\n\n# Skill Name\n\nUsage instructions, examples, etc.\nScripts are in: {baseDir}/\n\\`\\`\\`\n\n\\`name\\` and \\`description\\` are required. Use \\`{baseDir}\\` as placeholder for the skill's directory path.\n\n### Available Skills\n${skills.length > 0 ? formatSkillsForPrompt(skills) : \"(no skills installed yet)\"}\n\n## Events\nUse the \\`event\\` tool to schedule immediate, one-shot, or periodic follow-ups. It writes to the host-side mikan control plane and fills routing fields for the current conversation automatically.\n\nWrite event \\`text\\` as a self-contained future task with needed context, tone, and constraints because events do not inherit normal conversation history.\n\nFor one-shot reminders, include a timezone offset in \\`at\\`. For periodic events, use a cron schedule plus IANA timezone; assume ${Intl.DateTimeFormat().resolvedOptions().timeZone} when users omit timezone.\n\nWhen events trigger, messages are prefixed like \\`[EVENT:filename:type:time]\\`. Immediate and one-shot events auto-delete after triggering; periodic events persist until deleted.\n\nFor periodic events where there's nothing to report, respond with exactly \\`[SILENT]\\`. Debounce external triggers; prefer one summarized event over many.\n\n## Memory\nWrite to MEMORY.md files to persist context across conversations.\n- Global (${workspaceRoot}/MEMORY.md): skills, preferences, project info\n- Conversation (${conversationPath}/MEMORY.md): conversation-specific decisions, ongoing work\nUpdate when you learn something important or when asked to remember something.\n\n### Current Memory\n${memory}\n\n## System Configuration Log\nMaintain ${workspaceRoot}/SYSTEM.md to log all environment modifications:\n- Installed packages (apt install, npm install, uv pip install)\n- Environment variables set\n- Config files modified (~/.gitconfig, cron jobs, etc.)\n- Skill dependencies installed\n\nUpdate this file whenever you modify the environment. On fresh container, read it first to restore your setup.\n\n## Log Queries (for older history)\nFormat: \\`{\"date\":\"...\",\"ts\":\"...\",\"user\":\"...\",\"userName\":\"...\",\"text\":\"...\",\"isBot\":false}\\`\nThe log contains user messages and your final responses (not tool calls/results).\nUse \\`log.jsonl\\` for quick grep-style history. Use \\`${conversationPath}/sessions/\\` when you need structured turns, tool outputs, or thread/session lineage.\n${isContainerLike || isFirecracker ? \"Install jq: apt-get install jq\" : \"\"}\n${attributionInstructions}\n\\`\\`\\`bash\n# Recent messages\ntail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\n# Search for specific topic\ngrep -i \"topic\" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'\n\n# Messages from specific user\ngrep '\"userName\":\"mario\"' log.jsonl | tail -20 | jq -c '{date: .date[0:19], text}'\n\n# Inspect top-level session pointer and available session files\ncat sessions/current\nls -1 sessions/\n\\`\\`\\`\n\n## Tools\n- bash: Run shell commands (primary tool). Install packages as needed.\n- read: Read files\n- write: Create/overwrite files\n- edit: Surgical file edits\n- event: Schedule immediate, one-shot, or periodic follow-ups\n- sandbox: Inspect or temporarily adjust sandbox limits\n- attach: Share files to the platform\n\nEach tool requires a \"label\" parameter (shown to user).\n`;\n}\n\nfunction truncate(text: string, maxLen: number): string {\n if (text.length <= maxLen) return text;\n return `${text.substring(0, maxLen - 3)}...`;\n}\n\nexport function getUnresolvedSandboxPathContext(\n sandboxConfig: SandboxConfig,\n hostWorkspaceRoot: string,\n): RuntimePathContext {\n if (sandboxConfig.type === \"image\") {\n return createMountedRuntimePathContext(hostWorkspaceRoot, \"/workspace\");\n }\n\n return createExecutor(sandboxConfig).getPathContext(hostWorkspaceRoot);\n}\n\ninterface RunnerExecutionContext {\n executionResolver?: ActorExecutionResolver;\n executor: Executor;\n getPathContext: () => RuntimePathContext;\n resolveExecutorForRun(context: {\n platform: string;\n userId: string;\n conversationId: string;\n }): Promise<void>;\n}\n\ninterface RunnerSessionState {\n responseCtx: ChatResponseContext | null;\n logCtx: {\n conversationId: string;\n userName?: string;\n conversationName?: string;\n sessionId?: string;\n } | null;\n queue: {\n enqueue(fn: () => Promise<void>, errorContext: string): void;\n } | null;\n pendingTools: Map<string, { toolName: string; args: unknown; startTime: number }>;\n totalUsage: {\n input: number;\n output: number;\n cacheRead: number;\n cacheWrite: number;\n cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n };\n llmCallCount: number;\n stopReason: string;\n errorMessage: string | undefined;\n reportedLlmError: boolean;\n finalResponseHandledByTool: boolean;\n}\n\ninterface PreparedRunContext {\n sessionConversation: string;\n runQueue: ReturnType<typeof createRunQueue>;\n userMessage: string;\n imageAttachments: ImageContent[];\n triggerAttribution?: string;\n}\n\ninterface ConfiguredAgentSession {\n agent: Agent;\n session: AgentSession;\n}\n\nfunction createRunnerExecutionContext(\n sandboxConfig: SandboxConfig,\n vaultManager: VaultManager | undefined,\n provisioner: DockerContainerManager | undefined,\n workspaceDir: string,\n hostWorkspacePath: string,\n): RunnerExecutionContext {\n const executionResolver =\n vaultManager &&\n sandboxConfig.type !== \"host\" &&\n (vaultManager.isEnabled() ||\n sandboxConfig.type === \"container\" ||\n sandboxConfig.type === \"image\" ||\n sandboxConfig.type === \"cloudflare\" ||\n sandboxConfig.type === \"firecracker\")\n ? new ActorExecutionResolver(sandboxConfig, vaultManager, provisioner, workspaceDir)\n : undefined;\n\n // activeExecutor is replaced at the start of each run() call when executionResolver\n // is present, so the stable `executor` wrapper always delegates to the latest resolved value.\n let activeExecutor: Executor =\n executionResolver !== undefined\n ? createExecutor({ type: \"host\" })\n : createExecutor(sandboxConfig);\n const executor: Executor = {\n exec(command, options) {\n return activeExecutor.exec(command, options);\n },\n getWorkspacePath(hostPath) {\n return activeExecutor.getWorkspacePath(hostPath);\n },\n getSandboxConfig() {\n return activeExecutor.getSandboxConfig();\n },\n getPathContext(hostWorkspaceRoot) {\n return activeExecutor.getPathContext(hostWorkspaceRoot);\n },\n };\n\n return {\n executionResolver,\n executor,\n getPathContext: () => executor.getPathContext(hostWorkspacePath),\n async resolveExecutorForRun(context): Promise<void> {\n if (!executionResolver) return;\n activeExecutor = await executionResolver.resolve(context);\n },\n };\n}\n\nasync function createConfiguredAgentSession(params: {\n conversationId: string;\n workspaceDir: string;\n runtimeWorkspaceRoot: string;\n systemPrompt: string;\n model: Model<Api>;\n thinkingLevel: ThinkingLevel;\n tools: Awaited<ReturnType<typeof createMikanTools>>[\"tools\"];\n sessionManager: SessionManager;\n settingsManager: SettingsManager;\n}): Promise<ConfiguredAgentSession> {\n const {\n conversationId,\n workspaceDir,\n runtimeWorkspaceRoot,\n systemPrompt,\n model,\n thinkingLevel,\n tools,\n sessionManager,\n settingsManager,\n } = params;\n\n const authStorage = AuthStorage.create(join(homedir(), \".pi\", \"mikan\", \"auth.json\"));\n const modelRegistry = ModelRegistry.create(authStorage);\n const agent = new Agent({\n initialState: {\n systemPrompt,\n model,\n thinkingLevel,\n tools,\n },\n convertToLlm,\n getApiKey: async () => {\n const key = await modelRegistry.getApiKeyForProvider(model.provider);\n if (!key) {\n throw new Error(\n `No API key for provider \"${model.provider}\". Set the appropriate environment variable or configure via auth.json`,\n );\n }\n return key;\n },\n });\n\n const loadedSession = sessionManager.buildSessionContext();\n if (loadedSession.messages.length > 0) {\n agent.state.messages = loadedSession.messages;\n log.logInfo(\n `[${conversationId}] Reloaded ${loadedSession.messages.length} messages from session context`,\n );\n }\n\n const resourceLoader = new DefaultResourceLoader({\n cwd: workspaceDir,\n agentDir: getAgentDir(),\n systemPrompt,\n });\n try {\n await resourceLoader.reload();\n const extResult = resourceLoader.getExtensions();\n if (extResult.errors.length > 0) {\n for (const err of extResult.errors) {\n log.logWarning(`[${conversationId}] Extension load error: ${err.path}`, err.error);\n }\n }\n log.logInfo(\n `[${conversationId}] Loaded ${extResult.extensions.length} extension(s): ${extResult.extensions.map((extension) => extension.path).join(\", \")}`,\n );\n } catch (error) {\n log.logWarning(`[${conversationId}] Failed to load resources`, String(error));\n }\n\n const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));\n const session = new AgentSession({\n agent,\n sessionManager,\n settingsManager,\n cwd: runtimeWorkspaceRoot,\n modelRegistry,\n resourceLoader,\n baseToolsOverride,\n });\n return { agent, session };\n}\n\nfunction createEmptyUsageTotals() {\n return {\n input: 0,\n output: 0,\n cacheRead: 0,\n cacheWrite: 0,\n cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n };\n}\n\nfunction createRunState(): RunnerSessionState {\n return {\n responseCtx: null,\n logCtx: null,\n queue: null,\n pendingTools: new Map<string, { toolName: string; args: unknown; startTime: number }>(),\n totalUsage: createEmptyUsageTotals(),\n llmCallCount: 0,\n stopReason: \"stop\",\n errorMessage: undefined,\n reportedLlmError: false,\n finalResponseHandledByTool: false,\n };\n}\n\nfunction resetRunState(\n runState: RunnerSessionState,\n responseCtx: ChatResponseContext,\n sessionConversation: string,\n userName: string | undefined,\n sessionUuid: string,\n): void {\n runState.responseCtx = responseCtx;\n runState.logCtx = {\n conversationId: sessionConversation,\n userName,\n conversationName: undefined,\n sessionId: sessionUuid,\n };\n runState.pendingTools.clear();\n runState.totalUsage = createEmptyUsageTotals();\n runState.llmCallCount = 0;\n runState.stopReason = \"stop\";\n runState.errorMessage = undefined;\n runState.reportedLlmError = false;\n runState.finalResponseHandledByTool = false;\n}\n\nfunction createRunQueue(\n responseCtx: ChatResponseContext,\n runState: RunnerSessionState,\n): {\n queue: { enqueue(fn: () => Promise<void>, errorContext: string): void };\n wait: () => Promise<void>;\n} {\n let queueChain = Promise.resolve();\n return {\n queue: {\n enqueue(fn: () => Promise<void>, errorContext: string): void {\n queueChain = queueChain.then(async () => {\n if (runState.finalResponseHandledByTool) return;\n try {\n await fn();\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(`API error (${errorContext})`, errMsg);\n try {\n await responseCtx.respondDiagnostic(`Error: ${errMsg}`, { style: \"error\" });\n } catch {\n // Ignore\n }\n }\n });\n },\n },\n wait: () => queueChain,\n };\n}\n\nfunction padTwoDigits(n: number): string {\n return n.toString().padStart(2, \"0\");\n}\n\nfunction formatTimestampedUserMessage(message: ChatMessage): string {\n const now = new Date();\n const offset = -now.getTimezoneOffset();\n const offsetSign = offset >= 0 ? \"+\" : \"-\";\n const offsetHours = padTwoDigits(Math.floor(Math.abs(offset) / 60));\n const offsetMins = padTwoDigits(Math.abs(offset) % 60);\n const timestamp =\n `${now.getFullYear()}-${padTwoDigits(now.getMonth() + 1)}-${padTwoDigits(now.getDate())} ` +\n `${padTwoDigits(now.getHours())}:${padTwoDigits(now.getMinutes())}:${padTwoDigits(now.getSeconds())}` +\n `${offsetSign}${offsetHours}:${offsetMins}`;\n const threadContext = message.threadTs ? ` [in-thread:${message.threadTs}]` : \"\";\n return `[${timestamp}] [${message.userName || \"unknown\"}]${threadContext}: ${message.text}`;\n}\n\nfunction collectMessageAttachments(\n message: ChatMessage,\n workspacePath: string,\n pathContext?: RuntimePathContext,\n): { imageAttachments: ImageContent[]; nonImagePaths: string[] } {\n const imageAttachments: ImageContent[] = [];\n const nonImagePaths: string[] = [];\n\n for (const attachment of message.attachments || []) {\n const runtimePath = `${workspacePath}/${attachment.localPath}`;\n const hostPath = pathContext?.runtimeToHostPath?.(runtimePath) ?? runtimePath;\n const mimeType = getImageMimeType(attachment.localPath);\n\n if (mimeType && existsSync(hostPath)) {\n try {\n imageAttachments.push({\n type: \"image\",\n mimeType,\n data: readFileSync(hostPath).toString(\"base64\"),\n });\n } catch {\n nonImagePaths.push(runtimePath);\n }\n } else {\n nonImagePaths.push(runtimePath);\n }\n }\n\n return { imageAttachments, nonImagePaths };\n}\n\nexport function buildPromptPayload(\n message: ChatMessage,\n workspacePath: string,\n pathContext?: RuntimePathContext,\n): {\n userMessage: string;\n imageAttachments: ImageContent[];\n} {\n let userMessage = formatTimestampedUserMessage(message);\n const { imageAttachments, nonImagePaths } = collectMessageAttachments(\n message,\n workspacePath,\n pathContext,\n );\n\n if (nonImagePaths.length > 0) {\n userMessage += `\\n\\n<slack_attachments>\\n${nonImagePaths.join(\"\\n\")}\\n</slack_attachments>`;\n }\n\n return { userMessage, imageAttachments };\n}\n\nasync function writePromptDebugContext(\n conversationDir: string,\n systemPrompt: string,\n session: AgentSession,\n userMessage: string,\n imageAttachmentCount: number,\n): Promise<void> {\n const debugContext = {\n systemPrompt,\n messages: session.messages,\n newUserMessage: userMessage,\n imageAttachmentCount,\n };\n await writeFile(\n join(conversationDir, \"last_prompt.jsonl\"),\n JSON.stringify(debugContext, null, 2),\n );\n}\n\nfunction getFinalAssistantText(session: AgentSession): string {\n const lastAssistant = session.messages.filter((message) => message.role === \"assistant\").pop();\n return (\n lastAssistant?.content\n .filter((content): content is { type: \"text\"; text: string } => content.type === \"text\")\n .map((content) => content.text)\n .join(\"\\n\") || \"\"\n );\n}\n\nexport function appendTriggerAttribution(\n text: string,\n triggerAttribution: string | undefined,\n): string {\n if (!triggerAttribution) return text;\n const suffix = `_Triggered by ${triggerAttribution}_`;\n if (text.trimEnd().endsWith(suffix)) return text;\n return `${text.trimEnd()}\\n\\n${suffix}`;\n}\n\nasync function finalizeRunResponse(\n responseCtx: ChatResponseContext,\n session: AgentSession,\n runState: RunnerSessionState,\n options?: {\n triggerAttribution?: string;\n createOverflowLink?: () => string;\n platform?: string;\n model?: Model<Api>;\n sessionConversation?: string;\n sessionUuid?: string;\n },\n): Promise<void> {\n if (runState.stopReason === \"error\" && runState.errorMessage) {\n if (!runState.reportedLlmError) {\n runState.reportedLlmError = true;\n reportUserFacingError(new Error(\"LLM run completed with error stop reason\"), {\n domain: \"llm\",\n surface: \"assistant_response\",\n operation: \"llm_turn\",\n severity: \"error\",\n platform: options?.platform,\n provider: options?.model?.provider,\n model: options?.model?.name,\n stopReason: runState.stopReason,\n context: {\n sessionConversation: options?.sessionConversation,\n sessionUuid: options?.sessionUuid,\n hasErrorMessage: true,\n llmCallCount: runState.llmCallCount,\n },\n });\n }\n try {\n await responseCtx.replaceResponse(\"_Sorry, something went wrong_\");\n await responseCtx.respondDiagnostic(`Error: ${runState.errorMessage}`, {\n style: \"error\",\n });\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(\"Failed to post error message\", errMsg);\n reportUserFacingError(err, {\n domain: \"chat_platform\",\n surface: \"final_response\",\n operation: \"finalize_error_response\",\n severity: \"error\",\n platform: options?.platform,\n context: {\n sessionConversation: options?.sessionConversation,\n sessionUuid: options?.sessionUuid,\n stopReason: runState.stopReason,\n },\n });\n }\n return;\n }\n\n const finalText = getFinalAssistantText(session);\n if (runState.finalResponseHandledByTool) {\n log.logInfo(\"Final response already handled by tool - skipping final replacement\");\n return;\n }\n if (finalText.trim() === \"[SILENT]\" || finalText.trim().startsWith(\"[SILENT]\")) {\n try {\n await responseCtx.deleteResponse();\n log.logInfo(\"Silent response - deleted message and thread\");\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(\"Failed to delete message for silent response\", errMsg);\n }\n return;\n }\n\n if (!finalText.trim()) return;\n\n try {\n await responseCtx.replaceResponse(\n appendTriggerAttribution(finalText, options?.triggerAttribution),\n { createOverflowLink: options?.createOverflowLink },\n );\n } catch (err) {\n const errMsg = err instanceof Error ? err.message : String(err);\n log.logWarning(\"Failed to replace message with final text\", errMsg);\n reportUserFacingError(err, {\n domain: \"chat_platform\",\n surface: \"final_response\",\n operation: \"replace_final_response\",\n severity: \"error\",\n platform: options?.platform,\n context: {\n sessionConversation: options?.sessionConversation,\n sessionUuid: options?.sessionUuid,\n finalTextLength: finalText.length,\n },\n });\n }\n}\n\ninterface UsageReportContext {\n session: AgentSession;\n runState: RunnerSessionState;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n model: Model<Api>;\n agentConfig: ReturnType<typeof loadAgentConfigForConversation>;\n sessionConversation: string;\n sessionUuid: string;\n waitForQueue: () => Promise<void>;\n}\n\nasync function reportUsageSummary(ctx: UsageReportContext): Promise<void> {\n const {\n session,\n runState,\n responseCtx,\n platform,\n model,\n agentConfig,\n sessionConversation,\n sessionUuid,\n waitForQueue,\n } = ctx;\n if (runState.totalUsage.cost.total <= 0) return;\n\n const lastAssistantMessage = session.messages\n .slice()\n .toReversed()\n .find(\n (message): message is Extract<typeof message, { role: \"assistant\" }> =>\n message.role === \"assistant\" && message.stopReason !== \"aborted\",\n );\n\n const contextTokens = lastAssistantMessage\n ? lastAssistantMessage.usage.input +\n lastAssistantMessage.usage.output +\n lastAssistantMessage.usage.cacheRead +\n lastAssistantMessage.usage.cacheWrite\n : 0;\n const contextWindow = model.contextWindow || 200000;\n\n const { totalUsage } = runState;\n const runMetricAttributes = metricAttributes({\n provider: model.provider,\n model: agentConfig.model,\n channel_id: sessionConversation,\n session_id: sessionUuid,\n stop_reason: runState.stopReason,\n llm_calls: runState.llmCallCount,\n });\n Sentry.metrics.distribution(\"agent.run.tokens_in\", totalUsage.input, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.tokens_out\", totalUsage.output, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.cache_read\", totalUsage.cacheRead, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.cache_write\", totalUsage.cacheWrite, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.distribution(\"agent.run.cost\", totalUsage.cost.total, {\n attributes: runMetricAttributes,\n });\n Sentry.metrics.gauge(\"agent.context.utilization\", contextTokens / contextWindow, {\n unit: \"ratio\",\n attributes: runMetricAttributes,\n });\n\n const summary = log.logUsageSummary(\n runState.logCtx!,\n runState.totalUsage,\n contextTokens,\n contextWindow,\n );\n if (platform.diagnostics?.showUsageSummary === true) {\n runState.queue!.enqueue(\n () => responseCtx.respondDiagnostic(summary, { style: \"muted\" }),\n \"usage summary\",\n );\n await waitForQueue();\n }\n}\n\nfunction reloadSessionMessages(\n sessionManager: SessionManager,\n conversationId: string,\n agent: Agent,\n): void {\n const messages = sessionManager.buildSessionContext().messages;\n if (messages.length > 0) {\n agent.state.messages = messages;\n log.logInfo(`[${conversationId}] Reloaded ${messages.length} messages from context`);\n }\n}\n\nasync function prepareRunContext(params: {\n message: ChatMessage;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n conversationId: string;\n conversationDir: string;\n sessionUuid: string;\n runState: RunnerSessionState;\n executor: Executor;\n executionResolver?: ActorExecutionResolver;\n resolveExecutorForRun: RunnerExecutionContext[\"resolveExecutorForRun\"];\n getPathContext: () => RuntimePathContext;\n sessionManager: SessionManager;\n session: AgentSession;\n agent: Agent;\n setEventContext: (context: {\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId: string;\n }) => void;\n setSandboxContext: (context: { conversationId: string; userId: string }) => void;\n setUploadFunction: (fn: (filePath: string, title?: string) => Promise<void>) => void;\n setBlockKitResponseFunction: (\n fn: (response: import(\"./adapter.js\").ChatResponseBlockKit) => Promise<void>,\n ) => void;\n pathContext: RuntimePathContext;\n}): Promise<PreparedRunContext & { pathContext: RuntimePathContext }> {\n const {\n message,\n responseCtx,\n platform,\n conversationId,\n conversationDir,\n sessionUuid,\n runState,\n executor,\n executionResolver,\n resolveExecutorForRun,\n getPathContext,\n sessionManager,\n session,\n agent,\n setEventContext,\n setSandboxContext,\n setUploadFunction,\n setBlockKitResponseFunction,\n } = params;\n let pathContext = params.pathContext;\n const sessionConversation = message.sessionKey.split(\":\")[0];\n\n await mkdir(join(conversationDir, \"scratch\"), { recursive: true });\n\n if (executionResolver) {\n await resolveExecutorForRun({\n platform: platform.name,\n userId: message.userId,\n conversationId,\n });\n pathContext = getPathContext();\n }\n\n reloadSessionMessages(sessionManager, conversationId, agent);\n\n const memory = await getMemory(conversationDir);\n const skills = loadMikanSkills(conversationDir, pathContext.runtimeWorkspaceRoot);\n const triggerAttribution = resolveTriggerAttribution(message);\n const systemPrompt = buildSystemPrompt(\n pathContext.runtimeWorkspaceRoot,\n conversationId,\n message.conversationKind,\n message.userId,\n memory,\n executor.getSandboxConfig(),\n platform,\n skills,\n message.id.startsWith(\"event:\"),\n triggerAttribution,\n );\n session.agent.state.systemPrompt = systemPrompt;\n\n setEventContext({\n platform: platform.name,\n conversationId,\n conversationKind: message.conversationKind,\n userId: message.userId,\n });\n setSandboxContext({ conversationId, userId: message.userId });\n\n setUploadFunction(async (filePath: string, title?: string) => {\n const hostPath = translateRuntimePathToHost(filePath, pathContext);\n await responseCtx.uploadFile(hostPath, title);\n });\n setBlockKitResponseFunction(async (response) => {\n if (platform.name === \"slack\" && responseCtx.respondBlockKit) {\n await responseCtx.respondBlockKit(response);\n } else {\n await responseCtx.replaceResponse(response.text);\n }\n runState.finalResponseHandledByTool = true;\n });\n\n resetRunState(runState, responseCtx, sessionConversation, message.userName, sessionUuid);\n const runQueue = createRunQueue(responseCtx, runState);\n runState.queue = runQueue.queue;\n\n log.logInfo(\n `Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`,\n );\n log.logInfo(`Channels: ${platform.channels.length}, Users: ${platform.users.length}`);\n\n const { userMessage, imageAttachments } = buildPromptPayload(\n message,\n pathContext.runtimeWorkspaceRoot,\n pathContext,\n );\n await writePromptDebugContext(\n conversationDir,\n systemPrompt,\n session,\n userMessage,\n imageAttachments.length,\n );\n\n return {\n sessionConversation,\n runQueue,\n userMessage,\n imageAttachments,\n triggerAttribution,\n pathContext,\n };\n}\n\nfunction attachSessionEventHandlers(params: {\n session: AgentSession;\n runState: RunnerSessionState;\n model: Model<Api>;\n agentConfig: ReturnType<typeof loadAgentConfigForConversation>;\n}): void {\n const { session, runState, model, agentConfig } = params;\n session.subscribe(async (event) => {\n if (!runState.responseCtx || !runState.logCtx || !runState.queue) return;\n\n const { responseCtx, logCtx, queue, pendingTools } = runState;\n const baseAttrs = { channel_id: logCtx.conversationId, session_id: logCtx.sessionId };\n\n if (event.type === \"tool_execution_start\") {\n const args = (event.args ?? {}) as { label?: string };\n const label = args.label || event.toolName;\n\n pendingTools.set(event.toolCallId, {\n toolName: event.toolName,\n args: event.args,\n startTime: Date.now(),\n });\n addLifecycleBreadcrumb(\"agent.tool.started\", {\n tool: event.toolName,\n ...baseAttrs,\n });\n\n log.logToolStart(logCtx, event.toolName, label, event.args as Record<string, unknown>);\n return;\n }\n\n if (event.type === \"tool_execution_end\") {\n const resultStr = extractToolResultText(event.result);\n const pending = pendingTools.get(event.toolCallId);\n pendingTools.delete(event.toolCallId);\n const durationMs = pending ? Date.now() - pending.startTime : 0;\n\n Sentry.metrics.count(\"agent.tool.calls\", 1, {\n attributes: metricAttributes({\n tool: event.toolName,\n error: String(event.isError),\n ...baseAttrs,\n }),\n });\n Sentry.metrics.distribution(\"agent.tool.duration\", durationMs, {\n unit: \"millisecond\",\n attributes: metricAttributes({\n tool: event.toolName,\n ...baseAttrs,\n }),\n });\n addLifecycleBreadcrumb(\"agent.tool.completed\", {\n tool: event.toolName,\n error: event.isError,\n duration_ms: durationMs,\n ...baseAttrs,\n });\n\n if (event.isError) {\n log.logToolError(logCtx, event.toolName, durationMs, resultStr);\n } else {\n log.logToolSuccess(logCtx, event.toolName, durationMs, resultStr);\n }\n\n if (shouldSurfaceToolDiagnostic(event.toolName)) {\n const toolResult: ChatToolResult = {\n toolName: event.toolName,\n label: pending?.args ? (pending.args as { label?: string }).label : undefined,\n args: pending?.args as Record<string, unknown> | undefined,\n result: truncate(resultStr, TOOL_RESULT_DIAGNOSTIC_CAP),\n isError: event.isError,\n durationMs,\n };\n queue.enqueue(() => responseCtx.respondToolResult(toolResult), \"tool result diagnostic\");\n }\n\n if (event.isError && shouldSurfaceToolDiagnostic(event.toolName)) {\n queue.enqueue(\n () => responseCtx.respond(`_Error: ${truncate(resultStr, 200)}_`),\n \"tool error\",\n );\n }\n return;\n }\n\n if (event.type === \"message_start\") {\n if (event.message.role === \"assistant\") {\n runState.llmCallCount += 1;\n addLifecycleBreadcrumb(\"agent.llm.call.started\", {\n call_index: runState.llmCallCount,\n provider: model.provider,\n model: agentConfig.model,\n ...baseAttrs,\n });\n log.logResponseStart(logCtx);\n }\n return;\n }\n\n if (event.type === \"message_end\") {\n if (event.message.role === \"assistant\") {\n const assistantMsg = event.message;\n\n if (assistantMsg.stopReason) {\n runState.stopReason = assistantMsg.stopReason;\n }\n if (assistantMsg.errorMessage) {\n runState.errorMessage = assistantMsg.errorMessage;\n }\n\n if (assistantMsg.usage) {\n runState.totalUsage.input += assistantMsg.usage.input;\n runState.totalUsage.output += assistantMsg.usage.output;\n runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;\n runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;\n runState.totalUsage.cost.input += assistantMsg.usage.cost.input;\n runState.totalUsage.cost.output += assistantMsg.usage.cost.output;\n runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;\n runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;\n runState.totalUsage.cost.total += assistantMsg.usage.cost.total;\n\n const llmAttributes = metricAttributes({\n provider: model.provider,\n model: agentConfig.model,\n ...baseAttrs,\n stop_reason: assistantMsg.stopReason,\n error: Boolean(assistantMsg.errorMessage),\n });\n Sentry.metrics.count(\"agent.llm.calls\", 1, { attributes: llmAttributes });\n Sentry.metrics.distribution(\"agent.llm.tokens_in\", assistantMsg.usage.input, {\n attributes: llmAttributes,\n });\n Sentry.metrics.distribution(\"agent.llm.tokens_out\", assistantMsg.usage.output, {\n attributes: llmAttributes,\n });\n if (assistantMsg.usage.cacheRead > 0) {\n Sentry.metrics.distribution(\"agent.llm.cache_read\", assistantMsg.usage.cacheRead, {\n attributes: llmAttributes,\n });\n }\n if (assistantMsg.usage.cacheWrite > 0) {\n Sentry.metrics.distribution(\"agent.llm.cache_write\", assistantMsg.usage.cacheWrite, {\n attributes: llmAttributes,\n });\n }\n Sentry.metrics.distribution(\"agent.llm.cost_per_turn\", assistantMsg.usage.cost.total, {\n attributes: llmAttributes,\n });\n addLifecycleBreadcrumb(\"agent.llm.call.completed\", {\n call_index: runState.llmCallCount,\n provider: model.provider,\n model: agentConfig.model,\n stop_reason: assistantMsg.stopReason,\n error: Boolean(assistantMsg.errorMessage),\n input_tokens: assistantMsg.usage.input,\n output_tokens: assistantMsg.usage.output,\n cost_total_usd: assistantMsg.usage.cost.total,\n });\n }\n\n const thinkingParts: string[] = [];\n const textParts: string[] = [];\n const hasToolCall = assistantMsg.content.some((part) =>\n [\"tool_use\", \"toolCall\", \"tool-call\"].includes((part as { type?: string }).type ?? \"\"),\n );\n for (const part of assistantMsg.content) {\n if (part.type === \"thinking\") {\n thinkingParts.push(part.thinking);\n } else if (part.type === \"text\") {\n textParts.push(part.text);\n }\n }\n\n const text = textParts.join(\"\\n\");\n\n for (const thinking of thinkingParts) {\n log.logThinking(logCtx, thinking);\n queue.enqueue(() => responseCtx.respond(`_${thinking}_`), \"thinking main\");\n queue.enqueue(\n () => responseCtx.respondDiagnostic(`_${thinking}_`),\n \"thinking diagnostic\",\n );\n }\n\n if (text.trim() && !hasToolCall) {\n if (runState.finalResponseHandledByTool) return;\n log.logResponse(logCtx, text);\n queue.enqueue(() => responseCtx.respond(text), \"response main\");\n }\n }\n return;\n }\n\n if (event.type === \"compaction_start\") {\n log.logInfo(`Auto-compaction started (reason: ${event.reason})`);\n queue.enqueue(() => responseCtx.respond(\"_Compacting context..._\"), \"compaction start\");\n return;\n }\n\n if (event.type === \"compaction_end\") {\n if (event.result) {\n log.logInfo(`Auto-compaction complete: ${event.result.tokensBefore} tokens compacted`);\n } else if (event.aborted) {\n log.logInfo(\"Auto-compaction aborted\");\n }\n return;\n }\n\n if (event.type === \"auto_retry_start\") {\n log.logWarning(`Retrying (${event.attempt}/${event.maxAttempts})`, event.errorMessage);\n queue.enqueue(\n () => responseCtx.respond(`_Retrying (${event.attempt}/${event.maxAttempts})..._`),\n \"retry\",\n );\n }\n });\n}\n\n// Cap raw tool output before handing it to adapters. Bash output can be MB; without\n// this each adapter's splitter would fan it out into many sequential platform posts.\nconst TOOL_RESULT_DIAGNOSTIC_CAP = 8000;\n\nfunction extractToolResultText(result: unknown): string {\n if (typeof result === \"string\") {\n return result;\n }\n\n if (\n result &&\n typeof result === \"object\" &&\n \"content\" in result &&\n Array.isArray((result as { content: unknown }).content)\n ) {\n const content = (result as { content: Array<{ type: string; text?: string }> }).content;\n const textParts: string[] = [];\n for (const part of content) {\n if (part.type === \"text\" && part.text) {\n textParts.push(part.text);\n }\n }\n if (textParts.length > 0) {\n return textParts.join(\"\\n\");\n }\n }\n\n return JSON.stringify(result);\n}\n\n/**\n * Create a new AgentRunner for a channel.\n * Sets up the session and subscribes to events once.\n *\n * Runner caching is handled by the caller (channelStates in main.ts).\n * This is a stateless factory function.\n */\nexport async function createRunner(\n sandboxConfig: SandboxConfig,\n sessionKey: string,\n conversationId: string,\n conversationDir: string,\n workspaceDir: string,\n sessionScope: ResolvedSessionScope,\n vaultManager?: VaultManager,\n provisioner?: DockerContainerManager,\n sessionView?: {\n tokenStore: SessionViewTokenStoreLike;\n portalBaseUrl?: string;\n },\n): Promise<AgentRunner> {\n const agentConfig = loadAgentConfigForConversation(conversationDir);\n\n const workspaceBase = join(conversationDir, \"..\");\n const { executionResolver, executor, getPathContext, resolveExecutorForRun } =\n createRunnerExecutionContext(\n sandboxConfig,\n vaultManager,\n provisioner,\n workspaceDir,\n workspaceBase,\n );\n let pathContext = getUnresolvedSandboxPathContext(sandboxConfig, workspaceBase);\n\n // Create tools (per-runner, with per-runner upload function setter)\n const {\n tools,\n setUploadFunction,\n setBlockKitResponseFunction,\n setEventContext,\n setSandboxContext,\n } = createMikanTools(executor, workspaceDir, { sandbox: sandboxConfig, provisioner });\n\n // Resolve model from config. Config stores provider/model as user-provided strings,\n // while getModel's public overload is narrowed to generated known providers.\n const model = getModel(agentConfig.provider as never, agentConfig.model as never) as Model<Api>;\n\n // Initial system prompt (will be updated each run with fresh memory/channels/users/skills)\n const memory = await getMemory(conversationDir);\n const skills = loadMikanSkills(conversationDir, pathContext.runtimeWorkspaceRoot);\n const emptyPlatform: PlatformInfo = {\n name: \"chat\",\n formattingGuide: \"\",\n channels: [],\n users: [],\n };\n const systemPrompt = buildSystemPrompt(\n pathContext.runtimeWorkspaceRoot,\n conversationId,\n \"shared\",\n undefined,\n memory,\n sandboxConfig,\n emptyPlatform,\n skills,\n );\n\n // Create session manager and settings manager. Top-level/private sessions\n // use the conversation's current pointer; scoped sessions use fixed files.\n // Platform-specific scope behavior is resolved before runner creation.\n const isThread = sessionKey.includes(\":\");\n const { sessionDir, contextFile, threadRootMessage } = sessionScope;\n const sessionManager = openManagedSession(\n contextFile,\n sessionDir,\n pathContext.runtimeWorkspaceRoot,\n );\n const threadSessionName = buildThreadSessionName(threadRootMessage);\n if (isThread && threadSessionName && sessionManager.getSessionName() !== threadSessionName) {\n sessionManager.appendSessionInfo(threadSessionName);\n }\n\n const sessionUuid = extractSessionUuid(contextFile);\n const chatSessionManager = new ChatSessionManager();\n const settingsManager = SettingsManager.inMemory();\n const { agent, session } = await createConfiguredAgentSession({\n conversationId,\n workspaceDir,\n runtimeWorkspaceRoot: pathContext.runtimeWorkspaceRoot,\n systemPrompt,\n model,\n thinkingLevel: agentConfig.thinkingLevel,\n tools,\n sessionManager,\n settingsManager,\n });\n\n // Mutable per-run state - event handler references this\n const runState = createRunState();\n attachSessionEventHandlers({ session, runState, model, agentConfig });\n\n return {\n syncChatHistory(currentMessageId?: string): void {\n chatSessionManager.syncSessionManager({\n conversationDir,\n sessionKey,\n sessionManager,\n currentMessageId,\n });\n },\n\n async run(\n message: ChatMessage,\n responseCtx: ChatResponseContext,\n platform: PlatformInfo,\n ): Promise<{ stopReason: string; errorMessage?: string }> {\n const prepared = await prepareRunContext({\n message,\n responseCtx,\n platform,\n conversationId,\n conversationDir,\n sessionUuid,\n runState,\n executor,\n executionResolver,\n resolveExecutorForRun,\n getPathContext,\n sessionManager,\n session,\n agent,\n setEventContext,\n setSandboxContext,\n setUploadFunction,\n setBlockKitResponseFunction,\n pathContext,\n });\n pathContext = prepared.pathContext;\n\n addLifecycleBreadcrumb(\"agent.prompt.sent\", {\n provider: model.provider,\n model: agentConfig.model,\n channel_id: prepared.sessionConversation,\n session_id: sessionUuid,\n attachment_count: message.attachments?.length ?? 0,\n image_attachment_count: prepared.imageAttachments.length,\n });\n\n await session.prompt(\n prepared.userMessage,\n prepared.imageAttachments.length > 0 ? { images: prepared.imageAttachments } : undefined,\n );\n\n // Wait for queued messages\n await prepared.runQueue.wait();\n\n const sessionViewTokenStore = sessionView?.tokenStore;\n const sessionViewPortalBaseUrl = sessionView?.portalBaseUrl;\n const createOverflowLink =\n sessionViewTokenStore && sessionViewPortalBaseUrl\n ? () => {\n const token = sessionViewTokenStore.create(\n platform.name as PlatformName,\n message.userId,\n conversationId,\n message.sessionKey,\n contextFile,\n message.userName,\n );\n return `${sessionViewPortalBaseUrl}/session?token=${token.token}`;\n }\n : undefined;\n\n await finalizeRunResponse(responseCtx, session, runState, {\n triggerAttribution: prepared.triggerAttribution,\n createOverflowLink,\n platform: platform.name,\n model,\n sessionConversation: prepared.sessionConversation,\n sessionUuid,\n });\n\n await reportUsageSummary({\n session,\n runState,\n responseCtx,\n platform,\n model,\n agentConfig,\n sessionConversation: prepared.sessionConversation,\n sessionUuid,\n waitForQueue: () => prepared.runQueue.wait(),\n });\n\n // Clear run state\n runState.responseCtx = null;\n runState.logCtx = null;\n runState.queue = null;\n\n return { stopReason: runState.stopReason, errorMessage: runState.errorMessage };\n },\n\n abort(): void {\n session.abort();\n },\n\n getCurrentStep(): { toolName?: string; label?: string } | undefined {\n const pending = runState.pendingTools;\n if (pending.size === 0) return undefined;\n // Get the first pending tool\n const first = pending.values().next().value;\n if (!first) return undefined;\n return {\n toolName: first.toolName,\n label: (first.args as { label?: string })?.label,\n };\n },\n };\n}\n\nexport function translateRuntimePathToHost(\n runtimePath: string,\n pathContext: RuntimePathContext,\n): string {\n return pathContext.runtimeToHostPath?.(runtimePath) ?? runtimePath;\n}\n\nexport function buildInitialPathContextForTest(\n sandboxConfig: SandboxConfig,\n hostWorkspaceRoot: string,\n): RuntimePathContext {\n return getUnresolvedSandboxPathContext(sandboxConfig, hostWorkspaceRoot);\n}\n"]}
package/dist/agent.js CHANGED
@@ -139,31 +139,6 @@ function buildEnvDescription(sandboxType, workspaceRoot) {
139
139
  - Be careful with system modifications`;
140
140
  }
141
141
  }
142
- export function buildEventFilesystemInstructions(sandboxType, workspaceRoot) {
143
- if (sandboxType === "host" || sandboxType === "container" || sandboxType === "image") {
144
- return `Events live in the host-side mikan control plane and are mounted at \`${workspaceRoot}/events/\` in this runtime.
145
-
146
- Prefer the \`event\` tool over manually writing JSON files; it fills \`platform\`, \`conversationId\`, \`conversationKind\`, and \`userId\` for the current conversation automatically.
147
-
148
- ### Creating Events Manually
149
- Only do this when you need to create events from a script. Use unique filenames to avoid overwriting existing events. Include a timestamp or random suffix:
150
- \`\`\`bash
151
- cat > ${workspaceRoot}/events/dentist-reminder-$(date +%s).json << 'EOF'
152
- {"type": "one-shot", "platform": "<platform>", "conversationId": "<conversationId>", "conversationKind": "<direct|shared>", "userId": "<requester userId>", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
153
- EOF
154
- \`\`\`
155
-
156
- ### Managing Events
157
- - List: \`ls ${workspaceRoot}/events/\`
158
- - View: \`cat ${workspaceRoot}/events/foo.json\`
159
- - Delete/cancel: \`rm ${workspaceRoot}/events/foo.json\``;
160
- }
161
- return `Events live in the host-side mikan control plane, not necessarily in this runtime filesystem.
162
-
163
- Use the \`event\` tool to create events. It writes to the correct host-side events directory and fills \`platform\`, \`conversationId\`, \`conversationKind\`, and \`userId\` for the current conversation automatically.
164
-
165
- Do not create event files with bash in \`${workspaceRoot}/events/\` from this sandbox unless you have explicitly verified that path is mounted back to the host-side mikan events directory.`;
166
- }
167
142
  export function resolveTriggerAttribution(message) {
168
143
  const eventTextMatch = message.text.match(/^\[EVENT:([^:]+):/);
169
144
  if (eventTextMatch)
@@ -189,7 +164,6 @@ function buildSystemPrompt(workspacePath, conversationId, conversationKind, curr
189
164
  ? platform.users.map((u) => `${u.id}\t@${u.userName}\t${u.displayName}`).join("\n")
190
165
  : "(no users loaded)";
191
166
  const envDescription = buildEnvDescription(sandboxType, workspaceRoot);
192
- const eventFilesystemInstructions = buildEventFilesystemInstructions(sandboxType, workspaceRoot);
193
167
  const eventTriggerInstructions = isEventTrigger
194
168
  ? `
195
169
  ## Event Trigger Mode
@@ -207,6 +181,19 @@ Always end your final ${platform.name} response and any GitHub issue/PR comments
207
181
  _Triggered by ${triggerAttribution}_
208
182
 
209
183
  Do not add this to \`[SILENT]\` responses.
184
+ `
185
+ : "";
186
+ const slackBlockKitInstructions = platform.name === "slack"
187
+ ? `
188
+ ## Slack Block Kit
189
+ - On Slack, use the \`slack_blockkit\` tool when a response benefits from interaction or structured presentation.
190
+ - Good uses: choices, confirmations, clarifying questions, quizzes, status reports, comparisons, summaries, and step lists.
191
+ - Do not use it for normal short replies or simple factual answers.
192
+ - Supported blocks: section, context, divider, header, actions.
193
+ - Put buttons in actions.elements. Put static_select and multi_static_select in section.accessory.
194
+ - Every interactive element must include action_id. Buttons must include value.
195
+ - Always provide a plain-text fallback in text that matches the visible Block Kit content.
196
+ - When using \`slack_blockkit\`, do not also send a normal text response in the same assistant turn; put the fallback in the tool's text field.
210
197
  `
211
198
  : "";
212
199
  return `You are mikan, a ${platform.name} bot assistant. Be concise. No emojis.
@@ -221,7 +208,7 @@ Do not add this to \`[SILENT]\` responses.
221
208
  - If a user asks about something that should exist in conversation history but is not found in the current context window, do not answer "I don't know" or "I don't have that". Instead, search the thread session, top-level session, and \`log.jsonl\` before responding.
222
209
  - User messages include a \`[in-thread:TS]\` marker when sent from within a platform thread/reply (TS is the thread or parent message identifier). Without this marker, the message is a top-level conversation message.
223
210
  ${eventTriggerInstructions}
224
- ${platform.formattingGuide}
211
+ ${platform.formattingGuide}${slackBlockKitInstructions}
225
212
 
226
213
  ## Platform IDs
227
214
  Channels: ${channelMappings}
@@ -275,58 +262,15 @@ Scripts are in: {baseDir}/
275
262
  ${skills.length > 0 ? formatSkillsForPrompt(skills) : "(no skills installed yet)"}
276
263
 
277
264
  ## Events
278
- You can schedule events that wake you up at specific times or when external things happen.
279
- ${eventFilesystemInstructions}
280
-
281
- ### Event Types
282
-
283
- **Immediate** - Triggers as soon as harness sees the file. Use in scripts/webhooks to signal external events.
284
- \`\`\`json
285
- {"type": "immediate", "platform": "${platform.name}", "conversationId": "${conversationId}", "conversationKind": "${conversationKind}", "userId": "${currentUserId ?? "<requester userId>"}", "text": "New GitHub issue opened"}
286
- \`\`\`
287
-
288
- **One-shot** - Triggers once at a specific time. Use for reminders.
289
- \`\`\`json
290
- {"type": "one-shot", "platform": "${platform.name}", "conversationId": "${conversationId}", "conversationKind": "${conversationKind}", "userId": "${currentUserId ?? "<requester userId>"}", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
291
- \`\`\`
292
-
293
- **Periodic** - Triggers on a cron schedule. Use for recurring tasks.
294
- \`\`\`json
295
- {"type": "periodic", "platform": "${platform.name}", "conversationId": "${conversationId}", "conversationKind": "${conversationKind}", "userId": "${currentUserId ?? "<requester userId>"}", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
296
- \`\`\`
297
-
298
- ### Cron Format
299
- \`minute hour day-of-month month day-of-week\`
300
- - \`0 9 * * *\` = daily at 9:00
301
- - \`0 9 * * 1-5\` = weekdays at 9:00
302
- - \`30 14 * * 1\` = Mondays at 14:30
303
- - \`0 0 1 * *\` = first of each month at midnight
304
-
305
- ### Timezones
306
- All \`at\` timestamps must include offset (e.g., \`+01:00\`). Periodic events use IANA timezone names. The harness runs in ${Intl.DateTimeFormat().resolvedOptions().timeZone}. When users mention times without timezone, assume ${Intl.DateTimeFormat().resolvedOptions().timeZone}.
307
-
308
- ### Platform and Credential Routing
309
- Set \`platform\` to the target bot platform (\`${platform.name}\` for this conversation). Include it explicitly to avoid ambiguity.
310
-
311
- Set \`userId\` to the platform userId of whoever asked for the event. When the event fires, tool execution routes using that user's vault selection in per-user modes. In \`container:<name>\`, events use the container's single shared vault.
312
-
313
- When scheduling an event, write \`text\` as a self-contained task for your future self. Include the minimum necessary context, tone, and constraints in the text itself because events do not inherit normal conversation history. Good: \`Please remind the user that break time is over and it is time to return to class. Keep it brief, in Traditional Chinese, and do not ask follow-up questions.\` Bad: \`back to class\`.
265
+ Use the \`event\` tool to schedule immediate, one-shot, or periodic follow-ups. It writes to the host-side mikan control plane and fills routing fields for the current conversation automatically.
314
266
 
315
- ### When Events Trigger
316
- You receive a message like:
317
- \`\`\`
318
- [EVENT:dentist-reminder.json:one-shot:2025-12-14T09:00:00+01:00] Dentist tomorrow
319
- \`\`\`
320
- Immediate and one-shot events auto-delete after triggering. Periodic events persist until you delete them.
267
+ Write event \`text\` as a self-contained future task with needed context, tone, and constraints because events do not inherit normal conversation history.
321
268
 
322
- ### Silent Completion
323
- For periodic events where there's nothing to report, respond with just \`[SILENT]\` (no other text). This deletes the status message and posts nothing to the platform. Use this to avoid spamming the channel when periodic checks find nothing actionable.
269
+ For one-shot reminders, include a timezone offset in \`at\`. For periodic events, use a cron schedule plus IANA timezone; assume ${Intl.DateTimeFormat().resolvedOptions().timeZone} when users omit timezone.
324
270
 
325
- ### Debouncing
326
- When writing programs that create immediate events (email watchers, webhook handlers, etc.), always debounce. If 50 emails arrive in a minute, don't create 50 immediate events. Instead collect events over a window and create ONE immediate event summarizing what happened, or just signal "new activity, check inbox" rather than per-item events. Or simpler: use a periodic event to check for new items every N minutes instead of immediate events.
271
+ When events trigger, messages are prefixed like \`[EVENT:filename:type:time]\`. Immediate and one-shot events auto-delete after triggering; periodic events persist until deleted.
327
272
 
328
- ### Limits
329
- Maximum 5 events can be queued. Don't create excessive immediate or periodic events.
273
+ For periodic events where there's nothing to report, respond with exactly \`[SILENT]\`. Debounce external triggers; prefer one summarized event over many.
330
274
 
331
275
  ## Memory
332
276
  Write to MEMORY.md files to persist context across conversations.
@@ -372,6 +316,8 @@ ls -1 sessions/
372
316
  - read: Read files
373
317
  - write: Create/overwrite files
374
318
  - edit: Surgical file edits
319
+ - event: Schedule immediate, one-shot, or periodic follow-ups
320
+ - sandbox: Inspect or temporarily adjust sandbox limits
375
321
  - attach: Share files to the platform
376
322
 
377
323
  Each tool requires a "label" parameter (shown to user).
@@ -503,6 +449,7 @@ function createRunState() {
503
449
  stopReason: "stop",
504
450
  errorMessage: undefined,
505
451
  reportedLlmError: false,
452
+ finalResponseHandledByTool: false,
506
453
  };
507
454
  }
508
455
  function resetRunState(runState, responseCtx, sessionConversation, userName, sessionUuid) {
@@ -519,13 +466,16 @@ function resetRunState(runState, responseCtx, sessionConversation, userName, ses
519
466
  runState.stopReason = "stop";
520
467
  runState.errorMessage = undefined;
521
468
  runState.reportedLlmError = false;
469
+ runState.finalResponseHandledByTool = false;
522
470
  }
523
- function createRunQueue(responseCtx) {
471
+ function createRunQueue(responseCtx, runState) {
524
472
  let queueChain = Promise.resolve();
525
473
  return {
526
474
  queue: {
527
475
  enqueue(fn, errorContext) {
528
476
  queueChain = queueChain.then(async () => {
477
+ if (runState.finalResponseHandledByTool)
478
+ return;
529
479
  try {
530
480
  await fn();
531
481
  }
@@ -663,6 +613,10 @@ async function finalizeRunResponse(responseCtx, session, runState, options) {
663
613
  return;
664
614
  }
665
615
  const finalText = getFinalAssistantText(session);
616
+ if (runState.finalResponseHandledByTool) {
617
+ log.logInfo("Final response already handled by tool - skipping final replacement");
618
+ return;
619
+ }
666
620
  if (finalText.trim() === "[SILENT]" || finalText.trim().startsWith("[SILENT]")) {
667
621
  try {
668
622
  await responseCtx.deleteResponse();
@@ -753,7 +707,7 @@ function reloadSessionMessages(sessionManager, conversationId, agent) {
753
707
  }
754
708
  }
755
709
  async function prepareRunContext(params) {
756
- const { message, responseCtx, platform, conversationId, conversationDir, sessionUuid, runState, executor, executionResolver, resolveExecutorForRun, getPathContext, sessionManager, session, agent, setEventContext, setSandboxContext, setUploadFunction, } = params;
710
+ const { message, responseCtx, platform, conversationId, conversationDir, sessionUuid, runState, executor, executionResolver, resolveExecutorForRun, getPathContext, sessionManager, session, agent, setEventContext, setSandboxContext, setUploadFunction, setBlockKitResponseFunction, } = params;
757
711
  let pathContext = params.pathContext;
758
712
  const sessionConversation = message.sessionKey.split(":")[0];
759
713
  await mkdir(join(conversationDir, "scratch"), { recursive: true });
@@ -782,8 +736,17 @@ async function prepareRunContext(params) {
782
736
  const hostPath = translateRuntimePathToHost(filePath, pathContext);
783
737
  await responseCtx.uploadFile(hostPath, title);
784
738
  });
739
+ setBlockKitResponseFunction(async (response) => {
740
+ if (platform.name === "slack" && responseCtx.respondBlockKit) {
741
+ await responseCtx.respondBlockKit(response);
742
+ }
743
+ else {
744
+ await responseCtx.replaceResponse(response.text);
745
+ }
746
+ runState.finalResponseHandledByTool = true;
747
+ });
785
748
  resetRunState(runState, responseCtx, sessionConversation, message.userName, sessionUuid);
786
- const runQueue = createRunQueue(responseCtx);
749
+ const runQueue = createRunQueue(responseCtx, runState);
787
750
  runState.queue = runQueue.queue;
788
751
  log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
789
752
  log.logInfo(`Channels: ${platform.channels.length}, Users: ${platform.users.length}`);
@@ -939,6 +902,7 @@ function attachSessionEventHandlers(params) {
939
902
  }
940
903
  const thinkingParts = [];
941
904
  const textParts = [];
905
+ const hasToolCall = assistantMsg.content.some((part) => ["tool_use", "toolCall", "tool-call"].includes(part.type ?? ""));
942
906
  for (const part of assistantMsg.content) {
943
907
  if (part.type === "thinking") {
944
908
  thinkingParts.push(part.thinking);
@@ -953,7 +917,9 @@ function attachSessionEventHandlers(params) {
953
917
  queue.enqueue(() => responseCtx.respond(`_${thinking}_`), "thinking main");
954
918
  queue.enqueue(() => responseCtx.respondDiagnostic(`_${thinking}_`), "thinking diagnostic");
955
919
  }
956
- if (text.trim()) {
920
+ if (text.trim() && !hasToolCall) {
921
+ if (runState.finalResponseHandledByTool)
922
+ return;
957
923
  log.logResponse(logCtx, text);
958
924
  queue.enqueue(() => responseCtx.respond(text), "response main");
959
925
  }
@@ -1017,7 +983,7 @@ export async function createRunner(sandboxConfig, sessionKey, conversationId, co
1017
983
  const { executionResolver, executor, getPathContext, resolveExecutorForRun } = createRunnerExecutionContext(sandboxConfig, vaultManager, provisioner, workspaceDir, workspaceBase);
1018
984
  let pathContext = getUnresolvedSandboxPathContext(sandboxConfig, workspaceBase);
1019
985
  // Create tools (per-runner, with per-runner upload function setter)
1020
- const { tools, setUploadFunction, setEventContext, setSandboxContext } = createMikanTools(executor, workspaceDir, { sandbox: sandboxConfig, provisioner });
986
+ const { tools, setUploadFunction, setBlockKitResponseFunction, setEventContext, setSandboxContext, } = createMikanTools(executor, workspaceDir, { sandbox: sandboxConfig, provisioner });
1021
987
  // Resolve model from config. Config stores provider/model as user-provided strings,
1022
988
  // while getModel's public overload is narrowed to generated known providers.
1023
989
  const model = getModel(agentConfig.provider, agentConfig.model);
@@ -1086,6 +1052,7 @@ export async function createRunner(sandboxConfig, sessionKey, conversationId, co
1086
1052
  setEventContext,
1087
1053
  setSandboxContext,
1088
1054
  setUploadFunction,
1055
+ setBlockKitResponseFunction,
1089
1056
  pathContext,
1090
1057
  });
1091
1058
  pathContext = prepared.pathContext;