@geminixiang/mama 0.2.0-beta.0 → 0.2.0-beta.10

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 (273) hide show
  1. package/README.md +171 -334
  2. package/dist/adapter.d.ts +36 -10
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +10 -5
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +349 -114
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts +1 -1
  10. package/dist/adapters/discord/context.d.ts.map +1 -1
  11. package/dist/adapters/discord/context.js +102 -31
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/shared.d.ts +71 -0
  14. package/dist/adapters/shared.d.ts.map +1 -0
  15. package/dist/adapters/shared.js +168 -0
  16. package/dist/adapters/shared.js.map +1 -0
  17. package/dist/adapters/slack/bot.d.ts +29 -22
  18. package/dist/adapters/slack/bot.d.ts.map +1 -1
  19. package/dist/adapters/slack/bot.js +620 -186
  20. package/dist/adapters/slack/bot.js.map +1 -1
  21. package/dist/adapters/slack/branch-manager.d.ts +22 -0
  22. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  23. package/dist/adapters/slack/branch-manager.js +97 -0
  24. package/dist/adapters/slack/branch-manager.js.map +1 -0
  25. package/dist/adapters/slack/context.d.ts +1 -1
  26. package/dist/adapters/slack/context.d.ts.map +1 -1
  27. package/dist/adapters/slack/context.js +136 -71
  28. package/dist/adapters/slack/context.js.map +1 -1
  29. package/dist/adapters/slack/session.d.ts +3 -0
  30. package/dist/adapters/slack/session.d.ts.map +1 -0
  31. package/dist/adapters/slack/session.js +16 -0
  32. package/dist/adapters/slack/session.js.map +1 -0
  33. package/dist/adapters/slack/tools/attach.d.ts +1 -1
  34. package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
  35. package/dist/adapters/slack/tools/attach.js.map +1 -1
  36. package/dist/adapters/telegram/bot.d.ts +2 -0
  37. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  38. package/dist/adapters/telegram/bot.js +190 -123
  39. package/dist/adapters/telegram/bot.js.map +1 -1
  40. package/dist/adapters/telegram/context.d.ts.map +1 -1
  41. package/dist/adapters/telegram/context.js +57 -59
  42. package/dist/adapters/telegram/context.js.map +1 -1
  43. package/dist/adapters/telegram/html.d.ts +3 -0
  44. package/dist/adapters/telegram/html.d.ts.map +1 -0
  45. package/dist/adapters/telegram/html.js +98 -0
  46. package/dist/adapters/telegram/html.js.map +1 -0
  47. package/dist/agent.d.ts +9 -10
  48. package/dist/agent.d.ts.map +1 -1
  49. package/dist/agent.js +645 -555
  50. package/dist/agent.js.map +1 -1
  51. package/dist/commands/auto-reply.d.ts +16 -0
  52. package/dist/commands/auto-reply.d.ts.map +1 -0
  53. package/dist/commands/auto-reply.js +69 -0
  54. package/dist/commands/auto-reply.js.map +1 -0
  55. package/dist/commands/index.d.ts +5 -0
  56. package/dist/commands/index.d.ts.map +1 -0
  57. package/dist/commands/index.js +19 -0
  58. package/dist/commands/index.js.map +1 -0
  59. package/dist/commands/login.d.ts +5 -0
  60. package/dist/commands/login.d.ts.map +1 -0
  61. package/dist/commands/login.js +76 -0
  62. package/dist/commands/login.js.map +1 -0
  63. package/dist/commands/model.d.ts +14 -0
  64. package/dist/commands/model.d.ts.map +1 -0
  65. package/dist/commands/model.js +112 -0
  66. package/dist/commands/model.js.map +1 -0
  67. package/dist/commands/new.d.ts +9 -0
  68. package/dist/commands/new.d.ts.map +1 -0
  69. package/dist/commands/new.js +28 -0
  70. package/dist/commands/new.js.map +1 -0
  71. package/dist/commands/registry.d.ts +7 -0
  72. package/dist/commands/registry.d.ts.map +1 -0
  73. package/dist/commands/registry.js +14 -0
  74. package/dist/commands/registry.js.map +1 -0
  75. package/dist/commands/sandbox.d.ts +10 -0
  76. package/dist/commands/sandbox.d.ts.map +1 -0
  77. package/dist/commands/sandbox.js +88 -0
  78. package/dist/commands/sandbox.js.map +1 -0
  79. package/dist/commands/session-view.d.ts +5 -0
  80. package/dist/commands/session-view.d.ts.map +1 -0
  81. package/dist/commands/session-view.js +62 -0
  82. package/dist/commands/session-view.js.map +1 -0
  83. package/dist/commands/types.d.ts +41 -0
  84. package/dist/commands/types.d.ts.map +1 -0
  85. package/dist/commands/types.js +2 -0
  86. package/dist/commands/types.js.map +1 -0
  87. package/dist/commands/utils.d.ts +8 -0
  88. package/dist/commands/utils.d.ts.map +1 -0
  89. package/dist/commands/utils.js +14 -0
  90. package/dist/commands/utils.js.map +1 -0
  91. package/dist/config.d.ts +53 -7
  92. package/dist/config.d.ts.map +1 -1
  93. package/dist/config.js +320 -55
  94. package/dist/config.js.map +1 -1
  95. package/dist/context.d.ts +10 -42
  96. package/dist/context.d.ts.map +1 -1
  97. package/dist/context.js +15 -128
  98. package/dist/context.js.map +1 -1
  99. package/dist/events.d.ts +16 -5
  100. package/dist/events.d.ts.map +1 -1
  101. package/dist/events.js +127 -58
  102. package/dist/events.js.map +1 -1
  103. package/dist/execution-resolver.d.ts +24 -0
  104. package/dist/execution-resolver.d.ts.map +1 -0
  105. package/dist/execution-resolver.js +115 -0
  106. package/dist/execution-resolver.js.map +1 -0
  107. package/dist/file-guards.d.ts +6 -0
  108. package/dist/file-guards.d.ts.map +1 -0
  109. package/dist/file-guards.js +48 -0
  110. package/dist/file-guards.js.map +1 -0
  111. package/dist/fs-atomic.d.ts +10 -0
  112. package/dist/fs-atomic.d.ts.map +1 -0
  113. package/dist/fs-atomic.js +45 -0
  114. package/dist/fs-atomic.js.map +1 -0
  115. package/dist/index.d.ts +7 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +4 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/instrument.d.ts.map +1 -1
  120. package/dist/instrument.js +3 -3
  121. package/dist/instrument.js.map +1 -1
  122. package/dist/log.d.ts +3 -7
  123. package/dist/log.d.ts.map +1 -1
  124. package/dist/log.js +20 -45
  125. package/dist/log.js.map +1 -1
  126. package/dist/login/index.d.ts +41 -0
  127. package/dist/login/index.d.ts.map +1 -0
  128. package/dist/login/index.js +202 -0
  129. package/dist/login/index.js.map +1 -0
  130. package/dist/login/portal.d.ts +19 -0
  131. package/dist/login/portal.d.ts.map +1 -0
  132. package/dist/login/portal.js +1453 -0
  133. package/dist/login/portal.js.map +1 -0
  134. package/dist/login/session.d.ts +33 -0
  135. package/dist/login/session.d.ts.map +1 -0
  136. package/dist/login/session.js +68 -0
  137. package/dist/login/session.js.map +1 -0
  138. package/dist/main.d.ts.map +1 -1
  139. package/dist/main.js +229 -264
  140. package/dist/main.js.map +1 -1
  141. package/dist/provisioner.d.ts +79 -0
  142. package/dist/provisioner.d.ts.map +1 -0
  143. package/dist/provisioner.js +437 -0
  144. package/dist/provisioner.js.map +1 -0
  145. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  146. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  147. package/dist/runtime/conversation-orchestrator.js +150 -0
  148. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  149. package/dist/runtime/index.d.ts +2 -0
  150. package/dist/runtime/index.d.ts.map +1 -0
  151. package/dist/runtime/index.js +2 -0
  152. package/dist/runtime/index.js.map +1 -0
  153. package/dist/runtime/session-runtime.d.ts +27 -0
  154. package/dist/runtime/session-runtime.d.ts.map +1 -0
  155. package/dist/runtime/session-runtime.js +211 -0
  156. package/dist/runtime/session-runtime.js.map +1 -0
  157. package/dist/sandbox/cloudflare.d.ts +15 -0
  158. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  159. package/dist/sandbox/cloudflare.js +137 -0
  160. package/dist/sandbox/cloudflare.js.map +1 -0
  161. package/dist/sandbox/container.d.ts +16 -0
  162. package/dist/sandbox/container.d.ts.map +1 -0
  163. package/dist/sandbox/container.js +126 -0
  164. package/dist/sandbox/container.js.map +1 -0
  165. package/dist/sandbox/errors.d.ts +6 -0
  166. package/dist/sandbox/errors.d.ts.map +1 -0
  167. package/dist/sandbox/errors.js +11 -0
  168. package/dist/sandbox/errors.js.map +1 -0
  169. package/dist/sandbox/firecracker.d.ts +17 -0
  170. package/dist/sandbox/firecracker.d.ts.map +1 -0
  171. package/dist/sandbox/firecracker.js +212 -0
  172. package/dist/sandbox/firecracker.js.map +1 -0
  173. package/dist/sandbox/host.d.ts +11 -0
  174. package/dist/sandbox/host.d.ts.map +1 -0
  175. package/dist/sandbox/host.js +89 -0
  176. package/dist/sandbox/host.js.map +1 -0
  177. package/dist/sandbox/image.d.ts +5 -0
  178. package/dist/sandbox/image.d.ts.map +1 -0
  179. package/dist/sandbox/image.js +30 -0
  180. package/dist/sandbox/image.js.map +1 -0
  181. package/dist/sandbox/index.d.ts +22 -0
  182. package/dist/sandbox/index.d.ts.map +1 -0
  183. package/dist/sandbox/index.js +54 -0
  184. package/dist/sandbox/index.js.map +1 -0
  185. package/dist/sandbox/path-context.d.ts +4 -0
  186. package/dist/sandbox/path-context.d.ts.map +1 -0
  187. package/dist/sandbox/path-context.js +20 -0
  188. package/dist/sandbox/path-context.js.map +1 -0
  189. package/dist/sandbox/types.d.ts +67 -0
  190. package/dist/sandbox/types.d.ts.map +1 -0
  191. package/dist/sandbox/types.js +2 -0
  192. package/dist/sandbox/types.js.map +1 -0
  193. package/dist/sandbox/utils.d.ts +4 -0
  194. package/dist/sandbox/utils.d.ts.map +1 -0
  195. package/dist/sandbox/utils.js +51 -0
  196. package/dist/sandbox/utils.js.map +1 -0
  197. package/dist/sandbox.d.ts +1 -39
  198. package/dist/sandbox.d.ts.map +1 -1
  199. package/dist/sandbox.js +1 -286
  200. package/dist/sandbox.js.map +1 -1
  201. package/dist/sentry.d.ts +2 -2
  202. package/dist/sentry.d.ts.map +1 -1
  203. package/dist/sentry.js +6 -4
  204. package/dist/sentry.js.map +1 -1
  205. package/dist/session-policy.d.ts +13 -0
  206. package/dist/session-policy.d.ts.map +1 -0
  207. package/dist/session-policy.js +23 -0
  208. package/dist/session-policy.js.map +1 -0
  209. package/dist/session-store.d.ts +35 -8
  210. package/dist/session-store.d.ts.map +1 -1
  211. package/dist/session-store.js +182 -23
  212. package/dist/session-store.js.map +1 -1
  213. package/dist/session-view/command.d.ts +5 -0
  214. package/dist/session-view/command.d.ts.map +1 -0
  215. package/dist/session-view/command.js +11 -0
  216. package/dist/session-view/command.js.map +1 -0
  217. package/dist/session-view/portal.d.ts +16 -0
  218. package/dist/session-view/portal.d.ts.map +1 -0
  219. package/dist/session-view/portal.js +1742 -0
  220. package/dist/session-view/portal.js.map +1 -0
  221. package/dist/session-view/service.d.ts +34 -0
  222. package/dist/session-view/service.d.ts.map +1 -0
  223. package/dist/session-view/service.js +427 -0
  224. package/dist/session-view/service.js.map +1 -0
  225. package/dist/session-view/store.d.ts +18 -0
  226. package/dist/session-view/store.d.ts.map +1 -0
  227. package/dist/session-view/store.js +39 -0
  228. package/dist/session-view/store.js.map +1 -0
  229. package/dist/store.d.ts +4 -7
  230. package/dist/store.d.ts.map +1 -1
  231. package/dist/store.js +26 -52
  232. package/dist/store.js.map +1 -1
  233. package/dist/tool-diagnostics.d.ts +2 -0
  234. package/dist/tool-diagnostics.d.ts.map +1 -0
  235. package/dist/tool-diagnostics.js +7 -0
  236. package/dist/tool-diagnostics.js.map +1 -0
  237. package/dist/tools/bash.d.ts +1 -1
  238. package/dist/tools/bash.d.ts.map +1 -1
  239. package/dist/tools/bash.js.map +1 -1
  240. package/dist/tools/edit.d.ts +1 -1
  241. package/dist/tools/edit.d.ts.map +1 -1
  242. package/dist/tools/edit.js.map +1 -1
  243. package/dist/tools/event.d.ts +62 -0
  244. package/dist/tools/event.d.ts.map +1 -0
  245. package/dist/tools/event.js +138 -0
  246. package/dist/tools/event.js.map +1 -0
  247. package/dist/tools/index.d.ts +8 -2
  248. package/dist/tools/index.d.ts.map +1 -1
  249. package/dist/tools/index.js +5 -1
  250. package/dist/tools/index.js.map +1 -1
  251. package/dist/tools/read.d.ts +1 -1
  252. package/dist/tools/read.d.ts.map +1 -1
  253. package/dist/tools/read.js.map +1 -1
  254. package/dist/tools/write.d.ts +1 -1
  255. package/dist/tools/write.d.ts.map +1 -1
  256. package/dist/tools/write.js.map +1 -1
  257. package/dist/trigger.d.ts +31 -0
  258. package/dist/trigger.d.ts.map +1 -0
  259. package/dist/trigger.js +98 -0
  260. package/dist/trigger.js.map +1 -0
  261. package/dist/ui-copy.d.ts +12 -0
  262. package/dist/ui-copy.d.ts.map +1 -0
  263. package/dist/ui-copy.js +36 -0
  264. package/dist/ui-copy.js.map +1 -0
  265. package/dist/vault-routing.d.ts +4 -0
  266. package/dist/vault-routing.d.ts.map +1 -0
  267. package/dist/vault-routing.js +16 -0
  268. package/dist/vault-routing.js.map +1 -0
  269. package/dist/vault.d.ts +72 -0
  270. package/dist/vault.d.ts.map +1 -0
  271. package/dist/vault.js +264 -0
  272. package/dist/vault.js.map +1 -0
  273. package/package.json +16 -13
