@geminixiang/mikan 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/LICENSE +22 -0
  3. package/README.md +297 -0
  4. package/dist/adapter.d.ts +134 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +2 -0
  7. package/dist/adapter.js.map +1 -0
  8. package/dist/adapters/discord/bot.d.ts +63 -0
  9. package/dist/adapters/discord/bot.d.ts.map +1 -0
  10. package/dist/adapters/discord/bot.js +577 -0
  11. package/dist/adapters/discord/bot.js.map +1 -0
  12. package/dist/adapters/discord/context.d.ts +9 -0
  13. package/dist/adapters/discord/context.d.ts.map +1 -0
  14. package/dist/adapters/discord/context.js +245 -0
  15. package/dist/adapters/discord/context.js.map +1 -0
  16. package/dist/adapters/discord/index.d.ts +3 -0
  17. package/dist/adapters/discord/index.d.ts.map +1 -0
  18. package/dist/adapters/discord/index.js +3 -0
  19. package/dist/adapters/discord/index.js.map +1 -0
  20. package/dist/adapters/shared.d.ts +91 -0
  21. package/dist/adapters/shared.d.ts.map +1 -0
  22. package/dist/adapters/shared.js +191 -0
  23. package/dist/adapters/shared.js.map +1 -0
  24. package/dist/adapters/slack/bot.d.ts +139 -0
  25. package/dist/adapters/slack/bot.d.ts.map +1 -0
  26. package/dist/adapters/slack/bot.js +1272 -0
  27. package/dist/adapters/slack/bot.js.map +1 -0
  28. package/dist/adapters/slack/branch-manager.d.ts +28 -0
  29. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  30. package/dist/adapters/slack/branch-manager.js +117 -0
  31. package/dist/adapters/slack/branch-manager.js.map +1 -0
  32. package/dist/adapters/slack/context.d.ts +12 -0
  33. package/dist/adapters/slack/context.d.ts.map +1 -0
  34. package/dist/adapters/slack/context.js +327 -0
  35. package/dist/adapters/slack/context.js.map +1 -0
  36. package/dist/adapters/slack/index.d.ts +3 -0
  37. package/dist/adapters/slack/index.d.ts.map +1 -0
  38. package/dist/adapters/slack/index.js +3 -0
  39. package/dist/adapters/slack/index.js.map +1 -0
  40. package/dist/adapters/slack/session.d.ts +38 -0
  41. package/dist/adapters/slack/session.d.ts.map +1 -0
  42. package/dist/adapters/slack/session.js +66 -0
  43. package/dist/adapters/slack/session.js.map +1 -0
  44. package/dist/adapters/slack/tools/attach.d.ts +12 -0
  45. package/dist/adapters/slack/tools/attach.d.ts.map +1 -0
  46. package/dist/adapters/slack/tools/attach.js +40 -0
  47. package/dist/adapters/slack/tools/attach.js.map +1 -0
  48. package/dist/adapters/telegram/bot.d.ts +51 -0
  49. package/dist/adapters/telegram/bot.d.ts.map +1 -0
  50. package/dist/adapters/telegram/bot.js +430 -0
  51. package/dist/adapters/telegram/bot.js.map +1 -0
  52. package/dist/adapters/telegram/context.d.ts +9 -0
  53. package/dist/adapters/telegram/context.d.ts.map +1 -0
  54. package/dist/adapters/telegram/context.js +190 -0
  55. package/dist/adapters/telegram/context.js.map +1 -0
  56. package/dist/adapters/telegram/html.d.ts +3 -0
  57. package/dist/adapters/telegram/html.d.ts.map +1 -0
  58. package/dist/adapters/telegram/html.js +98 -0
  59. package/dist/adapters/telegram/html.js.map +1 -0
  60. package/dist/adapters/telegram/index.d.ts +3 -0
  61. package/dist/adapters/telegram/index.d.ts.map +1 -0
  62. package/dist/adapters/telegram/index.js +3 -0
  63. package/dist/adapters/telegram/index.js.map +1 -0
  64. package/dist/agent.d.ts +36 -0
  65. package/dist/agent.d.ts.map +1 -0
  66. package/dist/agent.js +1147 -0
  67. package/dist/agent.js.map +1 -0
  68. package/dist/commands/auto-reply.d.ts +5 -0
  69. package/dist/commands/auto-reply.d.ts.map +1 -0
  70. package/dist/commands/auto-reply.js +79 -0
  71. package/dist/commands/auto-reply.js.map +1 -0
  72. package/dist/commands/index.d.ts +5 -0
  73. package/dist/commands/index.d.ts.map +1 -0
  74. package/dist/commands/index.js +18 -0
  75. package/dist/commands/index.js.map +1 -0
  76. package/dist/commands/login.d.ts +5 -0
  77. package/dist/commands/login.d.ts.map +1 -0
  78. package/dist/commands/login.js +91 -0
  79. package/dist/commands/login.js.map +1 -0
  80. package/dist/commands/model.d.ts +14 -0
  81. package/dist/commands/model.d.ts.map +1 -0
  82. package/dist/commands/model.js +110 -0
  83. package/dist/commands/model.js.map +1 -0
  84. package/dist/commands/new.d.ts +5 -0
  85. package/dist/commands/new.d.ts.map +1 -0
  86. package/dist/commands/new.js +24 -0
  87. package/dist/commands/new.js.map +1 -0
  88. package/dist/commands/parse.d.ts +7 -0
  89. package/dist/commands/parse.d.ts.map +1 -0
  90. package/dist/commands/parse.js +17 -0
  91. package/dist/commands/parse.js.map +1 -0
  92. package/dist/commands/registry.d.ts +4 -0
  93. package/dist/commands/registry.d.ts.map +1 -0
  94. package/dist/commands/registry.js +9 -0
  95. package/dist/commands/registry.js.map +1 -0
  96. package/dist/commands/sandbox.d.ts +10 -0
  97. package/dist/commands/sandbox.d.ts.map +1 -0
  98. package/dist/commands/sandbox.js +83 -0
  99. package/dist/commands/sandbox.js.map +1 -0
  100. package/dist/commands/session-view.d.ts +5 -0
  101. package/dist/commands/session-view.d.ts.map +1 -0
  102. package/dist/commands/session-view.js +62 -0
  103. package/dist/commands/session-view.js.map +1 -0
  104. package/dist/commands/types.d.ts +41 -0
  105. package/dist/commands/types.d.ts.map +1 -0
  106. package/dist/commands/types.js +2 -0
  107. package/dist/commands/types.js.map +1 -0
  108. package/dist/commands/utils.d.ts +8 -0
  109. package/dist/commands/utils.d.ts.map +1 -0
  110. package/dist/commands/utils.js +14 -0
  111. package/dist/commands/utils.js.map +1 -0
  112. package/dist/config.d.ts +59 -0
  113. package/dist/config.d.ts.map +1 -0
  114. package/dist/config.js +370 -0
  115. package/dist/config.js.map +1 -0
  116. package/dist/context.d.ts +17 -0
  117. package/dist/context.d.ts.map +1 -0
  118. package/dist/context.js +24 -0
  119. package/dist/context.js.map +1 -0
  120. package/dist/conversation-history.d.ts +16 -0
  121. package/dist/conversation-history.d.ts.map +1 -0
  122. package/dist/conversation-history.js +144 -0
  123. package/dist/conversation-history.js.map +1 -0
  124. package/dist/download.d.ts +2 -0
  125. package/dist/download.d.ts.map +1 -0
  126. package/dist/download.js +89 -0
  127. package/dist/download.js.map +1 -0
  128. package/dist/env.d.ts +3 -0
  129. package/dist/env.d.ts.map +1 -0
  130. package/dist/env.js +12 -0
  131. package/dist/env.js.map +1 -0
  132. package/dist/events.d.ts +85 -0
  133. package/dist/events.d.ts.map +1 -0
  134. package/dist/events.js +483 -0
  135. package/dist/events.js.map +1 -0
  136. package/dist/execution-resolver.d.ts +25 -0
  137. package/dist/execution-resolver.d.ts.map +1 -0
  138. package/dist/execution-resolver.js +167 -0
  139. package/dist/execution-resolver.js.map +1 -0
  140. package/dist/file-guards.d.ts +9 -0
  141. package/dist/file-guards.d.ts.map +1 -0
  142. package/dist/file-guards.js +56 -0
  143. package/dist/file-guards.js.map +1 -0
  144. package/dist/fs-atomic.d.ts +10 -0
  145. package/dist/fs-atomic.d.ts.map +1 -0
  146. package/dist/fs-atomic.js +45 -0
  147. package/dist/fs-atomic.js.map +1 -0
  148. package/dist/index.d.ts +10 -0
  149. package/dist/index.d.ts.map +1 -0
  150. package/dist/index.js +7 -0
  151. package/dist/index.js.map +1 -0
  152. package/dist/instrument.d.ts +2 -0
  153. package/dist/instrument.d.ts.map +1 -0
  154. package/dist/instrument.js +10 -0
  155. package/dist/instrument.js.map +1 -0
  156. package/dist/log.d.ts +36 -0
  157. package/dist/log.d.ts.map +1 -0
  158. package/dist/log.js +206 -0
  159. package/dist/log.js.map +1 -0
  160. package/dist/login/index.d.ts +42 -0
  161. package/dist/login/index.d.ts.map +1 -0
  162. package/dist/login/index.js +239 -0
  163. package/dist/login/index.js.map +1 -0
  164. package/dist/login/portal.d.ts +19 -0
  165. package/dist/login/portal.d.ts.map +1 -0
  166. package/dist/login/portal.js +1544 -0
  167. package/dist/login/portal.js.map +1 -0
  168. package/dist/login/session.d.ts +26 -0
  169. package/dist/login/session.d.ts.map +1 -0
  170. package/dist/login/session.js +56 -0
  171. package/dist/login/session.js.map +1 -0
  172. package/dist/main.d.ts +3 -0
  173. package/dist/main.d.ts.map +1 -0
  174. package/dist/main.js +366 -0
  175. package/dist/main.js.map +1 -0
  176. package/dist/provisioner.d.ts +83 -0
  177. package/dist/provisioner.d.ts.map +1 -0
  178. package/dist/provisioner.js +500 -0
  179. package/dist/provisioner.js.map +1 -0
  180. package/dist/runtime/conversation-orchestrator.d.ts +40 -0
  181. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  182. package/dist/runtime/conversation-orchestrator.js +183 -0
  183. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  184. package/dist/runtime/index.d.ts +2 -0
  185. package/dist/runtime/index.d.ts.map +1 -0
  186. package/dist/runtime/index.js +2 -0
  187. package/dist/runtime/index.js.map +1 -0
  188. package/dist/runtime/session-runtime.d.ts +26 -0
  189. package/dist/runtime/session-runtime.d.ts.map +1 -0
  190. package/dist/runtime/session-runtime.js +221 -0
  191. package/dist/runtime/session-runtime.js.map +1 -0
  192. package/dist/sandbox/cloudflare.d.ts +15 -0
  193. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  194. package/dist/sandbox/cloudflare.js +138 -0
  195. package/dist/sandbox/cloudflare.js.map +1 -0
  196. package/dist/sandbox/container.d.ts +16 -0
  197. package/dist/sandbox/container.d.ts.map +1 -0
  198. package/dist/sandbox/container.js +138 -0
  199. package/dist/sandbox/container.js.map +1 -0
  200. package/dist/sandbox/errors.d.ts +6 -0
  201. package/dist/sandbox/errors.d.ts.map +1 -0
  202. package/dist/sandbox/errors.js +11 -0
  203. package/dist/sandbox/errors.js.map +1 -0
  204. package/dist/sandbox/firecracker.d.ts +17 -0
  205. package/dist/sandbox/firecracker.d.ts.map +1 -0
  206. package/dist/sandbox/firecracker.js +212 -0
  207. package/dist/sandbox/firecracker.js.map +1 -0
  208. package/dist/sandbox/host.d.ts +11 -0
  209. package/dist/sandbox/host.d.ts.map +1 -0
  210. package/dist/sandbox/host.js +89 -0
  211. package/dist/sandbox/host.js.map +1 -0
  212. package/dist/sandbox/image.d.ts +5 -0
  213. package/dist/sandbox/image.d.ts.map +1 -0
  214. package/dist/sandbox/image.js +30 -0
  215. package/dist/sandbox/image.js.map +1 -0
  216. package/dist/sandbox/index.d.ts +22 -0
  217. package/dist/sandbox/index.d.ts.map +1 -0
  218. package/dist/sandbox/index.js +54 -0
  219. package/dist/sandbox/index.js.map +1 -0
  220. package/dist/sandbox/path-context.d.ts +4 -0
  221. package/dist/sandbox/path-context.d.ts.map +1 -0
  222. package/dist/sandbox/path-context.js +20 -0
  223. package/dist/sandbox/path-context.js.map +1 -0
  224. package/dist/sandbox/types.d.ts +67 -0
  225. package/dist/sandbox/types.d.ts.map +1 -0
  226. package/dist/sandbox/types.js +2 -0
  227. package/dist/sandbox/types.js.map +1 -0
  228. package/dist/sandbox/utils.d.ts +4 -0
  229. package/dist/sandbox/utils.d.ts.map +1 -0
  230. package/dist/sandbox/utils.js +51 -0
  231. package/dist/sandbox/utils.js.map +1 -0
  232. package/dist/sentry.d.ts +50 -0
  233. package/dist/sentry.d.ts.map +1 -0
  234. package/dist/sentry.js +257 -0
  235. package/dist/sentry.js.map +1 -0
  236. package/dist/session-view/command.d.ts +5 -0
  237. package/dist/session-view/command.d.ts.map +1 -0
  238. package/dist/session-view/command.js +7 -0
  239. package/dist/session-view/command.js.map +1 -0
  240. package/dist/session-view/portal.d.ts +16 -0
  241. package/dist/session-view/portal.d.ts.map +1 -0
  242. package/dist/session-view/portal.js +1822 -0
  243. package/dist/session-view/portal.js.map +1 -0
  244. package/dist/session-view/service.d.ts +34 -0
  245. package/dist/session-view/service.d.ts.map +1 -0
  246. package/dist/session-view/service.js +434 -0
  247. package/dist/session-view/service.js.map +1 -0
  248. package/dist/session-view/store.d.ts +18 -0
  249. package/dist/session-view/store.d.ts.map +1 -0
  250. package/dist/session-view/store.js +36 -0
  251. package/dist/session-view/store.js.map +1 -0
  252. package/dist/sessions/metadata.d.ts +15 -0
  253. package/dist/sessions/metadata.d.ts.map +1 -0
  254. package/dist/sessions/metadata.js +11 -0
  255. package/dist/sessions/metadata.js.map +1 -0
  256. package/dist/sessions/policy.d.ts +13 -0
  257. package/dist/sessions/policy.d.ts.map +1 -0
  258. package/dist/sessions/policy.js +23 -0
  259. package/dist/sessions/policy.js.map +1 -0
  260. package/dist/sessions/store.d.ts +103 -0
  261. package/dist/sessions/store.d.ts.map +1 -0
  262. package/dist/sessions/store.js +349 -0
  263. package/dist/sessions/store.js.map +1 -0
  264. package/dist/store.d.ts +58 -0
  265. package/dist/store.d.ts.map +1 -0
  266. package/dist/store.js +152 -0
  267. package/dist/store.js.map +1 -0
  268. package/dist/tool-diagnostics.d.ts +2 -0
  269. package/dist/tool-diagnostics.d.ts.map +1 -0
  270. package/dist/tool-diagnostics.js +7 -0
  271. package/dist/tool-diagnostics.js.map +1 -0
  272. package/dist/tools/bash.d.ts +10 -0
  273. package/dist/tools/bash.d.ts.map +1 -0
  274. package/dist/tools/bash.js +80 -0
  275. package/dist/tools/bash.js.map +1 -0
  276. package/dist/tools/edit.d.ts +11 -0
  277. package/dist/tools/edit.d.ts.map +1 -0
  278. package/dist/tools/edit.js +133 -0
  279. package/dist/tools/edit.js.map +1 -0
  280. package/dist/tools/event.d.ts +62 -0
  281. package/dist/tools/event.d.ts.map +1 -0
  282. package/dist/tools/event.js +138 -0
  283. package/dist/tools/event.js.map +1 -0
  284. package/dist/tools/index.d.ts +14 -0
  285. package/dist/tools/index.d.ts.map +1 -0
  286. package/dist/tools/index.js +23 -0
  287. package/dist/tools/index.js.map +1 -0
  288. package/dist/tools/read.d.ts +11 -0
  289. package/dist/tools/read.d.ts.map +1 -0
  290. package/dist/tools/read.js +136 -0
  291. package/dist/tools/read.js.map +1 -0
  292. package/dist/tools/truncate.d.ts +57 -0
  293. package/dist/tools/truncate.d.ts.map +1 -0
  294. package/dist/tools/truncate.js +184 -0
  295. package/dist/tools/truncate.js.map +1 -0
  296. package/dist/tools/write.d.ts +10 -0
  297. package/dist/tools/write.d.ts.map +1 -0
  298. package/dist/tools/write.js +33 -0
  299. package/dist/tools/write.js.map +1 -0
  300. package/dist/trigger.d.ts +31 -0
  301. package/dist/trigger.d.ts.map +1 -0
  302. package/dist/trigger.js +98 -0
  303. package/dist/trigger.js.map +1 -0
  304. package/dist/ui-copy.d.ts +12 -0
  305. package/dist/ui-copy.d.ts.map +1 -0
  306. package/dist/ui-copy.js +36 -0
  307. package/dist/ui-copy.js.map +1 -0
  308. package/dist/vault-routing.d.ts +4 -0
  309. package/dist/vault-routing.d.ts.map +1 -0
  310. package/dist/vault-routing.js +16 -0
  311. package/dist/vault-routing.js.map +1 -0
  312. package/dist/vault.d.ts +72 -0
  313. package/dist/vault.d.ts.map +1 -0
  314. package/dist/vault.js +281 -0
  315. package/dist/vault.js.map +1 -0
  316. package/package.json +83 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAS;IAGzB,OAAO,CAAC,cAAc,CAA6B;IAEnD,YAAY,MAAM,EAAE,kBAAkB,EAMrC;IAED;;OAEG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIvC;IAED;;OAEG;IACH,qBAAqB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAMrE;IAED;;;OAGG;IACG,kBAAkB,CACtB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EACpF,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,EAAE,CAAC,CA+BvB;IAED;;;OAGG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CA8B5E;IAED;;OAEG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;IAED;;;OAGG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsBjD;YAKa,kBAAkB;CAoBjC","sourcesContent":["import { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport { ensureDirExists, isRecord, parseJsonValue, readTextFileIfExists } from \"./file-guards.js\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n original: string; // original filename from uploader\n localPath: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n date: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n ts: string; // slack timestamp or epoch ms\n user: string; // user ID (or \"bot\" for bot responses)\n userName?: string; // handle (e.g., \"mario\")\n displayName?: string; // display name (e.g., \"Mario Zechner\")\n text: string;\n attachments: Attachment[];\n isBot: boolean;\n threadTs?: string; // slack thread timestamp (root message ts)\n}\n\nexport interface ChannelStoreConfig {\n workingDir: string;\n botToken: string; // needed for authenticated file downloads\n}\n\nexport class ChannelStore {\n private workingDir: string;\n private botToken: string;\n // Track recently logged message timestamps to prevent duplicates\n // Key: \"channelId:ts\", automatically cleaned up after 60 seconds\n private recentlyLogged = new Map<string, number>();\n\n constructor(config: ChannelStoreConfig) {\n this.workingDir = config.workingDir;\n this.botToken = config.botToken;\n\n // Ensure working directory exists\n ensureDirExists(this.workingDir);\n }\n\n /**\n * Get or create the directory for a channel/DM\n */\n getChannelDir(channelId: string): string {\n const channelDir = join(this.workingDir, channelId);\n ensureDirExists(channelDir);\n return channelDir;\n }\n\n /**\n * Generate a unique local filename for an attachment\n */\n generateLocalFilename(originalName: string, timestamp: string): string {\n // Convert slack timestamp (1234567890.123456) to milliseconds\n const ts = Math.floor(parseFloat(timestamp) * 1000);\n // Sanitize original name (remove problematic characters)\n const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n return `${ts}_${sanitized}`;\n }\n\n /**\n * Process attachments from a Slack message event.\n * Downloads files before returning so callers only receive readable paths.\n */\n async processAttachments(\n channelId: string,\n files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n timestamp: string,\n ): Promise<Attachment[]> {\n const downloads: Array<Promise<Attachment | null>> = [];\n\n for (const file of files) {\n const url = file.url_private_download || file.url_private;\n if (!url) continue;\n if (!file.name) {\n log.logWarning(\"Attachment missing name, skipping\", url);\n continue;\n }\n\n const filename = this.generateLocalFilename(file.name, timestamp);\n const localPath = `${channelId}/attachments/${filename}`;\n const attachment: Attachment = {\n original: file.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(localPath, url)\n .then(() => attachment)\n .catch((error) => {\n const errorMsg = error instanceof Error ? error.message : String(error);\n log.logWarning(`Failed to download attachment`, `${localPath}: ${errorMsg}`);\n return null;\n }),\n );\n }\n\n const attachments = await Promise.all(downloads);\n return attachments.filter((attachment): attachment is Attachment => attachment !== null);\n }\n\n /**\n * Log a message to the channel's log.jsonl\n * Returns false if message was already logged (duplicate)\n */\n async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {\n // Check for duplicate (same channel + timestamp)\n const dedupeKey = `${channelId}:${message.ts}`;\n if (this.recentlyLogged.has(dedupeKey)) {\n return false; // Already logged\n }\n\n // Mark as logged and schedule cleanup after 60 seconds\n this.recentlyLogged.set(dedupeKey, Date.now());\n setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);\n\n const logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n // Ensure message has a date field\n if (!message.date) {\n // Parse timestamp to get date\n let date: Date;\n if (message.ts.includes(\".\")) {\n // Slack timestamp format (1234567890.123456)\n date = new Date(parseFloat(message.ts) * 1000);\n } else {\n // Epoch milliseconds\n date = new Date(parseInt(message.ts, 10));\n }\n message.date = date.toISOString();\n }\n\n const line = `${JSON.stringify(message)}\\n`;\n await appendFile(logPath, line, \"utf-8\");\n return true;\n }\n\n /**\n * Log a bot response\n */\n async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n await this.logMessage(channelId, {\n date: new Date().toISOString(),\n ts,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n /**\n * Get the timestamp of the last logged message for a channel\n * Returns null if no log exists\n */\n getLastTimestamp(channelId: string): string | null {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const content = readTextFileIfExists(logPath);\n if (content === undefined) {\n return null;\n }\n\n try {\n const lines = content.trim().split(\"\\n\");\n if (lines.length === 0 || lines[0] === \"\") {\n return null;\n }\n const lastLine = lines[lines.length - 1];\n const message = parseJsonValue(\n lastLine,\n (value): value is LoggedMessage => isRecord(value) && typeof value.ts === \"string\",\n (detail) => (detail === \"unexpected JSON shape\" ? \"log entry missing timestamp\" : detail),\n );\n return message.ts;\n } catch {\n return null;\n }\n }\n\n /**\n * Download a single attachment\n */\n private async downloadAttachment(localPath: string, url: string): Promise<void> {\n const filePath = join(this.workingDir, localPath);\n\n // Ensure directory exists\n const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n ensureDirExists(parentDir);\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${this.botToken}`,\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n await writeFile(filePath, Buffer.from(buffer));\n }\n}\n"]}