package/dist/agent.js CHANGED
@@ -1,16 +1,18 @@
1
- import { Agent } from "@mariozechner/pi-agent-core";
2
- import { getModel } from "@mariozechner/pi-ai";
3
- import { AgentSession, AuthStorage, convertToLlm, DefaultResourceLoader, formatSkillsForPrompt, loadSkillsFromDir, ModelRegistry, } from "@mariozechner/pi-coding-agent";
1
+ import { Agent } from "@earendil-works/pi-agent-core";
2
+ import { getModel } from "@earendil-works/pi-ai";
3
+ import { AgentSession, AuthStorage, convertToLlm, DefaultResourceLoader, formatSkillsForPrompt, getAgentDir, loadSkillsFromDir, ModelRegistry, SettingsManager, } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { mkdir, readFile, writeFile } from "fs/promises";
6
6
  import { homedir } from "os";
7
- import { join } from "path";
8
- import { loadAgentConfig } from "./config.js";
9
- import { createMamaSettingsManager, syncLogToSessionManager } from "./context.js";
7
+ import { join, posix } from "path";
8
+ import { loadAgentConfigForConversation } from "./config.js";
9
+ import { ActorExecutionResolver } from "./execution-resolver.js";
10
10
  import * as log from "./log.js";
11
- import { createExecutor } from "./sandbox.js";
11
+ import { createExecutor, } from "./sandbox.js";
12
+ import { createMountedRuntimePathContext } from "./sandbox/path-context.js";
12
13
  import { addLifecycleBreadcrumb, metricAttributes } from "./sentry.js";
13
- import { createManagedSessionFileAtPath, extractSessionSuffix, extractSessionUuid, forkThreadSessionFile, getSessionDir, getThreadSessionFile, openManagedSession, resolveChannelSessionFile, resolveManagedSessionFile, tryResolveThreadSession, } from "./session-store.js";
14
+ import { extractSessionUuid, openManagedSession, } from "./session-store.js";
15
+ import { shouldSurfaceToolDiagnostic } from "./tool-diagnostics.js";
14
16
  import { createMamaTools } from "./tools/index.js";
15
17
  import * as Sentry from "@sentry/node";
16
18
  const IMAGE_MIME_TYPES = {
@@ -23,10 +25,17 @@ const IMAGE_MIME_TYPES = {
23
25
  function getImageMimeType(filename) {
24
26
  return IMAGE_MIME_TYPES[filename.toLowerCase().split(".").pop() || ""];
25
27
  }
26
- async function getMemory(channelDir) {
28
+ function buildThreadSessionName(message) {
29
+ const text = message?.text?.trim();
30
+ if (!text)
31
+ return undefined;
32
+ const userLabel = message?.userName || message?.user || "unknown";
33
+ return `[${userLabel}]: ${text}`;
34
+ }
35
+ async function getMemory(conversationDir) {
27
36
  const parts = [];
28
- // Read workspace-level memory (shared across all channels)
29
- const workspaceMemoryPath = join(channelDir, "..", "MEMORY.md");
37
+ // Read workspace-level memory (shared across all conversations)
38
+ const workspaceMemoryPath = join(conversationDir, "..", "MEMORY.md");
30
39
  if (existsSync(workspaceMemoryPath)) {
31
40
  try {
32
41
  const content = (await readFile(workspaceMemoryPath, "utf-8")).trim();
@@ -38,17 +47,17 @@ async function getMemory(channelDir) {
38
47
  log.logWarning("Failed to read workspace memory", `${workspaceMemoryPath}: ${error}`);
39
48
  }
40
49
  }
41
- // Read channel-specific memory
42
- const channelMemoryPath = join(channelDir, "MEMORY.md");
43
- if (existsSync(channelMemoryPath)) {
50
+ // Read conversation-specific memory
51
+ const conversationMemoryPath = join(conversationDir, "MEMORY.md");
52
+ if (existsSync(conversationMemoryPath)) {
44
53
  try {
45
- const content = (await readFile(channelMemoryPath, "utf-8")).trim();
54
+ const content = (await readFile(conversationMemoryPath, "utf-8")).trim();
46
55
  if (content) {
47
- parts.push(`### Channel-Specific Memory\n${content}`);
56
+ parts.push(`### Conversation-Specific Memory\n${content}`);
48
57
  }
49
58
  }
50
59
  catch (error) {
51
- log.logWarning("Failed to read channel memory", `${channelMemoryPath}: ${error}`);
60
+ log.logWarning("Failed to read conversation memory", `${conversationMemoryPath}: ${error}`);
52
61
  }
53
62
  }
54
63
  if (parts.length === 0) {
@@ -56,12 +65,12 @@ async function getMemory(channelDir) {
56
65
  }
57
66
  return parts.join("\n\n");
58
67
  }
59
- function loadMamaSkills(channelDir, workspacePath) {
68
+ function loadMamaSkills(conversationDir, workspacePath) {
60
69
  const skillMap = new Map();
61
- // channelDir is the host path (e.g., /Users/.../data/C0A34FL8PMH)
70
+ // conversationDir is the host path (e.g., /Users/.../data/C0A34FL8PMH)
62
71
  // hostWorkspacePath is the parent directory on host
63
72
  // workspacePath is the container path (e.g., /workspace)
64
- const hostWorkspacePath = join(channelDir, "..");
73
+ const hostWorkspacePath = join(conversationDir, "..");
65
74
  // Helper to translate host paths to container paths
66
75
  const translatePath = (hostPath) => {
67
76
  if (hostPath.startsWith(hostWorkspacePath)) {
@@ -77,19 +86,87 @@ function loadMamaSkills(channelDir, workspacePath) {
77
86
  skill.baseDir = translatePath(skill.baseDir);
78
87
  skillMap.set(skill.name, skill);
79
88
  }
80
- // Load channel-specific skills (override workspace skills on collision)
81
- const channelSkillsDir = join(channelDir, "skills");
82
- for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" }).skills) {
89
+ // Load conversation-specific skills (override workspace skills on collision)
90
+ const conversationSkillsDir = join(conversationDir, "skills");
91
+ for (const skill of loadSkillsFromDir({ dir: conversationSkillsDir, source: "channel" }).skills) {
83
92
  skill.filePath = translatePath(skill.filePath);
84
93
  skill.baseDir = translatePath(skill.baseDir);
85
94
  skillMap.set(skill.name, skill);
86
95
  }
87
96
  return Array.from(skillMap.values());
88
97
  }
89
- function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, platform, skills) {
90
- const channelPath = `${workspacePath}/${channelId}`;
91
- const isDocker = sandboxConfig.type === "docker";
92
- const isFirecracker = sandboxConfig.type === "firecracker";
98
+ function buildRuntimePaths(runtimeWorkspaceRoot, conversationId) {
99
+ const workspaceRoot = runtimeWorkspaceRoot.replace(/\/+$/, "") || "/";
100
+ const conversationPath = posix.join(workspaceRoot, conversationId);
101
+ return {
102
+ workspaceRoot,
103
+ conversationPath,
104
+ scratchPath: posix.join(conversationPath, "scratch"),
105
+ };
106
+ }
107
+ function buildEnvDescription(sandboxType, workspaceRoot) {
108
+ switch (sandboxType) {
109
+ case "image":
110
+ return `You are running inside a managed per-user container.
111
+ - Runtime workspace root: ${workspaceRoot}
112
+ - Bash commands start in: ${workspaceRoot}
113
+ - Install tools with the image's package manager
114
+ - Your changes persist for this user's container until it is recreated`;
115
+ case "container":
116
+ return `You are running inside a shared container.
117
+ - Runtime workspace root: ${workspaceRoot}
118
+ - Bash commands start in: ${workspaceRoot}
119
+ - Install tools with the container's package manager
120
+ - Your changes persist across sessions`;
121
+ case "firecracker":
122
+ return `You are running inside a Firecracker microVM.
123
+ - Runtime workspace root: ${workspaceRoot}
124
+ - Use cd or absolute paths; project files are under ${workspaceRoot}
125
+ - Install tools with: apt-get install <package> (Debian-based)
126
+ - Your changes persist across sessions`;
127
+ case "cloudflare":
128
+ return `You are running through a Cloudflare Sandbox bridge.
129
+ - Runtime workspace root: ${workspaceRoot}
130
+ - Bash commands start in: ${workspaceRoot}
131
+ - Your commands run in a remote container managed by Cloudflare
132
+ - Important: the remote filesystem is not automatically synced back to the host workspace`;
133
+ default:
134
+ return `You are running directly on the host machine.
135
+ - Runtime workspace root: ${workspaceRoot}
136
+ - Bash commands start in: ${process.cwd()}
137
+ - Be careful with system modifications`;
138
+ }
139
+ }
140
+ export function buildEventFilesystemInstructions(sandboxType, workspaceRoot) {
141
+ if (sandboxType === "host" || sandboxType === "container" || sandboxType === "image") {
142
+ return `Events live in the host-side mama control plane and are mounted at \`${workspaceRoot}/events/\` in this runtime.
143
+
144
+ Prefer the \`event\` tool over manually writing JSON files; it fills \`platform\`, \`conversationId\`, \`conversationKind\`, and \`userId\` for the current conversation automatically.
145
+
146
+ ### Creating Events Manually
147
+ 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:
148
+ \`\`\`bash
149
+ cat > ${workspaceRoot}/events/dentist-reminder-$(date +%s).json << 'EOF'
150
+ {"type": "one-shot", "platform": "<platform>", "conversationId": "<conversationId>", "conversationKind": "<direct|shared>", "userId": "<requester userId>", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
151
+ EOF
152
+ \`\`\`
153
+
154
+ ### Managing Events
155
+ - List: \`ls ${workspaceRoot}/events/\`
156
+ - View: \`cat ${workspaceRoot}/events/foo.json\`
157
+ - Delete/cancel: \`rm ${workspaceRoot}/events/foo.json\``;
158
+ }
159
+ return `Events live in the host-side mama control plane, not necessarily in this runtime filesystem.
160
+
161
+ 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.
162
+
163
+ 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 mama events directory.`;
164
+ }
165
+ function buildSystemPrompt(workspacePath, conversationId, conversationKind, currentUserId, memory, sandboxConfig, platform, skills, isSyntheticEvent = false) {
166
+ const { workspaceRoot, conversationPath, scratchPath } = buildRuntimePaths(workspacePath, conversationId);
167
+ const sandboxType = sandboxConfig.type;
168
+ const isContainerLike = sandboxType === "container" || sandboxType === "image";
169
+ const isFirecracker = sandboxType === "firecracker";
93
170
  // Format channel mappings
94
171
  const channelMappings = platform.channels.length > 0
95
172
  ? platform.channels.map((c) => `${c.id}\t#${c.name}`).join("\n")
@@ -98,27 +175,29 @@ function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, plat
98
175
  const userMappings = platform.users.length > 0
99
176
  ? platform.users.map((u) => `${u.id}\t@${u.userName}\t${u.displayName}`).join("\n")
100
177
  : "(no users loaded)";
101
- const envDescription = isDocker
102
- ? `You are running inside a Docker container (Alpine Linux).
103
- - Bash working directory: / (use cd or absolute paths)
104
- - Install tools with: apk add <package>
105
- - Your changes persist across sessions`
106
- : isFirecracker
107
- ? `You are running inside a Firecracker microVM.
108
- - Bash working directory: / (use cd or absolute paths)
109
- - Install tools with: apt-get install <package> (Debian-based)
110
- - Your changes persist across sessions`
111
- : `You are running directly on the host machine.
112
- - Bash working directory: ${process.cwd()}
113
- - Be careful with system modifications`;
178
+ const envDescription = buildEnvDescription(sandboxType, workspaceRoot);
179
+ const eventFilesystemInstructions = buildEventFilesystemInstructions(sandboxType, workspaceRoot);
180
+ const syntheticEventInstructions = isSyntheticEvent
181
+ ? `
182
+ ## Synthetic Event Mode
183
+ - You are handling a scheduled/background event, not opening a brand new chat with a stranger.
184
+ - Treat the incoming user message as a self-contained task prepared by an earlier run.
185
+ - Complete the task directly. Avoid generic greetings, self-introductions, or boilerplate offers to help.
186
+ - For reminders/follow-ups, prefer a short direct response that sounds like a continuation of prior intent.
187
+ - If the event text includes tone, brevity, or language instructions, follow them literally.
188
+ `
189
+ : "";
114
190
  return `You are mama, a ${platform.name} bot assistant. Be concise. No emojis.
115
191
 
116
192
  ## Context
117
193
  - For current date/time, use: date
118
194
  - You have access to previous conversation context including tool results from prior turns.
119
- - For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).
120
- - User messages include a \`[in-thread:TS]\` marker when sent from within a Slack thread (TS is the root message timestamp). Without this marker, the message is a top-level channel message.
121
-
195
+ - For older human-readable history beyond your context, search \`log.jsonl\` (contains user messages and your final responses, but not tool results).
196
+ - Structured session history with tool results lives in \`${conversationPath}/sessions/\`.
197
+ - The active top-level session is selected by \`${conversationPath}/sessions/current\`, which points to a timestamped \`.jsonl\` file in the same directory.
198
+ - Scoped/thread sessions use fixed files at \`${conversationPath}/sessions/<scope_id>.jsonl\` (for example \`${conversationPath}/sessions/1777386320.800769.jsonl\`).
199
+ - 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.
200
+ ${syntheticEventInstructions}
122
201
  ${platform.formattingGuide}
123
202
 
124
203
  ## Platform IDs
@@ -130,23 +209,29 @@ When mentioning users, use <@username> format (e.g., <@mario>).
130
209
 
131
210
  ## Environment
132
211
  ${envDescription}
212
+ - Default place for clones, downloads, and experiments: ${scratchPath}
213
+ - Do not use host-only paths unless you are running in host mode and verified they exist.
133
214
 
134
215
  ## Workspace Layout
135
- ${workspacePath}/
136
- ├── MEMORY.md # Global memory (all channels)
216
+ ${workspaceRoot}/
217
+ ├── MEMORY.md # Global memory (all conversations)
137
218
  ├── skills/ # Global CLI tools you create
138
- └── ${channelId}/ # This channel
139
- ├── MEMORY.md # Channel-specific memory
140
- ├── log.jsonl # Message history (no tool results)
219
+ └── ${conversationId}/ # This conversation
220
+ ├── MEMORY.md # Conversation-specific memory
221
+ ├── log.jsonl # Human-readable message history (no tool results)
222
+ ├── sessions/ # Structured session history used for context reconstruction
223
+ │ ├── current # Active top-level session pointer
224
+ │ ├── <timestamp>_<id>.jsonl # Top-level session files
225
+ │ └── <scope_id>.jsonl # Scoped thread/reply session files
141
226
  ├── attachments/ # User-shared files
142
- ├── scratch/ # Your working directory
143
- └── skills/ # Channel-specific tools
227
+ ├── scratch/ # Working directory for clones/downloads/experiments: ${scratchPath}
228
+ └── skills/ # Conversation-specific tools
144
229
 
145
230
  ## Skills (Custom CLI Tools)
146
231
  You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).
147
232
 
148
233
  ### Creating Skills
149
- Store in \`${workspacePath}/skills/<name>/\` (global) or \`${channelPath}/skills/<name>/\` (channel-specific).
234
+ Store in \`${workspaceRoot}/skills/<name>/\` (global) or \`${conversationPath}/skills/<name>/\` (conversation-specific).
150
235
  Each skill directory needs a \`SKILL.md\` with YAML frontmatter:
151
236
 
152
237
  \`\`\`markdown
@@ -167,23 +252,24 @@ Scripts are in: {baseDir}/
167
252
  ${skills.length > 0 ? formatSkillsForPrompt(skills) : "(no skills installed yet)"}
168
253
 
169
254
  ## Events
170
- You can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \`${workspacePath}/events/\`.
255
+ You can schedule events that wake you up at specific times or when external things happen.
256
+ ${eventFilesystemInstructions}
171
257
 
172
258
  ### Event Types
173
259
 
174
260
  **Immediate** - Triggers as soon as harness sees the file. Use in scripts/webhooks to signal external events.
175
261
  \`\`\`json
176
- {"type": "immediate", "platform": "${platform.name}", "channelId": "${channelId}", "text": "New GitHub issue opened"}
262
+ {"type": "immediate", "platform": "${platform.name}", "conversationId": "${conversationId}", "conversationKind": "${conversationKind}", "userId": "${currentUserId ?? "<requester userId>"}", "text": "New GitHub issue opened"}
177
263
  \`\`\`
178
264
 
179
265
  **One-shot** - Triggers once at a specific time. Use for reminders.
180
266
  \`\`\`json
181
- {"type": "one-shot", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
267
+ {"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"}
182
268
  \`\`\`
183
269
 
184
270
  **Periodic** - Triggers on a cron schedule. Use for recurring tasks.
185
271
  \`\`\`json
186
- {"type": "periodic", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
272
+ {"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}"}
187
273
  \`\`\`
188
274
 
189
275
  ### Cron Format
@@ -196,22 +282,12 @@ You can schedule events that wake you up at specific times or when external thin
196
282
  ### Timezones
197
283
  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}.
198
284
 
199
- ### Platform Routing
200
- Set \`platform\` to the target bot platform (\`${platform.name}\` for this conversation). When only one platform is running, omitting \`platform\` is allowed for backward compatibility, but include it by default to avoid ambiguity.
285
+ ### Platform and Credential Routing
286
+ Set \`platform\` to the target bot platform (\`${platform.name}\` for this conversation). Include it explicitly to avoid ambiguity.
201
287
 
202
- ### Creating Events
203
- Use unique filenames to avoid overwriting existing events. Include a timestamp or random suffix:
204
- \`\`\`bash
205
- cat > ${workspacePath}/events/dentist-reminder-$(date +%s).json << 'EOF'
206
- {"type": "one-shot", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
207
- EOF
208
- \`\`\`
209
- Or check if file exists first before creating.
288
+ 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.
210
289
 
211
- ### Managing Events
212
- - List: \`ls ${workspacePath}/events/\`
213
- - View: \`cat ${workspacePath}/events/foo.json\`
214
- - Delete/cancel: \`rm ${workspacePath}/events/foo.json\`
290
+ 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\`.
215
291
 
216
292
  ### When Events Trigger
217
293
  You receive a message like:
@@ -231,16 +307,16 @@ Maximum 5 events can be queued. Don't create excessive immediate or periodic eve
231
307
 
232
308
  ## Memory
233
309
  Write to MEMORY.md files to persist context across conversations.
234
- - Global (${workspacePath}/MEMORY.md): skills, preferences, project info
235
- - Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work
310
+ - Global (${workspaceRoot}/MEMORY.md): skills, preferences, project info
311
+ - Conversation (${conversationPath}/MEMORY.md): conversation-specific decisions, ongoing work
236
312
  Update when you learn something important or when asked to remember something.
237
313
 
238
314
  ### Current Memory
239
315
  ${memory}
240
316
 
241
317
  ## System Configuration Log
242
- Maintain ${workspacePath}/SYSTEM.md to log all environment modifications:
243
- - Installed packages (apk add, npm install, pip install)
318
+ Maintain ${workspaceRoot}/SYSTEM.md to log all environment modifications:
319
+ - Installed packages (apt install, npm install, uv pip install)
244
320
  - Environment variables set
245
321
  - Config files modified (~/.gitconfig, cron jobs, etc.)
246
322
  - Skill dependencies installed
@@ -250,8 +326,8 @@ Update this file whenever you modify the environment. On fresh container, read i
250
326
  ## Log Queries (for older history)
251
327
  Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
252
328
  The log contains user messages and your final responses (not tool calls/results).
253
- ${isDocker ? "Install jq: apk add jq" : ""}
254
- ${isFirecracker ? "Install jq: apt-get install jq" : ""}
329
+ Use \`log.jsonl\` for quick grep-style history. Use \`${conversationPath}/sessions/\` when you need structured turns, tool outputs, or branch lineage.
330
+ ${isContainerLike || isFirecracker ? "Install jq: apt-get install jq" : ""}
255
331
 
256
332
  \`\`\`bash
257
333
  # Recent messages
@@ -262,6 +338,10 @@ grep -i "topic" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user
262
338
 
263
339
  # Messages from specific user
264
340
  grep '"userName":"mario"' log.jsonl | tail -20 | jq -c '{date: .date[0:19], text}'
341
+
342
+ # Inspect top-level session pointer and available session files
343
+ cat sessions/current
344
+ ls -1 sessions/
265
345
  \`\`\`
266
346
 
267
347
  ## Tools
@@ -279,162 +359,80 @@ function truncate(text, maxLen) {
279
359
  return text;
280
360
  return `${text.substring(0, maxLen - 3)}...`;
281
361
  }
282
- function extractToolResultText(result) {
283
- if (typeof result === "string") {
284
- return result;
362
+ export function getUnresolvedSandboxPathContext(sandboxConfig, hostWorkspaceRoot) {
363
+ if (sandboxConfig.type === "image") {
364
+ return createMountedRuntimePathContext(hostWorkspaceRoot, "/workspace");
285
365
  }
286
- if (result &&
287
- typeof result === "object" &&
288
- "content" in result &&
289
- Array.isArray(result.content)) {
290
- const content = result.content;
291
- const textParts = [];
292
- for (const part of content) {
293
- if (part.type === "text" && part.text) {
294
- textParts.push(part.text);
295
- }
296
- }
297
- if (textParts.length > 0) {
298
- return textParts.join("\n");
299
- }
300
- }
301
- return JSON.stringify(result);
302
- }
303
- function formatToolArgsForSlack(_toolName, args) {
304
- const lines = [];
305
- for (const [key, value] of Object.entries(args)) {
306
- if (key === "label")
307
- continue;
308
- if (key === "path" && typeof value === "string") {
309
- const offset = args.offset;
310
- const limit = args.limit;
311
- if (offset !== undefined && limit !== undefined) {
312
- lines.push(`${value}:${offset}-${offset + limit}`);
313
- }
314
- else {
315
- lines.push(value);
316
- }
317
- continue;
318
- }
319
- if (key === "offset" || key === "limit")
320
- continue;
321
- if (typeof value === "string") {
322
- lines.push(value);
323
- }
324
- else {
325
- lines.push(JSON.stringify(value));
326
- }
327
- }
328
- return lines.join("\n");
366
+ return createExecutor(sandboxConfig).getPathContext(hostWorkspaceRoot);
329
367
  }
330
- // ============================================================================
331
- // Agent runner
332
- // ============================================================================
333
- /**
334
- * Create a new AgentRunner for a channel.
335
- * Sets up the session and subscribes to events once.
336
- *
337
- * Runner caching is handled by the caller (channelStates in main.ts).
338
- * This is a stateless factory function.
339
- */
340
- export async function createRunner(sandboxConfig, sessionKey, channelId, channelDir, workspaceDir) {
341
- const agentConfig = loadAgentConfig(workspaceDir);
342
- // Initialize logger with settings from config
343
- log.initLogger({
344
- logFormat: agentConfig.logFormat,
345
- logLevel: agentConfig.logLevel,
346
- });
347
- const executor = createExecutor(sandboxConfig);
348
- const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
349
- // Create tools (per-runner, with per-runner upload function setter)
350
- const { tools, setUploadFunction } = createMamaTools(executor);
351
- // Resolve model from config
352
- // Use 'as any' cast because agentConfig.provider/model are plain strings,
353
- // while getModel() has constrained generic types for known providers.
354
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
355
- const model = getModel(agentConfig.provider, agentConfig.model);
356
- // Initial system prompt (will be updated each run with fresh memory/channels/users/skills)
357
- const memory = await getMemory(channelDir);
358
- const skills = loadMamaSkills(channelDir, workspacePath);
359
- const emptyPlatform = {
360
- name: "slack",
361
- formattingGuide: "",
362
- channels: [],
363
- users: [],
368
+ function createRunnerExecutionContext(sandboxConfig, vaultManager, provisioner, workspaceDir, hostWorkspacePath) {
369
+ const executionResolver = vaultManager &&
370
+ sandboxConfig.type !== "host" &&
371
+ (vaultManager.isEnabled() ||
372
+ sandboxConfig.type === "container" ||
373
+ sandboxConfig.type === "image" ||
374
+ sandboxConfig.type === "cloudflare" ||
375
+ sandboxConfig.type === "firecracker")
376
+ ? new ActorExecutionResolver(sandboxConfig, vaultManager, provisioner, workspaceDir)
377
+ : undefined;
378
+ // activeExecutor is replaced at the start of each run() call when executionResolver
379
+ // is present, so the stable `executor` wrapper always delegates to the latest resolved value.
380
+ let activeExecutor = executionResolver !== undefined
381
+ ? createExecutor({ type: "host" })
382
+ : createExecutor(sandboxConfig);
383
+ const executor = {
384
+ exec(command, options) {
385
+ return activeExecutor.exec(command, options);
386
+ },
387
+ getWorkspacePath(hostPath) {
388
+ return activeExecutor.getWorkspacePath(hostPath);
389
+ },
390
+ getSandboxConfig() {
391
+ return activeExecutor.getSandboxConfig();
392
+ },
393
+ getPathContext(hostWorkspaceRoot) {
394
+ return activeExecutor.getPathContext(hostWorkspaceRoot);
395
+ },
364
396
  };
365
- const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, emptyPlatform, skills);
366
- // Create session manager and settings manager
367
- // Channel sessions use {channelDir}/sessions/current.
368
- // Thread sessions use fixed files: {channelDir}/sessions/{threadTs}.jsonl
369
- const sessionDir = getSessionDir(channelDir, sessionKey);
370
- const isThread = sessionKey.includes(":");
371
- let sessionManager;
372
- let contextFile;
373
- if (isThread) {
374
- const threadFile = getThreadSessionFile(channelDir, sessionKey);
375
- const existing = tryResolveThreadSession(threadFile);
376
- if (existing) {
377
- contextFile = existing;
378
- sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
379
- }
380
- else {
381
- const channelSource = resolveChannelSessionFile(channelDir);
382
- if (channelSource) {
383
- try {
384
- contextFile = forkThreadSessionFile(channelSource, threadFile, channelDir);
385
- sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
386
- }
387
- catch {
388
- contextFile = createManagedSessionFileAtPath(threadFile, channelDir);
389
- sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
390
- }
391
- }
392
- else {
393
- contextFile = createManagedSessionFileAtPath(threadFile, channelDir);
394
- sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
395
- }
396
- }
397
- }
398
- else {
399
- // Channel/DM session: normal resolve
400
- contextFile = resolveManagedSessionFile(sessionDir, channelDir);
401
- sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
402
- }
403
- const sessionUuid = extractSessionUuid(contextFile);
404
- // Used for Slack thread filtering — for non-Slack platforms this is effectively a no-op
405
- const rootTs = extractSessionSuffix(sessionKey);
406
- const settingsManager = createMamaSettingsManager(join(channelDir, ".."));
407
- // Create AuthStorage and ModelRegistry
408
- // Auth stored outside workspace so agent can't access it
397
+ return {
398
+ executionResolver,
399
+ executor,
400
+ getPathContext: () => executor.getPathContext(hostWorkspacePath),
401
+ async resolveExecutorForRun(context) {
402
+ if (!executionResolver)
403
+ return;
404
+ activeExecutor = await executionResolver.resolve(context);
405
+ },
406
+ };
407
+ }
408
+ async function createConfiguredAgentSession(params) {
409
+ const { conversationId, workspaceDir, runtimeWorkspaceRoot, systemPrompt, model, thinkingLevel, tools, sessionManager, settingsManager, } = params;
409
410
  const authStorage = AuthStorage.create(join(homedir(), ".pi", "mama", "auth.json"));
410
- const modelRegistry = new ModelRegistry(authStorage);
411
- // Create agent
411
+ const modelRegistry = ModelRegistry.create(authStorage);
412
412
  const agent = new Agent({
413
413
  initialState: {
414
414
  systemPrompt,
415
415
  model,
416
- thinkingLevel: agentConfig.thinkingLevel ?? "off",
416
+ thinkingLevel,
417
417
  tools,
418
418
  },
419
419
  convertToLlm,
420
420
  getApiKey: async () => {
421
421
  const key = await modelRegistry.getApiKeyForProvider(model.provider);
422
- if (!key)
422
+ if (!key) {
423
423
  throw new Error(`No API key for provider "${model.provider}". Set the appropriate environment variable or configure via auth.json`);
424
+ }
424
425
  return key;
425
426
  },
426
427
  });
427
- // Load existing messages
428
428
  const loadedSession = sessionManager.buildSessionContext();
429
429
  if (loadedSession.messages.length > 0) {
430
- agent.replaceMessages(loadedSession.messages);
431
- log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
430
+ agent.state.messages = loadedSession.messages;
431
+ log.logInfo(`[${conversationId}] Reloaded ${loadedSession.messages.length} messages from session context`);
432
432
  }
433
- // Load extensions, skills, prompts, themes via DefaultResourceLoader
434
- // This reads ~/.pi/agent/settings.json (packages, extensions enable/disable)
435
- // and discovers resources from standard locations + npm/git packages.
436
433
  const resourceLoader = new DefaultResourceLoader({
437
434
  cwd: workspaceDir,
435
+ agentDir: getAgentDir(),
438
436
  systemPrompt,
439
437
  });
440
438
  try {
@@ -442,124 +440,355 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
442
440
  const extResult = resourceLoader.getExtensions();
443
441
  if (extResult.errors.length > 0) {
444
442
  for (const err of extResult.errors) {
445
- log.logWarning(`[${channelId}] Extension load error: ${err.path}`, err.error);
443
+ log.logWarning(`[${conversationId}] Extension load error: ${err.path}`, err.error);
446
444
  }
447
445
  }
448
- log.logInfo(`[${channelId}] Loaded ${extResult.extensions.length} extension(s): ${extResult.extensions.map((e) => e.path).join(", ")}`);
446
+ log.logInfo(`[${conversationId}] Loaded ${extResult.extensions.length} extension(s): ${extResult.extensions.map((extension) => extension.path).join(", ")}`);
449
447
  }
450
448
  catch (error) {
451
- log.logWarning(`[${channelId}] Failed to load resources`, String(error));
449
+ log.logWarning(`[${conversationId}] Failed to load resources`, String(error));
452
450
  }
453
451
  const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
454
- // Create AgentSession wrapper
455
452
  const session = new AgentSession({
456
453
  agent,
457
454
  sessionManager,
458
455
  settingsManager,
459
- cwd: workspaceDir,
456
+ cwd: runtimeWorkspaceRoot,
460
457
  modelRegistry,
461
458
  resourceLoader,
462
459
  baseToolsOverride,
463
460
  });
464
- // Mutable per-run state - event handler references this
465
- const runState = {
461
+ return { agent, session };
462
+ }
463
+ function createEmptyUsageTotals() {
464
+ return {
465
+ input: 0,
466
+ output: 0,
467
+ cacheRead: 0,
468
+ cacheWrite: 0,
469
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
470
+ };
471
+ }
472
+ function createRunState() {
473
+ return {
466
474
  responseCtx: null,
467
475
  logCtx: null,
468
476
  queue: null,
469
477
  pendingTools: new Map(),
470
- totalUsage: {
471
- input: 0,
472
- output: 0,
473
- cacheRead: 0,
474
- cacheWrite: 0,
475
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
476
- },
478
+ totalUsage: createEmptyUsageTotals(),
477
479
  llmCallCount: 0,
478
480
  stopReason: "stop",
479
481
  errorMessage: undefined,
480
482
  };
481
- // Subscribe to events ONCE
483
+ }
484
+ function resetRunState(runState, responseCtx, sessionConversation, userName, sessionUuid) {
485
+ runState.responseCtx = responseCtx;
486
+ runState.logCtx = {
487
+ conversationId: sessionConversation,
488
+ userName,
489
+ conversationName: undefined,
490
+ sessionId: sessionUuid,
491
+ };
492
+ runState.pendingTools.clear();
493
+ runState.totalUsage = createEmptyUsageTotals();
494
+ runState.llmCallCount = 0;
495
+ runState.stopReason = "stop";
496
+ runState.errorMessage = undefined;
497
+ }
498
+ function createRunQueue(responseCtx) {
499
+ let queueChain = Promise.resolve();
500
+ return {
501
+ queue: {
502
+ enqueue(fn, errorContext) {
503
+ queueChain = queueChain.then(async () => {
504
+ try {
505
+ await fn();
506
+ }
507
+ catch (err) {
508
+ const errMsg = err instanceof Error ? err.message : String(err);
509
+ log.logWarning(`API error (${errorContext})`, errMsg);
510
+ try {
511
+ await responseCtx.respondDiagnostic(`Error: ${errMsg}`, { style: "error" });
512
+ }
513
+ catch {
514
+ // Ignore
515
+ }
516
+ }
517
+ });
518
+ },
519
+ },
520
+ wait: () => queueChain,
521
+ };
522
+ }
523
+ function padTwoDigits(n) {
524
+ return n.toString().padStart(2, "0");
525
+ }
526
+ function formatTimestampedUserMessage(message) {
527
+ const now = new Date();
528
+ const offset = -now.getTimezoneOffset();
529
+ const offsetSign = offset >= 0 ? "+" : "-";
530
+ const offsetHours = padTwoDigits(Math.floor(Math.abs(offset) / 60));
531
+ const offsetMins = padTwoDigits(Math.abs(offset) % 60);
532
+ const timestamp = `${now.getFullYear()}-${padTwoDigits(now.getMonth() + 1)}-${padTwoDigits(now.getDate())} ` +
533
+ `${padTwoDigits(now.getHours())}:${padTwoDigits(now.getMinutes())}:${padTwoDigits(now.getSeconds())}` +
534
+ `${offsetSign}${offsetHours}:${offsetMins}`;
535
+ const threadContext = message.threadTs ? ` [in-thread:${message.threadTs}]` : "";
536
+ return `[${timestamp}] [${message.userName || "unknown"}]${threadContext}: ${message.text}`;
537
+ }
538
+ function collectMessageAttachments(message, workspacePath) {
539
+ const imageAttachments = [];
540
+ const nonImagePaths = [];
541
+ for (const attachment of message.attachments || []) {
542
+ const fullPath = `${workspacePath}/${attachment.localPath}`;
543
+ const mimeType = getImageMimeType(attachment.localPath);
544
+ if (mimeType && existsSync(fullPath)) {
545
+ try {
546
+ imageAttachments.push({
547
+ type: "image",
548
+ mimeType,
549
+ data: readFileSync(fullPath).toString("base64"),
550
+ });
551
+ }
552
+ catch {
553
+ nonImagePaths.push(fullPath);
554
+ }
555
+ }
556
+ else {
557
+ nonImagePaths.push(fullPath);
558
+ }
559
+ }
560
+ return { imageAttachments, nonImagePaths };
561
+ }
562
+ function buildPromptPayload(message, workspacePath) {
563
+ let userMessage = formatTimestampedUserMessage(message);
564
+ const { imageAttachments, nonImagePaths } = collectMessageAttachments(message, workspacePath);
565
+ if (nonImagePaths.length > 0) {
566
+ userMessage += `\n\n<slack_attachments>\n${nonImagePaths.join("\n")}\n</slack_attachments>`;
567
+ }
568
+ return { userMessage, imageAttachments };
569
+ }
570
+ async function writePromptDebugContext(conversationDir, systemPrompt, session, userMessage, imageAttachmentCount) {
571
+ const debugContext = {
572
+ systemPrompt,
573
+ messages: session.messages,
574
+ newUserMessage: userMessage,
575
+ imageAttachmentCount,
576
+ };
577
+ await writeFile(join(conversationDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2));
578
+ }
579
+ function getFinalAssistantText(session) {
580
+ const lastAssistant = session.messages.filter((message) => message.role === "assistant").pop();
581
+ return (lastAssistant?.content
582
+ .filter((content) => content.type === "text")
583
+ .map((content) => content.text)
584
+ .join("\n") || "");
585
+ }
586
+ async function finalizeRunResponse(responseCtx, session, runState) {
587
+ if (runState.stopReason === "error" && runState.errorMessage) {
588
+ try {
589
+ await responseCtx.replaceResponse("_Sorry, something went wrong_");
590
+ await responseCtx.respondDiagnostic(`Error: ${runState.errorMessage}`, {
591
+ style: "error",
592
+ });
593
+ }
594
+ catch (err) {
595
+ const errMsg = err instanceof Error ? err.message : String(err);
596
+ log.logWarning("Failed to post error message", errMsg);
597
+ }
598
+ return;
599
+ }
600
+ const finalText = getFinalAssistantText(session);
601
+ if (finalText.trim() === "[SILENT]" || finalText.trim().startsWith("[SILENT]")) {
602
+ try {
603
+ await responseCtx.deleteResponse();
604
+ log.logInfo("Silent response - deleted message and thread");
605
+ }
606
+ catch (err) {
607
+ const errMsg = err instanceof Error ? err.message : String(err);
608
+ log.logWarning("Failed to delete message for silent response", errMsg);
609
+ }
610
+ return;
611
+ }
612
+ if (!finalText.trim())
613
+ return;
614
+ try {
615
+ await responseCtx.replaceResponse(finalText);
616
+ }
617
+ catch (err) {
618
+ const errMsg = err instanceof Error ? err.message : String(err);
619
+ log.logWarning("Failed to replace message with final text", errMsg);
620
+ }
621
+ }
622
+ async function reportUsageSummary(ctx) {
623
+ const { session, runState, responseCtx, platform, model, agentConfig, sessionConversation, sessionUuid, waitForQueue, } = ctx;
624
+ if (runState.totalUsage.cost.total <= 0)
625
+ return;
626
+ const lastAssistantMessage = session.messages
627
+ .slice()
628
+ .toReversed()
629
+ .find((message) => message.role === "assistant" && message.stopReason !== "aborted");
630
+ const contextTokens = lastAssistantMessage
631
+ ? lastAssistantMessage.usage.input +
632
+ lastAssistantMessage.usage.output +
633
+ lastAssistantMessage.usage.cacheRead +
634
+ lastAssistantMessage.usage.cacheWrite
635
+ : 0;
636
+ const contextWindow = model.contextWindow || 200000;
637
+ const { totalUsage } = runState;
638
+ const runMetricAttributes = metricAttributes({
639
+ provider: model.provider,
640
+ model: agentConfig.model,
641
+ channel_id: sessionConversation,
642
+ session_id: sessionUuid,
643
+ stop_reason: runState.stopReason,
644
+ llm_calls: runState.llmCallCount,
645
+ });
646
+ Sentry.metrics.distribution("agent.run.tokens_in", totalUsage.input, {
647
+ attributes: runMetricAttributes,
648
+ });
649
+ Sentry.metrics.distribution("agent.run.tokens_out", totalUsage.output, {
650
+ attributes: runMetricAttributes,
651
+ });
652
+ Sentry.metrics.distribution("agent.run.cache_read", totalUsage.cacheRead, {
653
+ attributes: runMetricAttributes,
654
+ });
655
+ Sentry.metrics.distribution("agent.run.cache_write", totalUsage.cacheWrite, {
656
+ attributes: runMetricAttributes,
657
+ });
658
+ Sentry.metrics.distribution("agent.run.cost", totalUsage.cost.total, {
659
+ attributes: runMetricAttributes,
660
+ });
661
+ Sentry.metrics.gauge("agent.context.utilization", contextTokens / contextWindow, {
662
+ unit: "ratio",
663
+ attributes: runMetricAttributes,
664
+ });
665
+ const summary = log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
666
+ if (platform.diagnostics?.showUsageSummary === true) {
667
+ runState.queue.enqueue(() => responseCtx.respondDiagnostic(summary, { style: "muted" }), "usage summary");
668
+ await waitForQueue();
669
+ }
670
+ }
671
+ function reloadSessionMessages(sessionManager, conversationId, agent) {
672
+ const messages = sessionManager.buildSessionContext().messages;
673
+ if (messages.length > 0) {
674
+ agent.state.messages = messages;
675
+ log.logInfo(`[${conversationId}] Reloaded ${messages.length} messages from context`);
676
+ }
677
+ }
678
+ async function prepareRunContext(params) {
679
+ const { message, responseCtx, platform, conversationId, conversationDir, sessionUuid, runState, executor, executionResolver, resolveExecutorForRun, getPathContext, sessionManager, session, agent, setEventContext, setUploadFunction, } = params;
680
+ let pathContext = params.pathContext;
681
+ const sessionConversation = message.sessionKey.split(":")[0];
682
+ await mkdir(join(conversationDir, "scratch"), { recursive: true });
683
+ if (executionResolver) {
684
+ await resolveExecutorForRun({
685
+ platform: platform.name,
686
+ userId: message.userId,
687
+ conversationId,
688
+ });
689
+ pathContext = getPathContext();
690
+ }
691
+ reloadSessionMessages(sessionManager, conversationId, agent);
692
+ const memory = await getMemory(conversationDir);
693
+ const skills = loadMamaSkills(conversationDir, pathContext.runtimeWorkspaceRoot);
694
+ const systemPrompt = buildSystemPrompt(pathContext.runtimeWorkspaceRoot, conversationId, message.conversationKind, message.userId, memory, executor.getSandboxConfig(), platform, skills, message.id.startsWith("event:"));
695
+ session.agent.state.systemPrompt = systemPrompt;
696
+ setEventContext({
697
+ platform: platform.name,
698
+ conversationId,
699
+ conversationKind: message.conversationKind,
700
+ userId: message.userId,
701
+ });
702
+ setUploadFunction(async (filePath, title) => {
703
+ const hostPath = translateRuntimePathToHost(filePath, pathContext);
704
+ await responseCtx.uploadFile(hostPath, title);
705
+ });
706
+ resetRunState(runState, responseCtx, sessionConversation, message.userName, sessionUuid);
707
+ const runQueue = createRunQueue(responseCtx);
708
+ runState.queue = runQueue.queue;
709
+ log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
710
+ log.logInfo(`Channels: ${platform.channels.length}, Users: ${platform.users.length}`);
711
+ const { userMessage, imageAttachments } = buildPromptPayload(message, pathContext.runtimeWorkspaceRoot);
712
+ await writePromptDebugContext(conversationDir, systemPrompt, session, userMessage, imageAttachments.length);
713
+ return {
714
+ sessionConversation,
715
+ runQueue,
716
+ userMessage,
717
+ imageAttachments,
718
+ pathContext,
719
+ };
720
+ }
721
+ function attachSessionEventHandlers(params) {
722
+ const { session, runState, model, agentConfig } = params;
482
723
  session.subscribe(async (event) => {
483
- // Skip if no active run
484
724
  if (!runState.responseCtx || !runState.logCtx || !runState.queue)
485
725
  return;
486
726
  const { responseCtx, logCtx, queue, pendingTools } = runState;
487
- const baseAttrs = { channel_id: logCtx.channelId, session_id: logCtx.sessionId };
727
+ const baseAttrs = { channel_id: logCtx.conversationId, session_id: logCtx.sessionId };
488
728
  if (event.type === "tool_execution_start") {
489
- const agentEvent = event;
490
- const args = agentEvent.args;
491
- const label = args.label || agentEvent.toolName;
492
- pendingTools.set(agentEvent.toolCallId, {
493
- toolName: agentEvent.toolName,
494
- args: agentEvent.args,
729
+ const args = (event.args ?? {});
730
+ const label = args.label || event.toolName;
731
+ pendingTools.set(event.toolCallId, {
732
+ toolName: event.toolName,
733
+ args: event.args,
495
734
  startTime: Date.now(),
496
735
  });
497
736
  addLifecycleBreadcrumb("agent.tool.started", {
498
- tool: agentEvent.toolName,
737
+ tool: event.toolName,
499
738
  ...baseAttrs,
500
739
  });
501
- log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
502
- // Tool labels are omitted from the main message to reduce Slack noise.
503
- // Tool execution details are still posted to the thread (see tool_execution_end).
740
+ log.logToolStart(logCtx, event.toolName, label, event.args);
741
+ return;
504
742
  }
505
- else if (event.type === "tool_execution_end") {
506
- const agentEvent = event;
507
- const resultStr = extractToolResultText(agentEvent.result);
508
- const pending = pendingTools.get(agentEvent.toolCallId);
509
- pendingTools.delete(agentEvent.toolCallId);
743
+ if (event.type === "tool_execution_end") {
744
+ const resultStr = extractToolResultText(event.result);
745
+ const pending = pendingTools.get(event.toolCallId);
746
+ pendingTools.delete(event.toolCallId);
510
747
  const durationMs = pending ? Date.now() - pending.startTime : 0;
511
748
  Sentry.metrics.count("agent.tool.calls", 1, {
512
749
  attributes: metricAttributes({
513
- tool: agentEvent.toolName,
514
- error: String(agentEvent.isError),
750
+ tool: event.toolName,
751
+ error: String(event.isError),
515
752
  ...baseAttrs,
516
753
  }),
517
754
  });
518
755
  Sentry.metrics.distribution("agent.tool.duration", durationMs, {
519
756
  unit: "millisecond",
520
757
  attributes: metricAttributes({
521
- tool: agentEvent.toolName,
758
+ tool: event.toolName,
522
759
  ...baseAttrs,
523
760
  }),
524
761
  });
525
762
  addLifecycleBreadcrumb("agent.tool.completed", {
526
- tool: agentEvent.toolName,
527
- error: agentEvent.isError,
763
+ tool: event.toolName,
764
+ error: event.isError,
528
765
  duration_ms: durationMs,
529
766
  ...baseAttrs,
530
767
  });
531
- if (agentEvent.isError) {
532
- log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
768
+ if (event.isError) {
769
+ log.logToolError(logCtx, event.toolName, durationMs, resultStr);
533
770
  }
534
771
  else {
535
- log.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);
772
+ log.logToolSuccess(logCtx, event.toolName, durationMs, resultStr);
536
773
  }
537
- // Post args + result to thread
538
- const label = pending?.args ? pending.args.label : undefined;
539
- const argsFormatted = pending
540
- ? formatToolArgsForSlack(agentEvent.toolName, pending.args)
541
- : "(args not found)";
542
- const duration = (durationMs / 1000).toFixed(1);
543
- let threadMessage = `*${agentEvent.isError ? "✗" : "✓"} ${agentEvent.toolName}*`;
544
- if (label)
545
- threadMessage += `: ${label}`;
546
- threadMessage += ` (${duration}s)\n`;
547
- if (argsFormatted)
548
- threadMessage += `\`\`\`\n${argsFormatted}\n\`\`\`\n`;
549
- threadMessage += `*Result:*\n\`\`\`\n${resultStr}\n\`\`\``;
550
- // Only post thread details for tools with meaningful output (bash, attach).
551
- // Skip read/write/edit to reduce Slack noise — their results are in the log.
552
- const quietTools = new Set(["read", "write", "edit"]);
553
- if (!quietTools.has(agentEvent.toolName)) {
554
- queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
774
+ if (shouldSurfaceToolDiagnostic(event.toolName)) {
775
+ const toolResult = {
776
+ toolName: event.toolName,
777
+ label: pending?.args ? pending.args.label : undefined,
778
+ args: pending?.args,
779
+ result: truncate(resultStr, TOOL_RESULT_DIAGNOSTIC_CAP),
780
+ isError: event.isError,
781
+ durationMs,
782
+ };
783
+ queue.enqueue(() => responseCtx.respondToolResult(toolResult), "tool result diagnostic");
555
784
  }
556
- if (agentEvent.isError) {
785
+ if (event.isError) {
557
786
  queue.enqueue(() => responseCtx.respond(`_Error: ${truncate(resultStr, 200)}_`), "tool error");
558
787
  }
788
+ return;
559
789
  }
560
- else if (event.type === "message_start") {
561
- const agentEvent = event;
562
- if (agentEvent.message.role === "assistant") {
790
+ if (event.type === "message_start") {
791
+ if (event.message.role === "assistant") {
563
792
  runState.llmCallCount += 1;
564
793
  addLifecycleBreadcrumb("agent.llm.call.started", {
565
794
  call_index: runState.llmCallCount,
@@ -569,11 +798,11 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
569
798
  });
570
799
  log.logResponseStart(logCtx);
571
800
  }
801
+ return;
572
802
  }
573
- else if (event.type === "message_end") {
574
- const agentEvent = event;
575
- if (agentEvent.message.role === "assistant") {
576
- const assistantMsg = agentEvent.message;
803
+ if (event.type === "message_end") {
804
+ if (event.message.role === "assistant") {
805
+ const assistantMsg = event.message;
577
806
  if (assistantMsg.stopReason) {
578
807
  runState.stopReason = assistantMsg.stopReason;
579
808
  }
@@ -590,7 +819,6 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
590
819
  runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
591
820
  runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
592
821
  runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
593
- // Per-turn LLM metrics
594
822
  const llmAttributes = metricAttributes({
595
823
  provider: model.provider,
596
824
  model: agentConfig.model,
@@ -629,10 +857,9 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
629
857
  cost_total_usd: assistantMsg.usage.cost.total,
630
858
  });
631
859
  }
632
- const content = agentEvent.message.content;
633
860
  const thinkingParts = [];
634
861
  const textParts = [];
635
- for (const part of content) {
862
+ for (const part of assistantMsg.content) {
636
863
  if (part.type === "thinking") {
637
864
  thinkingParts.push(part.thinking);
638
865
  }
@@ -643,296 +870,168 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
643
870
  const text = textParts.join("\n");
644
871
  for (const thinking of thinkingParts) {
645
872
  log.logThinking(logCtx, thinking);
646
- queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
647
- queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread", false);
873
+ queue.enqueue(() => responseCtx.respond(`_${thinking}_`), "thinking main");
874
+ queue.enqueue(() => responseCtx.respondDiagnostic(`_${thinking}_`), "thinking diagnostic");
648
875
  }
649
876
  if (text.trim()) {
650
877
  log.logResponse(logCtx, text);
651
- queue.enqueueMessage(text, "main", "response main");
652
- // Only overflow to thread for texts that will be truncated in main
653
- if (text.length > SLACK_MAX_LENGTH) {
654
- queue.enqueueMessage(text, "thread", "response thread", false);
655
- }
878
+ queue.enqueue(() => responseCtx.respond(text), "response main");
656
879
  }
657
880
  }
881
+ return;
658
882
  }
659
- else if (event.type === "compaction_start") {
883
+ if (event.type === "compaction_start") {
660
884
  log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
661
885
  queue.enqueue(() => responseCtx.respond("_Compacting context..._"), "compaction start");
886
+ return;
662
887
  }
663
- else if (event.type === "compaction_end") {
664
- const compEvent = event;
665
- if (compEvent.result) {
666
- log.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);
888
+ if (event.type === "compaction_end") {
889
+ if (event.result) {
890
+ log.logInfo(`Auto-compaction complete: ${event.result.tokensBefore} tokens compacted`);
667
891
  }
668
- else if (compEvent.aborted) {
892
+ else if (event.aborted) {
669
893
  log.logInfo("Auto-compaction aborted");
670
894
  }
895
+ return;
671
896
  }
672
- else if (event.type === "auto_retry_start") {
673
- const retryEvent = event;
674
- log.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);
675
- queue.enqueue(() => responseCtx.respond(`_Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})..._`), "retry");
897
+ if (event.type === "auto_retry_start") {
898
+ log.logWarning(`Retrying (${event.attempt}/${event.maxAttempts})`, event.errorMessage);
899
+ queue.enqueue(() => responseCtx.respond(`_Retrying (${event.attempt}/${event.maxAttempts})..._`), "retry");
676
900
  }
677
901
  });
678
- // Message limit constant
679
- const SLACK_MAX_LENGTH = 40000;
680
- const splitForSlack = (text) => {
681
- if (text.length <= SLACK_MAX_LENGTH)
682
- return [text];
683
- const parts = [];
684
- let remaining = text;
685
- let partNum = 1;
686
- while (remaining.length > 0) {
687
- const chunk = remaining.substring(0, SLACK_MAX_LENGTH - 50);
688
- remaining = remaining.substring(SLACK_MAX_LENGTH - 50);
689
- const suffix = remaining.length > 0 ? `\n_(continued ${partNum}...)_` : "";
690
- parts.push(chunk + suffix);
691
- partNum++;
902
+ }
903
+ // Cap raw tool output before handing it to adapters. Bash output can be MB; without
904
+ // this each adapter's splitter would fan it out into many sequential platform posts.
905
+ const TOOL_RESULT_DIAGNOSTIC_CAP = 8000;
906
+ function extractToolResultText(result) {
907
+ if (typeof result === "string") {
908
+ return result;
909
+ }
910
+ if (result &&
911
+ typeof result === "object" &&
912
+ "content" in result &&
913
+ Array.isArray(result.content)) {
914
+ const content = result.content;
915
+ const textParts = [];
916
+ for (const part of content) {
917
+ if (part.type === "text" && part.text) {
918
+ textParts.push(part.text);
919
+ }
692
920
  }
693
- return parts;
921
+ if (textParts.length > 0) {
922
+ return textParts.join("\n");
923
+ }
924
+ }
925
+ return JSON.stringify(result);
926
+ }
927
+ // ============================================================================
928
+ // Agent runner
929
+ // ============================================================================
930
+ /**
931
+ * Create a new AgentRunner for a channel.
932
+ * Sets up the session and subscribes to events once.
933
+ *
934
+ * Runner caching is handled by the caller (channelStates in main.ts).
935
+ * This is a stateless factory function.
936
+ */
937
+ export async function createRunner(sandboxConfig, sessionKey, conversationId, conversationDir, workspaceDir, sessionScope, vaultManager, provisioner) {
938
+ const agentConfig = loadAgentConfigForConversation(conversationDir);
939
+ // Initialize logger with settings from config
940
+ log.initLogger({
941
+ logFormat: agentConfig.logFormat,
942
+ logLevel: agentConfig.logLevel,
943
+ });
944
+ const workspaceBase = join(conversationDir, "..");
945
+ const { executionResolver, executor, getPathContext, resolveExecutorForRun } = createRunnerExecutionContext(sandboxConfig, vaultManager, provisioner, workspaceDir, workspaceBase);
946
+ let pathContext = getUnresolvedSandboxPathContext(sandboxConfig, workspaceBase);
947
+ // Create tools (per-runner, with per-runner upload function setter)
948
+ const { tools, setUploadFunction, setEventContext } = createMamaTools(executor, workspaceDir);
949
+ // Resolve model from config
950
+ // Use 'as any' cast because agentConfig.provider/model are plain strings,
951
+ // while getModel() has constrained generic types for known providers.
952
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
953
+ const model = getModel(agentConfig.provider, agentConfig.model);
954
+ // Initial system prompt (will be updated each run with fresh memory/channels/users/skills)
955
+ const memory = await getMemory(conversationDir);
956
+ const skills = loadMamaSkills(conversationDir, pathContext.runtimeWorkspaceRoot);
957
+ const emptyPlatform = {
958
+ name: "chat",
959
+ formattingGuide: "",
960
+ channels: [],
961
+ users: [],
694
962
  };
963
+ const systemPrompt = buildSystemPrompt(pathContext.runtimeWorkspaceRoot, conversationId, "shared", undefined, memory, sandboxConfig, emptyPlatform, skills);
964
+ // Create session manager and settings manager. Top-level/private sessions
965
+ // use the conversation's current pointer; scoped sessions use fixed files.
966
+ // Platform-specific branch/fork behavior is resolved before runner creation.
967
+ const isThread = sessionKey.includes(":");
968
+ const { sessionDir, contextFile, threadRootMessage } = sessionScope;
969
+ const sessionManager = openManagedSession(contextFile, sessionDir, pathContext.runtimeWorkspaceRoot);
970
+ const threadSessionName = buildThreadSessionName(threadRootMessage);
971
+ if (isThread && threadSessionName && sessionManager.getSessionName() !== threadSessionName) {
972
+ sessionManager.appendSessionInfo(threadSessionName);
973
+ }
974
+ const sessionUuid = extractSessionUuid(contextFile);
975
+ const settingsManager = SettingsManager.inMemory();
976
+ const { agent, session } = await createConfiguredAgentSession({
977
+ conversationId,
978
+ workspaceDir,
979
+ runtimeWorkspaceRoot: pathContext.runtimeWorkspaceRoot,
980
+ systemPrompt,
981
+ model,
982
+ thinkingLevel: agentConfig.thinkingLevel,
983
+ tools,
984
+ sessionManager,
985
+ settingsManager,
986
+ });
987
+ // Mutable per-run state - event handler references this
988
+ const runState = createRunState();
989
+ attachSessionEventHandlers({ session, runState, model, agentConfig });
695
990
  return {
696
991
  async run(message, responseCtx, platform) {
697
- // Extract channelId from sessionKey (format: "channelId:rootTs" or just "channelId")
698
- const sessionChannel = message.sessionKey.split(":")[0];
699
- // Ensure channel directory exists
700
- await mkdir(channelDir, { recursive: true });
701
- // Sync messages from log.jsonl that arrived while we were offline or busy
702
- // Exclude the current message (it will be added via prompt())
703
- // Default sync range is 10 days (handled by syncLogToSessionManager)
704
- // Thread filter ensures only messages from this session's thread are synced
705
- const threadFilter = message.sessionKey.includes(":")
706
- ? { scope: "thread", rootTs, threadTs: message.threadTs }
707
- : { scope: "top-level", rootTs };
708
- const syncedCount = await syncLogToSessionManager(sessionManager, channelDir, message.id, undefined, threadFilter);
709
- if (syncedCount > 0) {
710
- log.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);
711
- }
712
- // Reload messages from context.jsonl
713
- // This picks up any messages synced above
714
- const reloadedSession = sessionManager.buildSessionContext();
715
- if (reloadedSession.messages.length > 0) {
716
- agent.replaceMessages(reloadedSession.messages);
717
- log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
718
- }
719
- // Update system prompt with fresh memory, channel/user info, and skills
720
- const memory = await getMemory(channelDir);
721
- const skills = loadMamaSkills(channelDir, workspacePath);
722
- const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, platform, skills);
723
- session.agent.setSystemPrompt(systemPrompt);
724
- // Set up file upload function
725
- setUploadFunction(async (filePath, title) => {
726
- const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
727
- await responseCtx.uploadFile(hostPath, title);
992
+ const prepared = await prepareRunContext({
993
+ message,
994
+ responseCtx,
995
+ platform,
996
+ conversationId,
997
+ conversationDir,
998
+ sessionUuid,
999
+ runState,
1000
+ executor,
1001
+ executionResolver,
1002
+ resolveExecutorForRun,
1003
+ getPathContext,
1004
+ sessionManager,
1005
+ session,
1006
+ agent,
1007
+ setEventContext,
1008
+ setUploadFunction,
1009
+ pathContext,
728
1010
  });
729
- // Reset per-run state
730
- runState.responseCtx = responseCtx;
731
- runState.logCtx = {
732
- channelId: sessionChannel,
733
- userName: message.userName,
734
- channelName: undefined,
735
- sessionId: sessionUuid,
736
- };
737
- runState.pendingTools.clear();
738
- runState.totalUsage = {
739
- input: 0,
740
- output: 0,
741
- cacheRead: 0,
742
- cacheWrite: 0,
743
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
744
- };
745
- runState.llmCallCount = 0;
746
- runState.stopReason = "stop";
747
- runState.errorMessage = undefined;
748
- // Create queue for this run
749
- let queueChain = Promise.resolve();
750
- runState.queue = {
751
- enqueue(fn, errorContext) {
752
- queueChain = queueChain.then(async () => {
753
- try {
754
- await fn();
755
- }
756
- catch (err) {
757
- const errMsg = err instanceof Error ? err.message : String(err);
758
- log.logWarning(`API error (${errorContext})`, errMsg);
759
- try {
760
- // Split long error messages to avoid msg_too_long
761
- const errParts = splitForSlack(`_Error: ${errMsg}_`);
762
- for (const part of errParts) {
763
- await responseCtx.respondInThread(part);
764
- }
765
- }
766
- catch {
767
- // Ignore
768
- }
769
- }
770
- });
771
- },
772
- enqueueMessage(text, target, errorContext, _doLog = true) {
773
- const parts = splitForSlack(text);
774
- for (const part of parts) {
775
- this.enqueue(() => target === "main" ? responseCtx.respond(part) : responseCtx.respondInThread(part), errorContext);
776
- }
777
- },
778
- };
779
- // Log context info
780
- log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
781
- log.logInfo(`Channels: ${platform.channels.length}, Users: ${platform.users.length}`);
782
- // Build user message with timestamp and username prefix
783
- // Format: "[YYYY-MM-DD HH:MM:SS+HH:MM] [username]: message" so LLM knows when and who
784
- const now = new Date();
785
- const pad = (n) => n.toString().padStart(2, "0");
786
- const offset = -now.getTimezoneOffset();
787
- const offsetSign = offset >= 0 ? "+" : "-";
788
- const offsetHours = pad(Math.floor(Math.abs(offset) / 60));
789
- const offsetMins = pad(Math.abs(offset) % 60);
790
- const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;
791
- const threadContext = message.threadTs ? ` [in-thread:${message.threadTs}]` : "";
792
- let userMessage = `[${timestamp}] [${message.userName || "unknown"}]${threadContext}: ${message.text}`;
793
- const imageAttachments = [];
794
- const nonImagePaths = [];
795
- for (const a of message.attachments || []) {
796
- // a.localPath is the path relative to the workspace (same as old a.local)
797
- const fullPath = `${workspacePath}/${a.localPath}`;
798
- const mimeType = getImageMimeType(a.localPath);
799
- if (mimeType && existsSync(fullPath)) {
800
- try {
801
- imageAttachments.push({
802
- type: "image",
803
- mimeType,
804
- data: readFileSync(fullPath).toString("base64"),
805
- });
806
- }
807
- catch {
808
- nonImagePaths.push(fullPath);
809
- }
810
- }
811
- else {
812
- nonImagePaths.push(fullPath);
813
- }
814
- }
815
- if (nonImagePaths.length > 0) {
816
- userMessage += `\n\n<slack_attachments>\n${nonImagePaths.join("\n")}\n</slack_attachments>`;
817
- }
818
- // Debug: write context to last_prompt.jsonl
819
- const debugContext = {
820
- systemPrompt,
821
- messages: session.messages,
822
- newUserMessage: userMessage,
823
- imageAttachmentCount: imageAttachments.length,
824
- };
825
- await writeFile(join(channelDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2));
1011
+ pathContext = prepared.pathContext;
826
1012
  addLifecycleBreadcrumb("agent.prompt.sent", {
827
1013
  provider: model.provider,
828
1014
  model: agentConfig.model,
829
- channel_id: sessionChannel,
1015
+ channel_id: prepared.sessionConversation,
830
1016
  session_id: sessionUuid,
831
1017
  attachment_count: message.attachments?.length ?? 0,
832
- image_attachment_count: imageAttachments.length,
1018
+ image_attachment_count: prepared.imageAttachments.length,
833
1019
  });
834
- await session.prompt(userMessage, imageAttachments.length > 0 ? { images: imageAttachments } : undefined);
1020
+ await session.prompt(prepared.userMessage, prepared.imageAttachments.length > 0 ? { images: prepared.imageAttachments } : undefined);
835
1021
  // Wait for queued messages
836
- await queueChain;
837
- // Handle error case - update main message and post error to thread
838
- if (runState.stopReason === "error" && runState.errorMessage) {
839
- try {
840
- await responseCtx.replaceResponse("_Sorry, something went wrong_");
841
- // Split long error messages to avoid msg_too_long
842
- const errorParts = splitForSlack(`_Error: ${runState.errorMessage}_`);
843
- for (const part of errorParts) {
844
- await responseCtx.respondInThread(part);
845
- }
846
- }
847
- catch (err) {
848
- const errMsg = err instanceof Error ? err.message : String(err);
849
- log.logWarning("Failed to post error message", errMsg);
850
- }
851
- }
852
- else {
853
- // Final message update
854
- const messages = session.messages;
855
- const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
856
- const finalText = lastAssistant?.content
857
- .filter((c) => c.type === "text")
858
- .map((c) => c.text)
859
- .join("\n") || "";
860
- // Check for [SILENT] marker - delete message and thread instead of posting
861
- if (finalText.trim() === "[SILENT]" || finalText.trim().startsWith("[SILENT]")) {
862
- try {
863
- await responseCtx.deleteResponse();
864
- log.logInfo("Silent response - deleted message and thread");
865
- }
866
- catch (err) {
867
- const errMsg = err instanceof Error ? err.message : String(err);
868
- log.logWarning("Failed to delete message for silent response", errMsg);
869
- }
870
- }
871
- else if (finalText.trim()) {
872
- try {
873
- const mainText = finalText.length > SLACK_MAX_LENGTH
874
- ? `${finalText.substring(0, SLACK_MAX_LENGTH - 50)}\n\n_(see thread for full response)_`
875
- : finalText;
876
- await responseCtx.replaceResponse(mainText);
877
- }
878
- catch (err) {
879
- const errMsg = err instanceof Error ? err.message : String(err);
880
- log.logWarning("Failed to replace message with final text", errMsg);
881
- }
882
- }
883
- }
884
- // Log usage summary with context info
885
- if (runState.totalUsage.cost.total > 0) {
886
- // Get last non-aborted assistant message for context calculation
887
- const messages = session.messages;
888
- const lastAssistantMessage = messages
889
- .slice()
890
- .reverse()
891
- .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
892
- const contextTokens = lastAssistantMessage
893
- ? lastAssistantMessage.usage.input +
894
- lastAssistantMessage.usage.output +
895
- lastAssistantMessage.usage.cacheRead +
896
- lastAssistantMessage.usage.cacheWrite
897
- : 0;
898
- const contextWindow = model.contextWindow || 200000;
899
- // Run-level Sentry metrics
900
- const { totalUsage } = runState;
901
- const runMetricAttributes = metricAttributes({
902
- provider: model.provider,
903
- model: agentConfig.model,
904
- channel_id: sessionChannel,
905
- session_id: sessionUuid,
906
- stop_reason: runState.stopReason,
907
- llm_calls: runState.llmCallCount,
908
- });
909
- Sentry.metrics.distribution("agent.run.tokens_in", totalUsage.input, {
910
- attributes: runMetricAttributes,
911
- });
912
- Sentry.metrics.distribution("agent.run.tokens_out", totalUsage.output, {
913
- attributes: runMetricAttributes,
914
- });
915
- Sentry.metrics.distribution("agent.run.cache_read", totalUsage.cacheRead, {
916
- attributes: runMetricAttributes,
917
- });
918
- Sentry.metrics.distribution("agent.run.cache_write", totalUsage.cacheWrite, {
919
- attributes: runMetricAttributes,
920
- });
921
- Sentry.metrics.distribution("agent.run.cost", totalUsage.cost.total, {
922
- attributes: runMetricAttributes,
923
- });
924
- Sentry.metrics.gauge("agent.context.utilization", contextTokens / contextWindow, {
925
- unit: "ratio",
926
- attributes: runMetricAttributes,
927
- });
928
- const summary = log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
929
- // Split long summaries to avoid msg_too_long
930
- const summaryParts = splitForSlack(summary);
931
- for (const part of summaryParts) {
932
- runState.queue.enqueue(() => responseCtx.respondInThread(part, { style: "muted" }), "usage summary");
933
- }
934
- await queueChain;
935
- }
1022
+ await prepared.runQueue.wait();
1023
+ await finalizeRunResponse(responseCtx, session, runState);
1024
+ await reportUsageSummary({
1025
+ session,
1026
+ runState,
1027
+ responseCtx,
1028
+ platform,
1029
+ model,
1030
+ agentConfig,
1031
+ sessionConversation: prepared.sessionConversation,
1032
+ sessionUuid,
1033
+ waitForQueue: () => prepared.runQueue.wait(),
1034
+ });
936
1035
  // Clear run state
937
1036
  runState.responseCtx = null;
938
1037
  runState.logCtx = null;
@@ -957,19 +1056,10 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
957
1056
  },
958
1057
  };
959
1058
  }
960
- /**
961
- * Translate container path back to host path for file operations
962
- */
963
- function translateToHostPath(containerPath, channelDir, workspacePath, channelId) {
964
- if (workspacePath === "/workspace") {
965
- const prefix = `/workspace/${channelId}/`;
966
- if (containerPath.startsWith(prefix)) {
967
- return join(channelDir, containerPath.slice(prefix.length));
968
- }
969
- if (containerPath.startsWith("/workspace/")) {
970
- return join(channelDir, "..", containerPath.slice("/workspace/".length));
971
- }
972
- }
973
- return containerPath;
1059
+ export function translateRuntimePathToHost(runtimePath, pathContext) {
1060
+ return pathContext.runtimeToHostPath?.(runtimePath) ?? runtimePath;
1061
+ }
1062
+ export function buildInitialPathContextForTest(sandboxConfig, hostWorkspaceRoot) {
1063
+ return getUnresolvedSandboxPathContext(sandboxConfig, hostWorkspaceRoot);
974
1064
  }
975
1065
  //# sourceMappingURL=agent.js.map