package/dist/store.js ADDED
@@ -0,0 +1,152 @@
1
+ import { appendFile, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import { ensureDirExists, isRecord, parseJsonValue, readTextFileIfExists } from "./file-guards.js";
4
+ import * as log from "./log.js";
5
+ export class ChannelStore {
6
+ constructor(config) {
7
+ // Track recently logged message timestamps to prevent duplicates
8
+ // Key: "channelId:ts", automatically cleaned up after 60 seconds
9
+ this.recentlyLogged = new Map();
10
+ this.workingDir = config.workingDir;
11
+ this.botToken = config.botToken;
12
+ // Ensure working directory exists
13
+ ensureDirExists(this.workingDir);
14
+ }
15
+ /**
16
+ * Get or create the directory for a channel/DM
17
+ */
18
+ getChannelDir(channelId) {
19
+ const channelDir = join(this.workingDir, channelId);
20
+ ensureDirExists(channelDir);
21
+ return channelDir;
22
+ }
23
+ /**
24
+ * Generate a unique local filename for an attachment
25
+ */
26
+ generateLocalFilename(originalName, timestamp) {
27
+ // Convert slack timestamp (1234567890.123456) to milliseconds
28
+ const ts = Math.floor(parseFloat(timestamp) * 1000);
29
+ // Sanitize original name (remove problematic characters)
30
+ const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, "_");
31
+ return `${ts}_${sanitized}`;
32
+ }
33
+ /**
34
+ * Process attachments from a Slack message event.
35
+ * Downloads files before returning so callers only receive readable paths.
36
+ */
37
+ async processAttachments(channelId, files, timestamp) {
38
+ const downloads = [];
39
+ for (const file of files) {
40
+ const url = file.url_private_download || file.url_private;
41
+ if (!url)
42
+ continue;
43
+ if (!file.name) {
44
+ log.logWarning("Attachment missing name, skipping", url);
45
+ continue;
46
+ }
47
+ const filename = this.generateLocalFilename(file.name, timestamp);
48
+ const localPath = `${channelId}/attachments/${filename}`;
49
+ const attachment = {
50
+ original: file.name,
51
+ localPath,
52
+ };
53
+ downloads.push(this.downloadAttachment(localPath, url)
54
+ .then(() => attachment)
55
+ .catch((error) => {
56
+ const errorMsg = error instanceof Error ? error.message : String(error);
57
+ log.logWarning(`Failed to download attachment`, `${localPath}: ${errorMsg}`);
58
+ return null;
59
+ }));
60
+ }
61
+ const attachments = await Promise.all(downloads);
62
+ return attachments.filter((attachment) => attachment !== null);
63
+ }
64
+ /**
65
+ * Log a message to the channel's log.jsonl
66
+ * Returns false if message was already logged (duplicate)
67
+ */
68
+ async logMessage(channelId, message) {
69
+ // Check for duplicate (same channel + timestamp)
70
+ const dedupeKey = `${channelId}:${message.ts}`;
71
+ if (this.recentlyLogged.has(dedupeKey)) {
72
+ return false; // Already logged
73
+ }
74
+ // Mark as logged and schedule cleanup after 60 seconds
75
+ this.recentlyLogged.set(dedupeKey, Date.now());
76
+ setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);
77
+ const logPath = join(this.getChannelDir(channelId), "log.jsonl");
78
+ // Ensure message has a date field
79
+ if (!message.date) {
80
+ // Parse timestamp to get date
81
+ let date;
82
+ if (message.ts.includes(".")) {
83
+ // Slack timestamp format (1234567890.123456)
84
+ date = new Date(parseFloat(message.ts) * 1000);
85
+ }
86
+ else {
87
+ // Epoch milliseconds
88
+ date = new Date(parseInt(message.ts, 10));
89
+ }
90
+ message.date = date.toISOString();
91
+ }
92
+ const line = `${JSON.stringify(message)}\n`;
93
+ await appendFile(logPath, line, "utf-8");
94
+ return true;
95
+ }
96
+ /**
97
+ * Log a bot response
98
+ */
99
+ async logBotResponse(channelId, text, ts) {
100
+ await this.logMessage(channelId, {
101
+ date: new Date().toISOString(),
102
+ ts,
103
+ user: "bot",
104
+ text,
105
+ attachments: [],
106
+ isBot: true,
107
+ });
108
+ }
109
+ /**
110
+ * Get the timestamp of the last logged message for a channel
111
+ * Returns null if no log exists
112
+ */
113
+ getLastTimestamp(channelId) {
114
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
115
+ const content = readTextFileIfExists(logPath);
116
+ if (content === undefined) {
117
+ return null;
118
+ }
119
+ try {
120
+ const lines = content.trim().split("\n");
121
+ if (lines.length === 0 || lines[0] === "") {
122
+ return null;
123
+ }
124
+ const lastLine = lines[lines.length - 1];
125
+ const message = parseJsonValue(lastLine, (value) => isRecord(value) && typeof value.ts === "string", (detail) => (detail === "unexpected JSON shape" ? "log entry missing timestamp" : detail));
126
+ return message.ts;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ /**
133
+ * Download a single attachment
134
+ */
135
+ async downloadAttachment(localPath, url) {
136
+ const filePath = join(this.workingDir, localPath);
137
+ // Ensure directory exists
138
+ const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf("/")));
139
+ ensureDirExists(parentDir);
140
+ const response = await fetch(url, {
141
+ headers: {
142
+ Authorization: `Bearer ${this.botToken}`,
143
+ },
144
+ });
145
+ if (!response.ok) {
146
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
147
+ }
148
+ const buffer = await response.arrayBuffer();
149
+ await writeFile(filePath, Buffer.from(buffer));
150
+ }
151
+ }
152
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport { ensureDirExists, isRecord, parseJsonValue, readTextFileIfExists } from \"./file-guards.js\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n original: string; // original filename from uploader\n localPath: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n date: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n ts: string; // slack timestamp or epoch ms\n user: string; // user ID (or \"bot\" for bot responses)\n userName?: string; // handle (e.g., \"mario\")\n displayName?: string; // display name (e.g., \"Mario Zechner\")\n text: string;\n attachments: Attachment[];\n isBot: boolean;\n threadTs?: string; // slack thread timestamp (root message ts)\n}\n\nexport interface ChannelStoreConfig {\n workingDir: string;\n botToken: string; // needed for authenticated file downloads\n}\n\nexport class ChannelStore {\n private workingDir: string;\n private botToken: string;\n // Track recently logged message timestamps to prevent duplicates\n // Key: \"channelId:ts\", automatically cleaned up after 60 seconds\n private recentlyLogged = new Map<string, number>();\n\n constructor(config: ChannelStoreConfig) {\n this.workingDir = config.workingDir;\n this.botToken = config.botToken;\n\n // Ensure working directory exists\n ensureDirExists(this.workingDir);\n }\n\n /**\n * Get or create the directory for a channel/DM\n */\n getChannelDir(channelId: string): string {\n const channelDir = join(this.workingDir, channelId);\n ensureDirExists(channelDir);\n return channelDir;\n }\n\n /**\n * Generate a unique local filename for an attachment\n */\n generateLocalFilename(originalName: string, timestamp: string): string {\n // Convert slack timestamp (1234567890.123456) to milliseconds\n const ts = Math.floor(parseFloat(timestamp) * 1000);\n // Sanitize original name (remove problematic characters)\n const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n return `${ts}_${sanitized}`;\n }\n\n /**\n * Process attachments from a Slack message event.\n * Downloads files before returning so callers only receive readable paths.\n */\n async processAttachments(\n channelId: string,\n files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n timestamp: string,\n ): Promise<Attachment[]> {\n const downloads: Array<Promise<Attachment | null>> = [];\n\n for (const file of files) {\n const url = file.url_private_download || file.url_private;\n if (!url) continue;\n if (!file.name) {\n log.logWarning(\"Attachment missing name, skipping\", url);\n continue;\n }\n\n const filename = this.generateLocalFilename(file.name, timestamp);\n const localPath = `${channelId}/attachments/${filename}`;\n const attachment: Attachment = {\n original: file.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(localPath, url)\n .then(() => attachment)\n .catch((error) => {\n const errorMsg = error instanceof Error ? error.message : String(error);\n log.logWarning(`Failed to download attachment`, `${localPath}: ${errorMsg}`);\n return null;\n }),\n );\n }\n\n const attachments = await Promise.all(downloads);\n return attachments.filter((attachment): attachment is Attachment => attachment !== null);\n }\n\n /**\n * Log a message to the channel's log.jsonl\n * Returns false if message was already logged (duplicate)\n */\n async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {\n // Check for duplicate (same channel + timestamp)\n const dedupeKey = `${channelId}:${message.ts}`;\n if (this.recentlyLogged.has(dedupeKey)) {\n return false; // Already logged\n }\n\n // Mark as logged and schedule cleanup after 60 seconds\n this.recentlyLogged.set(dedupeKey, Date.now());\n setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);\n\n const logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n // Ensure message has a date field\n if (!message.date) {\n // Parse timestamp to get date\n let date: Date;\n if (message.ts.includes(\".\")) {\n // Slack timestamp format (1234567890.123456)\n date = new Date(parseFloat(message.ts) * 1000);\n } else {\n // Epoch milliseconds\n date = new Date(parseInt(message.ts, 10));\n }\n message.date = date.toISOString();\n }\n\n const line = `${JSON.stringify(message)}\\n`;\n await appendFile(logPath, line, \"utf-8\");\n return true;\n }\n\n /**\n * Log a bot response\n */\n async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n await this.logMessage(channelId, {\n date: new Date().toISOString(),\n ts,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n /**\n * Get the timestamp of the last logged message for a channel\n * Returns null if no log exists\n */\n getLastTimestamp(channelId: string): string | null {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const content = readTextFileIfExists(logPath);\n if (content === undefined) {\n return null;\n }\n\n try {\n const lines = content.trim().split(\"\\n\");\n if (lines.length === 0 || lines[0] === \"\") {\n return null;\n }\n const lastLine = lines[lines.length - 1];\n const message = parseJsonValue(\n lastLine,\n (value): value is LoggedMessage => isRecord(value) && typeof value.ts === \"string\",\n (detail) => (detail === \"unexpected JSON shape\" ? \"log entry missing timestamp\" : detail),\n );\n return message.ts;\n } catch {\n return null;\n }\n }\n\n /**\n * Download a single attachment\n */\n private async downloadAttachment(localPath: string, url: string): Promise<void> {\n const filePath = join(this.workingDir, localPath);\n\n // Ensure directory exists\n const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n ensureDirExists(parentDir);\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${this.botToken}`,\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n await writeFile(filePath, Buffer.from(buffer));\n }\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export declare function shouldSurfaceToolDiagnostic(toolName: string): boolean;
2
+ //# sourceMappingURL=tool-diagnostics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-diagnostics.d.ts","sourceRoot":"","sources":["../src/tool-diagnostics.ts"],"names":[],"mappings":"AAIA,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAErE","sourcesContent":["// Central policy for what tool diagnostics are posted back to chat surfaces.\n// Detailed tool calls/results still remain in the structured session history and session view.\nconst QUIET_TOOL_DIAGNOSTICS = new Set([\"bash\", \"read\", \"write\", \"edit\"]);\n\nexport function shouldSurfaceToolDiagnostic(toolName: string): boolean {\n return !QUIET_TOOL_DIAGNOSTICS.has(toolName);\n}\n"]}
@@ -0,0 +1,7 @@
1
+ // Central policy for what tool diagnostics are posted back to chat surfaces.
2
+ // Detailed tool calls/results still remain in the structured session history and session view.
3
+ const QUIET_TOOL_DIAGNOSTICS = new Set(["bash", "read", "write", "edit"]);
4
+ export function shouldSurfaceToolDiagnostic(toolName) {
5
+ return !QUIET_TOOL_DIAGNOSTICS.has(toolName);
6
+ }
7
+ //# sourceMappingURL=tool-diagnostics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-diagnostics.js","sourceRoot":"","sources":["../src/tool-diagnostics.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+FAA+F;AAC/F,MAAM,sBAAsB,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;AAE1E,MAAM,UAAU,2BAA2B,CAAC,QAAgB;IAC1D,OAAO,CAAC,sBAAsB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;AAC/C,CAAC","sourcesContent":["// Central policy for what tool diagnostics are posted back to chat surfaces.\n// Detailed tool calls/results still remain in the structured session history and session view.\nconst QUIET_TOOL_DIAGNOSTICS = new Set([\"bash\", \"read\", \"write\", \"edit\"]);\n\nexport function shouldSurfaceToolDiagnostic(toolName: string): boolean {\n return !QUIET_TOOL_DIAGNOSTICS.has(toolName);\n}\n"]}
@@ -0,0 +1,10 @@
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ import type { Executor } from "../sandbox/index.js";
3
+ declare const bashSchema: import("@sinclair/typebox").TObject<{
4
+ label: import("@sinclair/typebox").TString;
5
+ command: import("@sinclair/typebox").TString;
6
+ timeout: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
7
+ }>;
8
+ export declare function createBashTool(executor: Executor): AgentTool<typeof bashSchema>;
9
+ export {};
10
+ //# sourceMappingURL=bash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bash.d.ts","sourceRoot":"","sources":["../../src/tools/bash.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAE/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAiBpD,QAAA,MAAM,UAAU;;;;EAQd,CAAC;AAOH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAsE/E","sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { createWriteStream } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AgentTool } from \"@earendil-works/pi-agent-core\";\nimport { Type } from \"@sinclair/typebox\";\nimport type { Executor } from \"../sandbox/index.js\";\nimport {\n DEFAULT_MAX_BYTES,\n DEFAULT_MAX_LINES,\n formatSize,\n type TruncationResult,\n truncateTail,\n} from \"./truncate.js\";\n\n/**\n * Generate a unique temp file path for bash output\n */\nfunction getTempFilePath(): string {\n const id = randomBytes(8).toString(\"hex\");\n return join(tmpdir(), `mikan-bash-${id}.log`);\n}\n\nconst bashSchema = Type.Object({\n label: Type.String({\n description: \"Brief description of what this command does (shown to user)\",\n }),\n command: Type.String({ description: \"Bash command to execute\" }),\n timeout: Type.Optional(\n Type.Number({ description: \"Timeout in seconds (optional, no default timeout)\" }),\n ),\n});\n\ninterface BashToolDetails {\n truncation?: TruncationResult;\n fullOutputPath?: string;\n}\n\nexport function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {\n return {\n name: \"bash\",\n label: \"bash\",\n description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,\n parameters: bashSchema,\n execute: async (\n _toolCallId: string,\n { command, timeout }: { label: string; command: string; timeout?: number },\n signal?: AbortSignal,\n ) => {\n // Track output for potential temp file writing\n let tempFilePath: string | undefined;\n let tempFileStream: ReturnType<typeof createWriteStream> | undefined;\n\n const result = await executor.exec(command, { timeout, signal });\n let output = \"\";\n if (result.stdout) output += result.stdout;\n if (result.stderr) {\n if (output) output += \"\\n\";\n output += result.stderr;\n }\n\n const totalBytes = Buffer.byteLength(output, \"utf-8\");\n\n // Write to temp file if output exceeds limit\n if (totalBytes > DEFAULT_MAX_BYTES) {\n tempFilePath = getTempFilePath();\n tempFileStream = createWriteStream(tempFilePath);\n tempFileStream.write(output);\n tempFileStream.end();\n }\n\n // Apply tail truncation\n const truncation = truncateTail(output);\n let outputText = truncation.content || \"(no output)\";\n\n // Build details with truncation info\n let details: BashToolDetails | undefined;\n\n if (truncation.truncated) {\n details = {\n truncation,\n fullOutputPath: tempFilePath,\n };\n\n // Build actionable notice\n const startLine = truncation.totalLines - truncation.outputLines + 1;\n const endLine = truncation.totalLines;\n\n if (truncation.lastLinePartial) {\n // Edge case: last line alone > 50KB\n const lastLineSize = formatSize(\n Buffer.byteLength(output.split(\"\\n\").pop() || \"\", \"utf-8\"),\n );\n outputText += `\\n\\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;\n } else if (truncation.truncatedBy === \"lines\") {\n outputText += `\\n\\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;\n } else {\n outputText += `\\n\\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;\n }\n }\n\n if (result.code !== 0) {\n throw new Error(`${outputText}\\n\\nCommand exited with code ${result.code}`.trim());\n }\n\n return { content: [{ type: \"text\", text: outputText }], details };\n },\n };\n}\n"]}
@@ -0,0 +1,80 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Type } from "@sinclair/typebox";
6
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateTail, } from "./truncate.js";
7
+ /**
8
+ * Generate a unique temp file path for bash output
9
+ */
10
+ function getTempFilePath() {
11
+ const id = randomBytes(8).toString("hex");
12
+ return join(tmpdir(), `mikan-bash-${id}.log`);
13
+ }
14
+ const bashSchema = Type.Object({
15
+ label: Type.String({
16
+ description: "Brief description of what this command does (shown to user)",
17
+ }),
18
+ command: Type.String({ description: "Bash command to execute" }),
19
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
20
+ });
21
+ export function createBashTool(executor) {
22
+ return {
23
+ name: "bash",
24
+ label: "bash",
25
+ description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
26
+ parameters: bashSchema,
27
+ execute: async (_toolCallId, { command, timeout }, signal) => {
28
+ // Track output for potential temp file writing
29
+ let tempFilePath;
30
+ let tempFileStream;
31
+ const result = await executor.exec(command, { timeout, signal });
32
+ let output = "";
33
+ if (result.stdout)
34
+ output += result.stdout;
35
+ if (result.stderr) {
36
+ if (output)
37
+ output += "\n";
38
+ output += result.stderr;
39
+ }
40
+ const totalBytes = Buffer.byteLength(output, "utf-8");
41
+ // Write to temp file if output exceeds limit
42
+ if (totalBytes > DEFAULT_MAX_BYTES) {
43
+ tempFilePath = getTempFilePath();
44
+ tempFileStream = createWriteStream(tempFilePath);
45
+ tempFileStream.write(output);
46
+ tempFileStream.end();
47
+ }
48
+ // Apply tail truncation
49
+ const truncation = truncateTail(output);
50
+ let outputText = truncation.content || "(no output)";
51
+ // Build details with truncation info
52
+ let details;
53
+ if (truncation.truncated) {
54
+ details = {
55
+ truncation,
56
+ fullOutputPath: tempFilePath,
57
+ };
58
+ // Build actionable notice
59
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
60
+ const endLine = truncation.totalLines;
61
+ if (truncation.lastLinePartial) {
62
+ // Edge case: last line alone > 50KB
63
+ const lastLineSize = formatSize(Buffer.byteLength(output.split("\n").pop() || "", "utf-8"));
64
+ outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
65
+ }
66
+ else if (truncation.truncatedBy === "lines") {
67
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
68
+ }
69
+ else {
70
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
71
+ }
72
+ }
73
+ if (result.code !== 0) {
74
+ throw new Error(`${outputText}\n\nCommand exited with code ${result.code}`.trim());
75
+ }
76
+ return { content: [{ type: "text", text: outputText }], details };
77
+ },
78
+ };
79
+ }
80
+ //# sourceMappingURL=bash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bash.js","sourceRoot":"","sources":["../../src/tools/bash.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { createWriteStream } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AgentTool } from \"@earendil-works/pi-agent-core\";\nimport { Type } from \"@sinclair/typebox\";\nimport type { Executor } from \"../sandbox/index.js\";\nimport {\n DEFAULT_MAX_BYTES,\n DEFAULT_MAX_LINES,\n formatSize,\n type TruncationResult,\n truncateTail,\n} from \"./truncate.js\";\n\n/**\n * Generate a unique temp file path for bash output\n */\nfunction getTempFilePath(): string {\n const id = randomBytes(8).toString(\"hex\");\n return join(tmpdir(), `mikan-bash-${id}.log`);\n}\n\nconst bashSchema = Type.Object({\n label: Type.String({\n description: \"Brief description of what this command does (shown to user)\",\n }),\n command: Type.String({ description: \"Bash command to execute\" }),\n timeout: Type.Optional(\n Type.Number({ description: \"Timeout in seconds (optional, no default timeout)\" }),\n ),\n});\n\ninterface BashToolDetails {\n truncation?: TruncationResult;\n fullOutputPath?: string;\n}\n\nexport function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {\n return {\n name: \"bash\",\n label: \"bash\",\n description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,\n parameters: bashSchema,\n execute: async (\n _toolCallId: string,\n { command, timeout }: { label: string; command: string; timeout?: number },\n signal?: AbortSignal,\n ) => {\n // Track output for potential temp file writing\n let tempFilePath: string | undefined;\n let tempFileStream: ReturnType<typeof createWriteStream> | undefined;\n\n const result = await executor.exec(command, { timeout, signal });\n let output = \"\";\n if (result.stdout) output += result.stdout;\n if (result.stderr) {\n if (output) output += \"\\n\";\n output += result.stderr;\n }\n\n const totalBytes = Buffer.byteLength(output, \"utf-8\");\n\n // Write to temp file if output exceeds limit\n if (totalBytes > DEFAULT_MAX_BYTES) {\n tempFilePath = getTempFilePath();\n tempFileStream = createWriteStream(tempFilePath);\n tempFileStream.write(output);\n tempFileStream.end();\n }\n\n // Apply tail truncation\n const truncation = truncateTail(output);\n let outputText = truncation.content || \"(no output)\";\n\n // Build details with truncation info\n let details: BashToolDetails | undefined;\n\n if (truncation.truncated) {\n details = {\n truncation,\n fullOutputPath: tempFilePath,\n };\n\n // Build actionable notice\n const startLine = truncation.totalLines - truncation.outputLines + 1;\n const endLine = truncation.totalLines;\n\n if (truncation.lastLinePartial) {\n // Edge case: last line alone > 50KB\n const lastLineSize = formatSize(\n Buffer.byteLength(output.split(\"\\n\").pop() || \"\", \"utf-8\"),\n );\n outputText += `\\n\\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;\n } else if (truncation.truncatedBy === \"lines\") {\n outputText += `\\n\\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;\n } else {\n outputText += `\\n\\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;\n }\n }\n\n if (result.code !== 0) {\n throw new Error(`${outputText}\\n\\nCommand exited with code ${result.code}`.trim());\n }\n\n return { content: [{ type: \"text\", text: outputText }], details };\n },\n };\n}\n"]}
@@ -0,0 +1,11 @@
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ import type { Executor } from "../sandbox/index.js";
3
+ declare const editSchema: import("@sinclair/typebox").TObject<{
4
+ label: import("@sinclair/typebox").TString;
5
+ path: import("@sinclair/typebox").TString;
6
+ oldText: import("@sinclair/typebox").TString;
7
+ newText: import("@sinclair/typebox").TString;
8
+ }>;
9
+ export declare function createEditTool(executor: Executor): AgentTool<typeof editSchema>;
10
+ export {};
11
+ //# sourceMappingURL=edit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../src/tools/edit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAG/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAqFpD,QAAA,MAAM,UAAU;;;;;EAOd,CAAC;AAEH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAqE/E","sourcesContent":["import type { AgentTool } from \"@earendil-works/pi-agent-core\";\nimport { Type } from \"@sinclair/typebox\";\nimport * as Diff from \"diff\";\nimport type { Executor } from \"../sandbox/index.js\";\n\n/**\n * Generate a unified diff string with line numbers and context\n */\nfunction generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {\n const parts = Diff.diffLines(oldContent, newContent);\n const output: string[] = [];\n\n const oldLines = oldContent.split(\"\\n\");\n const newLines = newContent.split(\"\\n\");\n const maxLineNum = Math.max(oldLines.length, newLines.length);\n const lineNumWidth = String(maxLineNum).length;\n\n let oldLineNum = 1;\n let newLineNum = 1;\n let lastWasChange = false;\n\n for (let i = 0; i < parts.length; i++) {\n const part = parts[i];\n const raw = part.value.split(\"\\n\");\n if (raw[raw.length - 1] === \"\") {\n raw.pop();\n }\n\n if (part.added || part.removed) {\n for (const line of raw) {\n if (part.added) {\n const lineNum = String(newLineNum).padStart(lineNumWidth, \" \");\n output.push(`+${lineNum} ${line}`);\n newLineNum++;\n } else {\n const lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n output.push(`-${lineNum} ${line}`);\n oldLineNum++;\n }\n }\n lastWasChange = true;\n } else {\n const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);\n\n if (lastWasChange || nextPartIsChange) {\n let linesToShow = raw;\n let skipStart = 0;\n let skipEnd = 0;\n\n if (!lastWasChange) {\n skipStart = Math.max(0, raw.length - contextLines);\n linesToShow = raw.slice(skipStart);\n }\n\n if (!nextPartIsChange && linesToShow.length > contextLines) {\n skipEnd = linesToShow.length - contextLines;\n linesToShow = linesToShow.slice(0, contextLines);\n }\n\n if (skipStart > 0) {\n output.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n }\n\n for (const line of linesToShow) {\n const lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n output.push(` ${lineNum} ${line}`);\n oldLineNum++;\n newLineNum++;\n }\n\n if (skipEnd > 0) {\n output.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n }\n\n oldLineNum += skipStart + skipEnd;\n newLineNum += skipStart + skipEnd;\n } else {\n oldLineNum += raw.length;\n newLineNum += raw.length;\n }\n\n lastWasChange = false;\n }\n }\n\n return output.join(\"\\n\");\n}\n\nconst editSchema = Type.Object({\n label: Type.String({\n description: \"Brief description of the edit you're making (shown to user)\",\n }),\n path: Type.String({ description: \"Path to the file to edit (relative or absolute)\" }),\n oldText: Type.String({ description: \"Exact text to find and replace (must match exactly)\" }),\n newText: Type.String({ description: \"New text to replace the old text with\" }),\n});\n\nexport function createEditTool(executor: Executor): AgentTool<typeof editSchema> {\n return {\n name: \"edit\",\n label: \"edit\",\n description:\n \"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.\",\n parameters: editSchema,\n execute: async (\n _toolCallId: string,\n { path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },\n signal?: AbortSignal,\n ) => {\n // Read the file\n const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });\n if (readResult.code !== 0) {\n throw new Error(readResult.stderr || `File not found: ${path}`);\n }\n\n const content = readResult.stdout;\n\n // Check if old text exists\n if (!content.includes(oldText)) {\n throw new Error(\n `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,\n );\n }\n\n // Count occurrences\n const occurrences = content.split(oldText).length - 1;\n\n if (occurrences > 1) {\n throw new Error(\n `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,\n );\n }\n\n // Perform replacement\n const index = content.indexOf(oldText);\n const newContent =\n content.substring(0, index) + newText + content.substring(index + oldText.length);\n\n if (content === newContent) {\n throw new Error(\n `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,\n );\n }\n\n // Write the file back\n const writeResult = await executor.exec(\n `printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`,\n {\n signal,\n },\n );\n if (writeResult.code !== 0) {\n throw new Error(writeResult.stderr || `Failed to write file: ${path}`);\n }\n\n return {\n content: [\n {\n type: \"text\",\n text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,\n },\n ],\n details: { diff: generateDiffString(content, newContent) },\n };\n },\n };\n}\n\nfunction shellEscape(s: string): string {\n return `'${s.replace(/'/g, \"'\\\\''\")}'`;\n}\n"]}
@@ -0,0 +1,133 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import * as Diff from "diff";
3
+ /**
4
+ * Generate a unified diff string with line numbers and context
5
+ */
6
+ function generateDiffString(oldContent, newContent, contextLines = 4) {
7
+ const parts = Diff.diffLines(oldContent, newContent);
8
+ const output = [];
9
+ const oldLines = oldContent.split("\n");
10
+ const newLines = newContent.split("\n");
11
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
12
+ const lineNumWidth = String(maxLineNum).length;
13
+ let oldLineNum = 1;
14
+ let newLineNum = 1;
15
+ let lastWasChange = false;
16
+ for (let i = 0; i < parts.length; i++) {
17
+ const part = parts[i];
18
+ const raw = part.value.split("\n");
19
+ if (raw[raw.length - 1] === "") {
20
+ raw.pop();
21
+ }
22
+ if (part.added || part.removed) {
23
+ for (const line of raw) {
24
+ if (part.added) {
25
+ const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
26
+ output.push(`+${lineNum} ${line}`);
27
+ newLineNum++;
28
+ }
29
+ else {
30
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
31
+ output.push(`-${lineNum} ${line}`);
32
+ oldLineNum++;
33
+ }
34
+ }
35
+ lastWasChange = true;
36
+ }
37
+ else {
38
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
39
+ if (lastWasChange || nextPartIsChange) {
40
+ let linesToShow = raw;
41
+ let skipStart = 0;
42
+ let skipEnd = 0;
43
+ if (!lastWasChange) {
44
+ skipStart = Math.max(0, raw.length - contextLines);
45
+ linesToShow = raw.slice(skipStart);
46
+ }
47
+ if (!nextPartIsChange && linesToShow.length > contextLines) {
48
+ skipEnd = linesToShow.length - contextLines;
49
+ linesToShow = linesToShow.slice(0, contextLines);
50
+ }
51
+ if (skipStart > 0) {
52
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
53
+ }
54
+ for (const line of linesToShow) {
55
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
56
+ output.push(` ${lineNum} ${line}`);
57
+ oldLineNum++;
58
+ newLineNum++;
59
+ }
60
+ if (skipEnd > 0) {
61
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
62
+ }
63
+ oldLineNum += skipStart + skipEnd;
64
+ newLineNum += skipStart + skipEnd;
65
+ }
66
+ else {
67
+ oldLineNum += raw.length;
68
+ newLineNum += raw.length;
69
+ }
70
+ lastWasChange = false;
71
+ }
72
+ }
73
+ return output.join("\n");
74
+ }
75
+ const editSchema = Type.Object({
76
+ label: Type.String({
77
+ description: "Brief description of the edit you're making (shown to user)",
78
+ }),
79
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
80
+ oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
81
+ newText: Type.String({ description: "New text to replace the old text with" }),
82
+ });
83
+ export function createEditTool(executor) {
84
+ return {
85
+ name: "edit",
86
+ label: "edit",
87
+ description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
88
+ parameters: editSchema,
89
+ execute: async (_toolCallId, { path, oldText, newText }, signal) => {
90
+ // Read the file
91
+ const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
92
+ if (readResult.code !== 0) {
93
+ throw new Error(readResult.stderr || `File not found: ${path}`);
94
+ }
95
+ const content = readResult.stdout;
96
+ // Check if old text exists
97
+ if (!content.includes(oldText)) {
98
+ throw new Error(`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`);
99
+ }
100
+ // Count occurrences
101
+ const occurrences = content.split(oldText).length - 1;
102
+ if (occurrences > 1) {
103
+ throw new Error(`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`);
104
+ }
105
+ // Perform replacement
106
+ const index = content.indexOf(oldText);
107
+ const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
108
+ if (content === newContent) {
109
+ throw new Error(`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`);
110
+ }
111
+ // Write the file back
112
+ const writeResult = await executor.exec(`printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`, {
113
+ signal,
114
+ });
115
+ if (writeResult.code !== 0) {
116
+ throw new Error(writeResult.stderr || `Failed to write file: ${path}`);
117
+ }
118
+ return {
119
+ content: [
120
+ {
121
+ type: "text",
122
+ text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
123
+ },
124
+ ],
125
+ details: { diff: generateDiffString(content, newContent) },
126
+ };
127
+ },
128
+ };
129
+ }
130
+ function shellEscape(s) {
131
+ return `'${s.replace(/'/g, "'\\''")}'`;
132
+ }
133
+ //# sourceMappingURL=edit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit.js","sourceRoot":"","sources":["../../src/tools/edit.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AgentTool } from \"@earendil-works/pi-agent-core\";\nimport { Type } from \"@sinclair/typebox\";\nimport * as Diff from \"diff\";\nimport type { Executor } from \"../sandbox/index.js\";\n\n/**\n * Generate a unified diff string with line numbers and context\n */\nfunction generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {\n const parts = Diff.diffLines(oldContent, newContent);\n const output: string[] = [];\n\n const oldLines = oldContent.split(\"\\n\");\n const newLines = newContent.split(\"\\n\");\n const maxLineNum = Math.max(oldLines.length, newLines.length);\n const lineNumWidth = String(maxLineNum).length;\n\n let oldLineNum = 1;\n let newLineNum = 1;\n let lastWasChange = false;\n\n for (let i = 0; i < parts.length; i++) {\n const part = parts[i];\n const raw = part.value.split(\"\\n\");\n if (raw[raw.length - 1] === \"\") {\n raw.pop();\n }\n\n if (part.added || part.removed) {\n for (const line of raw) {\n if (part.added) {\n const lineNum = String(newLineNum).padStart(lineNumWidth, \" \");\n output.push(`+${lineNum} ${line}`);\n newLineNum++;\n } else {\n const lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n output.push(`-${lineNum} ${line}`);\n oldLineNum++;\n }\n }\n lastWasChange = true;\n } else {\n const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);\n\n if (lastWasChange || nextPartIsChange) {\n let linesToShow = raw;\n let skipStart = 0;\n let skipEnd = 0;\n\n if (!lastWasChange) {\n skipStart = Math.max(0, raw.length - contextLines);\n linesToShow = raw.slice(skipStart);\n }\n\n if (!nextPartIsChange && linesToShow.length > contextLines) {\n skipEnd = linesToShow.length - contextLines;\n linesToShow = linesToShow.slice(0, contextLines);\n }\n\n if (skipStart > 0) {\n output.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n }\n\n for (const line of linesToShow) {\n const lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n output.push(` ${lineNum} ${line}`);\n oldLineNum++;\n newLineNum++;\n }\n\n if (skipEnd > 0) {\n output.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n }\n\n oldLineNum += skipStart + skipEnd;\n newLineNum += skipStart + skipEnd;\n } else {\n oldLineNum += raw.length;\n newLineNum += raw.length;\n }\n\n lastWasChange = false;\n }\n }\n\n return output.join(\"\\n\");\n}\n\nconst editSchema = Type.Object({\n label: Type.String({\n description: \"Brief description of the edit you're making (shown to user)\",\n }),\n path: Type.String({ description: \"Path to the file to edit (relative or absolute)\" }),\n oldText: Type.String({ description: \"Exact text to find and replace (must match exactly)\" }),\n newText: Type.String({ description: \"New text to replace the old text with\" }),\n});\n\nexport function createEditTool(executor: Executor): AgentTool<typeof editSchema> {\n return {\n name: \"edit\",\n label: \"edit\",\n description:\n \"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.\",\n parameters: editSchema,\n execute: async (\n _toolCallId: string,\n { path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },\n signal?: AbortSignal,\n ) => {\n // Read the file\n const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });\n if (readResult.code !== 0) {\n throw new Error(readResult.stderr || `File not found: ${path}`);\n }\n\n const content = readResult.stdout;\n\n // Check if old text exists\n if (!content.includes(oldText)) {\n throw new Error(\n `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,\n );\n }\n\n // Count occurrences\n const occurrences = content.split(oldText).length - 1;\n\n if (occurrences > 1) {\n throw new Error(\n `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,\n );\n }\n\n // Perform replacement\n const index = content.indexOf(oldText);\n const newContent =\n content.substring(0, index) + newText + content.substring(index + oldText.length);\n\n if (content === newContent) {\n throw new Error(\n `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,\n );\n }\n\n // Write the file back\n const writeResult = await executor.exec(\n `printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`,\n {\n signal,\n },\n );\n if (writeResult.code !== 0) {\n throw new Error(writeResult.stderr || `Failed to write file: ${path}`);\n }\n\n return {\n content: [\n {\n type: \"text\",\n text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,\n },\n ],\n details: { diff: generateDiffString(content, newContent) },\n };\n },\n };\n}\n\nfunction shellEscape(s: string): string {\n return `'${s.replace(/'/g, \"'\\\\''\")}'`;\n}\n"]}
@@ -0,0 +1,62 @@
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ declare const eventSchema: import("@sinclair/typebox").TObject<{
3
+ label: import("@sinclair/typebox").TString;
4
+ type: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"immediate">, import("@sinclair/typebox").TLiteral<"one-shot">, import("@sinclair/typebox").TLiteral<"periodic">]>;
5
+ text: import("@sinclair/typebox").TString;
6
+ at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
+ schedule: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
8
+ timezone: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
9
+ filenamePrefix: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
10
+ }>;
11
+ interface EventToolContext {
12
+ platform: string;
13
+ conversationId: string;
14
+ conversationKind: "direct" | "shared";
15
+ userId: string;
16
+ }
17
+ export type EventPayload = {
18
+ type: "immediate";
19
+ platform: string;
20
+ conversationId: string;
21
+ conversationKind: "direct" | "shared";
22
+ userId: string;
23
+ text: string;
24
+ } | {
25
+ type: "one-shot";
26
+ platform: string;
27
+ conversationId: string;
28
+ conversationKind: "direct" | "shared";
29
+ userId: string;
30
+ text: string;
31
+ at: string;
32
+ } | {
33
+ type: "periodic";
34
+ platform: string;
35
+ conversationId: string;
36
+ conversationKind: "direct" | "shared";
37
+ userId: string;
38
+ text: string;
39
+ schedule: string;
40
+ timezone: string;
41
+ };
42
+ export interface EventStore {
43
+ write(filename: string, payload: EventPayload): Promise<{
44
+ path: string;
45
+ size: number;
46
+ }>;
47
+ }
48
+ export declare class HostEventStore implements EventStore {
49
+ private readonly eventsDir;
50
+ constructor(eventsDir: string);
51
+ static fromWorkspaceDir(workspaceDir: string): HostEventStore;
52
+ write(filename: string, payload: EventPayload): Promise<{
53
+ path: string;
54
+ size: number;
55
+ }>;
56
+ }
57
+ export declare function createEventTool(eventStore: EventStore): {
58
+ tool: AgentTool<typeof eventSchema>;
59
+ setEventContext: (context: EventToolContext) => void;
60
+ };
61
+ export {};
62
+ //# sourceMappingURL=event.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event.d.ts","sourceRoot":"","sources":["../../src/tools/event.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAI/D,QAAA,MAAM,WAAW;;;;;;;;EA6Bf,CAAC;AAEH,UAAU,gBAAgB;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACtC,MAAM,EAAE,MAAM,CAAC;CAChB;AAYD,MAAM,MAAM,YAAY,GACpB;IACE,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd,GACD;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ,GACD;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEN,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACzF;AAED,qBAAa,cAAe,YAAW,UAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS;IAAtC,YAA6B,SAAS,EAAE,MAAM,EAAI;IAElD,MAAM,CAAC,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,cAAc,CAE5D;IAEK,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAM5F;CACF;AAED,wBAAgB,eAAe,CAAC,UAAU,EAAE,UAAU,GAAG;IACvD,IAAI,EAAE,SAAS,CAAC,OAAO,WAAW,CAAC,CAAC;IACpC,eAAe,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACtD,CA8DA","sourcesContent":["import { mkdir, stat, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { AgentTool } from \"@earendil-works/pi-agent-core\";\nimport { Type } from \"@sinclair/typebox\";\nimport * as log from \"../log.js\";\n\nconst eventSchema = Type.Object({\n label: Type.String({\n description: \"Brief description of the event you're scheduling (shown to user)\",\n }),\n type: Type.Union([Type.Literal(\"immediate\"), Type.Literal(\"one-shot\"), Type.Literal(\"periodic\")]),\n text: Type.String({\n description:\n \"A self-contained task for the future run. Include the necessary context, tone, and constraints in the text itself because events do not inherit normal conversation history.\",\n }),\n at: Type.Optional(\n Type.String({\n description: \"ISO 8601 timestamp with offset, required for one-shot events\",\n }),\n ),\n schedule: Type.Optional(\n Type.String({\n description: \"Cron schedule, required for periodic events\",\n }),\n ),\n timezone: Type.Optional(\n Type.String({\n description: \"IANA timezone, required for periodic events\",\n }),\n ),\n filenamePrefix: Type.Optional(\n Type.String({\n description: \"Optional filename prefix for the event file\",\n }),\n ),\n});\n\ninterface EventToolContext {\n platform: string;\n conversationId: string;\n conversationKind: \"direct\" | \"shared\";\n userId: string;\n}\n\ntype EventToolParams = {\n label: string;\n type: \"immediate\" | \"one-shot\" | \"periodic\";\n text: string;\n at?: string;\n schedule?: string;\n timezone?: string;\n filenamePrefix?: string;\n};\n\nexport type EventPayload =\n | {\n type: \"immediate\";\n platform: string;\n conversationId: string;\n conversationKind: \"direct\" | \"shared\";\n userId: string;\n text: string;\n }\n | {\n type: \"one-shot\";\n platform: string;\n conversationId: string;\n conversationKind: \"direct\" | \"shared\";\n userId: string;\n text: string;\n at: string;\n }\n | {\n type: \"periodic\";\n platform: string;\n conversationId: string;\n conversationKind: \"direct\" | \"shared\";\n userId: string;\n text: string;\n schedule: string;\n timezone: string;\n };\n\nexport interface EventStore {\n write(filename: string, payload: EventPayload): Promise<{ path: string; size: number }>;\n}\n\nexport class HostEventStore implements EventStore {\n constructor(private readonly eventsDir: string) {}\n\n static fromWorkspaceDir(workspaceDir: string): HostEventStore {\n return new HostEventStore(join(workspaceDir, \"events\"));\n }\n\n async write(filename: string, payload: EventPayload): Promise<{ path: string; size: number }> {\n await mkdir(this.eventsDir, { recursive: true });\n const filePath = join(this.eventsDir, filename);\n await writeFile(filePath, JSON.stringify(payload) + \"\\n\", \"utf-8\");\n const fileStat = await stat(filePath);\n return { path: filePath, size: fileStat.size };\n }\n}\n\nexport function createEventTool(eventStore: EventStore): {\n tool: AgentTool<typeof eventSchema>;\n setEventContext: (context: EventToolContext) => void;\n} {\n let eventContext: EventToolContext | null = null;\n\n const tool: AgentTool<typeof eventSchema> = {\n name: \"event\",\n label: \"event\",\n description:\n \"Schedule an immediate, one-shot, or periodic event for the current conversation. Write text as a self-contained task with any needed context, tone, or constraints because events do not inherit normal conversation history. This automatically writes to the correct events directory and fills the current platform, conversation, conversation kind, and requester userId.\",\n parameters: eventSchema,\n execute: async (_toolCallId: string, params: EventToolParams, signal?: AbortSignal) => {\n if (signal?.aborted) {\n throw new Error(\"Operation aborted\");\n }\n\n if (!eventContext) {\n throw new Error(\"Event context not configured\");\n }\n\n const payload = buildEventPayload(params, eventContext);\n const prefix = sanitizeFileSegment(params.filenamePrefix || payload.type || \"event\");\n const filename = `${prefix}-${Date.now()}.json`;\n\n log.logInfo(\n `Writing event file via control plane store: ${filename} (type=${payload.type}, platform=${payload.platform}, conversation=${payload.conversationId})`,\n );\n\n try {\n const result = await eventStore.write(filename, payload);\n log.logInfo(\n `Wrote event file via control plane store: ${result.path} (${result.size} bytes)`,\n );\n } catch (err) {\n log.logWarning(\n `Failed to write event file via control plane store: ${filename}`,\n String(err),\n );\n throw err;\n }\n\n return {\n content: [\n {\n type: \"text\",\n text:\n payload.type === \"periodic\"\n ? `Scheduled periodic event ${filename} for ${payload.platform}/${payload.conversationId} (${payload.schedule} ${payload.timezone})`\n : payload.type === \"one-shot\"\n ? `Scheduled one-shot event ${filename} for ${payload.platform}/${payload.conversationId} at ${payload.at}`\n : `Queued immediate event ${filename} for ${payload.platform}/${payload.conversationId}`,\n },\n ],\n details: undefined,\n };\n },\n };\n\n return {\n tool,\n setEventContext: (context: EventToolContext) => {\n eventContext = context;\n },\n };\n}\n\nfunction buildEventPayload(params: EventToolParams, context: EventToolContext): EventPayload {\n const base = {\n platform: context.platform,\n conversationId: context.conversationId,\n conversationKind: context.conversationKind,\n userId: context.userId,\n text: params.text,\n };\n\n if (params.type === \"immediate\") {\n return {\n ...base,\n type: \"immediate\",\n };\n }\n\n if (params.type === \"one-shot\") {\n if (!params.at) {\n throw new Error(\"`at` is required for one-shot events\");\n }\n\n const atTime = new Date(params.at).getTime();\n if (Number.isNaN(atTime)) {\n throw new Error(\"`at` must be a valid ISO 8601 timestamp with UTC offset\");\n }\n if (atTime <= Date.now()) {\n throw new Error(\n `\\`at\\` must be in the future; got ${params.at} (now=${new Date().toISOString()}). Check the timezone offset.`,\n );\n }\n\n // No sessionKey or threadTs: reminders should fire as top-level messages, not buried in old threads\n return { ...base, type: \"one-shot\", at: params.at };\n }\n\n if (!params.schedule) {\n throw new Error(\"`schedule` is required for periodic events\");\n }\n if (!params.timezone) {\n throw new Error(\"`timezone` is required for periodic events\");\n }\n return {\n ...base,\n type: \"periodic\",\n schedule: params.schedule,\n timezone: params.timezone,\n };\n}\n\nfunction sanitizeFileSegment(value: string): string {\n const sanitized = value\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return sanitized || \"event\";\n}\n"]}