octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,685 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ class Agent
5
+ # Session serialization for saving and restoring agent state
6
+ # Handles session data serialization and deserialization
7
+ module SessionSerializer
8
+ # Restore from a saved session
9
+ # @param session_data [Hash] Saved session data
10
+ def restore_session(session_data)
11
+ @session_id = session_data[:session_id]
12
+ @name = session_data[:name] || ""
13
+ @pinned = session_data[:pinned] || false
14
+ @history = MessageHistory.new(session_data[:messages] || [])
15
+ @todos = session_data[:todos] || [] # Restore todos from session
16
+ @iterations = session_data.dig(:stats, :total_iterations) || 0
17
+ @working_dir = session_data[:working_dir]
18
+ @created_at = session_data[:created_at]
19
+ @total_tasks = session_data.dig(:stats, :total_tasks) || 0
20
+ # Restore source; fall back to :manual for sessions saved before this field existed
21
+ @source = (session_data[:source] || "manual").to_sym
22
+
23
+ # Restore cache statistics if available
24
+ @cache_stats = session_data.dig(:stats, :cache_stats) || {
25
+ cache_creation_input_tokens: 0,
26
+ cache_read_input_tokens: 0,
27
+ total_requests: 0,
28
+ cache_hit_requests: 0
29
+ }
30
+
31
+ # Restore previous_total_tokens for accurate delta calculation across sessions
32
+ @previous_total_tokens = session_data.dig(:stats, :previous_total_tokens) || 0
33
+
34
+ # Recover the latest latency metric from the most recent assistant message
35
+ # that carries a :latency field. This is the source of truth for the status-bar
36
+ # signal — no separate session-level field is needed. Older sessions (pre-feature)
37
+ # simply start with nil; the signal stays hidden until the next LLM call populates it.
38
+ last_assistant_with_latency = @history.to_a.reverse.find do |m|
39
+ m[:role].to_s == "assistant" && m[:latency]
40
+ end
41
+ @latest_latency = last_assistant_with_latency&.dig(:latency)
42
+
43
+ # Restore Time Machine state
44
+ @task_parents = session_data.dig(:time_machine, :task_parents) || {}
45
+ @current_task_id = session_data.dig(:time_machine, :current_task_id) || 0
46
+ @active_task_id = session_data.dig(:time_machine, :active_task_id) || 0
47
+
48
+ # Check if the session ended with an error.
49
+ # We record the rollback intent here but do NOT truncate history immediately —
50
+ # truncating at restore time causes the history replay to return empty results,
51
+ # leaving the chat panel blank on first open.
52
+ # Instead, the rollback is deferred: history is trimmed lazily when the user
53
+ # actually sends the next message (see run() / handle_user_message).
54
+ last_status = session_data.dig(:stats, :last_status)
55
+ last_error = session_data.dig(:stats, :last_error)
56
+
57
+ if last_status == "error" && last_error
58
+ @pending_error_rollback = true
59
+ end
60
+
61
+ saved_reasoning = session_data.dig(:config, :reasoning_effort)
62
+ self.reasoning_effort = saved_reasoning if saved_reasoning
63
+
64
+ # Restore the session's original model if it still exists in the current
65
+ # config. This prevents all sessions from silently switching to the new
66
+ # default model when the user changes it and restarts. Falls back to the
67
+ # current default if the model was deleted/renamed since the session was
68
+ # last saved.
69
+ saved_model_name = session_data.dig(:config, :model_name)
70
+ if saved_model_name
71
+ saved_base_url = session_data.dig(:config, :model_base_url)
72
+ model_entry = @config.find_model_by_name_and_url(saved_model_name, saved_base_url)
73
+ if model_entry && model_entry["id"]
74
+ switch_model_by_id(model_entry["id"])
75
+ end
76
+ end
77
+
78
+ # Rebuild and refresh the system prompt so any newly installed skills
79
+ # (or other configuration changes since the session was saved) are
80
+ # reflected immediately — without requiring the user to create a new session.
81
+ refresh_system_prompt
82
+ end
83
+
84
+ # Generate session data for saving
85
+ # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
86
+ # @param error_message [String] Error message if status is :error
87
+ # @return [Hash] Session data ready for serialization
88
+ def to_session_data(status: :success, error_message: nil)
89
+ stats_data = {
90
+ total_tasks: @total_tasks,
91
+ total_iterations: @iterations,
92
+ duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
93
+ last_status: status.to_s,
94
+ cache_stats: @cache_stats,
95
+ debug_logs: @debug_logs,
96
+ previous_total_tokens: @previous_total_tokens
97
+ }
98
+
99
+ # Add error message if status is error
100
+ stats_data[:last_error] = error_message if status == :error && error_message
101
+
102
+ {
103
+ session_id: @session_id,
104
+ name: @name,
105
+ pinned: @pinned,
106
+ created_at: @created_at,
107
+ updated_at: Time.now.iso8601,
108
+ working_dir: @working_dir,
109
+ source: @source.to_s, # "manual" | "cron" | "channel" | "setup"
110
+ agent_profile: @agent_profile&.name || "", # "general" | "coding" | custom
111
+ todos: @todos, # Include todos in session data
112
+ time_machine: { # Include Time Machine state
113
+ task_parents: @task_parents || {},
114
+ current_task_id: @current_task_id || 0,
115
+ active_task_id: @active_task_id || 0
116
+ },
117
+ config: {
118
+ # NOTE: api_key and other sensitive credentials are intentionally excluded
119
+ # to prevent leaking secrets into session files on disk.
120
+ # model_name is saved so the session can restore its original model on restart
121
+ # (falling back to the current default if the model no longer exists).
122
+ permission_mode: @config.permission_mode.to_s,
123
+ enable_compression: @config.enable_compression,
124
+ enable_prompt_caching: @config.enable_prompt_caching,
125
+ max_tokens: @config.max_tokens,
126
+ verbose: @config.verbose,
127
+ reasoning_effort: @reasoning_effort,
128
+ # Persist the current model identity so the session can restore its
129
+ # original model on restart. model_name + model_base_url form a
130
+ # composite key to avoid matching a different provider's model of
131
+ # the same name. Falls back to default if the model no longer exists.
132
+ model_name: @config.current_model&.dig("model"),
133
+ model_base_url: @config.current_model&.dig("base_url")
134
+ },
135
+ stats: stats_data,
136
+ messages: @history.to_a
137
+ }
138
+ end
139
+
140
+ # Get recent user messages from conversation history
141
+ # @param limit [Integer] Number of recent user messages to retrieve (default: 5)
142
+ # @return [Array<String>] Array of recent user message contents
143
+ def get_recent_user_messages(limit: 5)
144
+ @history.real_user_messages.last(limit).map do |msg|
145
+ extract_text_from_content(msg[:content])
146
+ end
147
+ end
148
+
149
+ # Replay conversation history by calling ui.show_* methods for each message.
150
+ # Supports cursor-based pagination using created_at timestamps on user messages.
151
+ # Each "round" starts at a user message and includes all subsequent assistant/tool messages.
152
+ # Compressed chunks (chunk_path on assistant messages) are transparently expanded.
153
+ #
154
+ # @param ui [Object] UI interface that responds to show_user_message, show_assistant_message, etc.
155
+ # @param limit [Integer] Maximum number of rounds (user turns) to replay
156
+ # @param before [Float, nil] Unix timestamp cursor — only replay rounds where the user message
157
+ # created_at < before. Pass nil to get the most recent rounds.
158
+ # @return [Hash] { has_more: Boolean } — whether older rounds exist beyond this page
159
+ def replay_history(ui, limit: 20, before: nil)
160
+ # Split @history into rounds, each starting at a real user message
161
+ rounds = []
162
+ current_round = nil
163
+
164
+ @history.to_a.each do |msg|
165
+ role = msg[:role].to_s
166
+
167
+ # A real user message can have either a String content or an Array content
168
+ # (Array = multipart: text + image blocks). Exclude system-injected messages
169
+ # and synthetic [SYSTEM] text messages.
170
+ is_real_user_msg = role == "user" && !msg[:system_injected] &&
171
+ if msg[:content].is_a?(String)
172
+ !msg[:content].start_with?("[SYSTEM]")
173
+ elsif msg[:content].is_a?(Array)
174
+ # Must contain at least one text or image block (not a tool_result array)
175
+ msg[:content].any? { |b| b.is_a?(Hash) && %w[text image].include?(b[:type].to_s) }
176
+ else
177
+ false
178
+ end
179
+
180
+ if is_real_user_msg
181
+ # Start a new round at each real user message
182
+ current_round = { user_msg: msg, events: [] }
183
+ rounds << current_round
184
+ elsif current_round
185
+ current_round[:events] << msg
186
+ elsif msg[:compressed_summary] && msg[:chunk_path]
187
+ # Compressed summary sitting before any user rounds — expand ALL chunk
188
+ # MD files that belong to the same session (siblings of chunk_path),
189
+ # in chunk-index ascending order.
190
+ #
191
+ # Under the current "single summary + previous_chunks index" scheme,
192
+ # session.json only keeps the newest compressed_summary message (which
193
+ # points at the newest chunk). Older chunks (chunk-1..chunk-N-1) are
194
+ # referenced only as basenames inside the summary text. Expanding just
195
+ # msg[:chunk_path] would therefore lose all prior chunks on replay.
196
+ chunk_rounds = sibling_chunks_of(msg[:chunk_path]).flat_map { |p|
197
+ parse_chunk_md_to_rounds(p)
198
+ }
199
+ rounds.concat(chunk_rounds)
200
+ # After expanding, treat the last chunk round as the current round so that
201
+ # any orphaned assistant/tool messages that follow in session.json (belonging
202
+ # to the same task whose user message was compressed into the chunk) get
203
+ # appended here instead of being silently discarded.
204
+ current_round = rounds.last
205
+ elsif rounds.last
206
+ # Orphaned non-user message with no current_round yet (e.g. recent_messages
207
+ # after compression started mid-task with no leading user message).
208
+ # Attach to the last known round rather than drop silently.
209
+ rounds.last[:events] << msg
210
+ end
211
+ end
212
+
213
+ # Expand any compressed_summary assistant messages sitting inside a round's events.
214
+ # These occur when compression happened mid-round (rare) — expand them in-place.
215
+ rounds.each do |round|
216
+ round[:events].select! { |ev| !ev[:compressed_summary] }
217
+ end
218
+
219
+ # Apply before-cursor filter: only rounds whose user message created_at < before
220
+ if before
221
+ rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before }
222
+ end
223
+
224
+ # Fallback: when the conversation was compressed and no user messages remain in the
225
+ # kept slice, render the surviving assistant/tool messages directly so the user can
226
+ # still see the last visible state of the chat (e.g. compressed summary + recent work).
227
+ if rounds.empty?
228
+ visible = @history.to_a.reject { |m| m[:role].to_s == "system" || m[:system_injected] }
229
+ visible.each { |msg| _replay_single_message(msg, ui) }
230
+ return { has_more: false }
231
+ end
232
+
233
+ has_more = rounds.size > limit
234
+ # Take the most recent `limit` rounds
235
+ page = rounds.last(limit)
236
+
237
+ page.each do |round|
238
+ msg = round[:user_msg]
239
+ raw_text = extract_text_from_content(msg[:content])
240
+ # Images: recovered from inline image_url blocks in content (carry data_url for <img> rendering)
241
+ image_files = extract_image_files_from_content(msg[:content])
242
+ # Disk files (PDF, doc, etc.): stored in display_files on the user message at send time
243
+ disk_files = Array(msg[:display_files]).map { |f|
244
+ { name: f[:name] || f["name"], type: f[:type] || f["type"] || "file",
245
+ preview_path: f[:preview_path] || f["preview_path"] }
246
+ }
247
+ all_files = image_files + disk_files
248
+ ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files)
249
+
250
+ round[:events].each do |ev|
251
+ # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
252
+ # — they are internal scaffolding and must not be shown to the user.
253
+ next if ev[:system_injected]
254
+
255
+ _replay_single_message(ev, ui)
256
+ end
257
+ end
258
+
259
+ { has_more: has_more }
260
+ end
261
+
262
+ # Return all chunk MD file paths that belong to the same session as
263
+ # +chunk_path+, sorted by chunk index ascending (chunk-1, chunk-2, …).
264
+ # Uses the filename convention "<base>-chunk-<N>.md".
265
+ #
266
+ # Handles path resolution the same way parse_chunk_md_to_rounds does:
267
+ # if the stored path doesn't exist, fall back to SESSIONS_DIR + basename
268
+ # (cross-machine / cross-user session bundles).
269
+ private def sibling_chunks_of(chunk_path)
270
+ return [] unless chunk_path
271
+
272
+ resolved = chunk_path.to_s
273
+ unless File.exist?(resolved)
274
+ resolved = File.join(Octo::SessionManager::SESSIONS_DIR, File.basename(resolved))
275
+ end
276
+ return [] unless File.exist?(resolved)
277
+
278
+ dir = File.dirname(resolved)
279
+ base = File.basename(resolved).sub(/-chunk-\d+\.md\z/, "")
280
+ return [resolved] if base == File.basename(resolved) # unconventional name — just use as-is
281
+
282
+ Dir.glob(File.join(dir, "#{base}-chunk-*.md")).sort_by do |p|
283
+ m = File.basename(p).match(/-chunk-(\d+)\.md\z/)
284
+ m ? m[1].to_i : Float::INFINITY
285
+ end
286
+ end
287
+
288
+ # Parse a chunk MD file into an array of rounds compatible with replay_history.
289
+ # Each round is { user_msg: Hash, events: Array<Hash> }.
290
+ # Timestamps are synthesised from the chunk's archived_at, spread backwards.
291
+ # Recursively expands nested chunk references (compressed summary inside a chunk).
292
+ #
293
+ # @param chunk_path [String] Path to the chunk md file
294
+ # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
295
+ private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new)
296
+ return [] unless chunk_path
297
+
298
+ # 1. Try the stored absolute path first (same machine, normal case).
299
+ # 2. If not found, fall back to basename + SESSIONS_DIR (cross-user / cross-machine).
300
+ resolved = chunk_path.to_s
301
+ unless File.exist?(resolved)
302
+ resolved = File.join(Octo::SessionManager::SESSIONS_DIR, File.basename(resolved))
303
+ end
304
+
305
+ return [] unless File.exist?(resolved)
306
+
307
+ # Guard against circular chunk references (e.g. chunk-3 → chunk-2 → chunk-1 → chunk-9 → … → chunk-3)
308
+ canonical = File.expand_path(resolved)
309
+ if visited.include?(canonical)
310
+ Octo::Logger.warn("parse_chunk_md_to_rounds: circular reference detected, skipping #{canonical}")
311
+ return []
312
+ end
313
+ visited = visited.dup.add(canonical)
314
+
315
+ # Scrub invalid UTF-8 bytes defensively — chunk files written before
316
+ # the 0.9.37 fix may contain poisoned bytes from file_reader results.
317
+ raw = File.read(resolved).then do |s|
318
+ s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
319
+ s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
320
+ end
321
+
322
+ # Parse YAML front matter to get archived_at for synthetic timestamps
323
+ archived_at = nil
324
+ if raw.start_with?("---")
325
+ fm_end = raw.index("\n---\n", 4)
326
+ if fm_end
327
+ fm_text = raw[4...fm_end]
328
+ fm_text.each_line do |line|
329
+ if line.start_with?("archived_at:")
330
+ archived_at = Time.parse(line.split(":", 2).last.strip) rescue nil
331
+ end
332
+ end
333
+ end
334
+ end
335
+ base_time = (archived_at || Time.now).to_f
336
+ chunk_dir = File.dirname(chunk_path.to_s)
337
+
338
+ # Split into sections by ## headings
339
+ sections = []
340
+ current_role = nil
341
+ current_lines = []
342
+ current_nested_chunk = nil # chunk reference from a Compressed Summary heading
343
+
344
+ raw.each_line do |line|
345
+ stripped = line.chomp
346
+ if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
347
+ # Nested chunk reference — record it, treat as assistant section
348
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
349
+ current_role = "assistant"
350
+ current_lines = []
351
+ current_nested_chunk = File.join(chunk_dir, m[1])
352
+ elsif stripped.match?(/\A## (User|Assistant)/)
353
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
354
+ current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
355
+ current_lines = []
356
+ current_nested_chunk = nil
357
+ elsif stripped.match?(/\A### Tool Result:/)
358
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
359
+ current_role = "tool"
360
+ current_lines = []
361
+ current_nested_chunk = nil
362
+ else
363
+ current_lines << line
364
+ end
365
+ end
366
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
367
+
368
+ # Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
369
+ sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
370
+
371
+ # Group into rounds: each user section starts a new round
372
+ rounds = []
373
+ current_round = nil
374
+ round_index = 0
375
+
376
+ sections.each do |sec|
377
+ text = sec[:lines].join.strip
378
+
379
+ # Nested chunk: expand it recursively, prepend before current rounds
380
+ if sec[:nested_chunk]
381
+ nested = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
382
+ rounds = nested + rounds unless nested.empty?
383
+ # Also render its summary text as an assistant event in current round if any
384
+ if current_round && !text.empty?
385
+ current_round[:events] << { role: "assistant", content: text }
386
+ end
387
+ next
388
+ end
389
+
390
+ next if text.empty?
391
+
392
+ if sec[:role] == "user"
393
+ round_index += 1
394
+ # Synthetic timestamp: spread rounds backwards from archived_at
395
+ synthetic_ts = base_time - (sections.size - round_index) * 1.0
396
+ current_round = {
397
+ user_msg: {
398
+ role: "user",
399
+ content: text,
400
+ created_at: synthetic_ts,
401
+ _from_chunk: true
402
+ },
403
+ events: []
404
+ }
405
+ rounds << current_round
406
+ elsif current_round
407
+ if sec[:role] == "assistant"
408
+ # Detect "_Tool calls: ..._" lines — convert to tool_calls events
409
+ # so _replay_single_message renders them as tool group UI (same as live).
410
+ #
411
+ # Formats supported:
412
+ # New: "_Tool calls: name | {"arg":"val"}; name2 | {"k":"v"}_"
413
+ # Old: "_Tool calls: name1, name2_" (backward compat)
414
+ remaining_lines = []
415
+ pending_tool_entries = [] # [{name:, args:}]
416
+
417
+ text.each_line do |line|
418
+ stripped = line.strip
419
+ if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
420
+ raw = m[1]
421
+ # New format uses ";" as separator between tools (each entry: "name | {json}")
422
+ # Old format uses "," with no JSON part.
423
+ entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
424
+ entries.each do |entry|
425
+ entry = entry.strip
426
+ if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
427
+ tool_name = parts[1].strip
428
+ args = JSON.parse(parts[2]) rescue {}
429
+ pending_tool_entries << { name: tool_name, args: args }
430
+ else
431
+ pending_tool_entries << { name: entry, args: {} }
432
+ end
433
+ end
434
+ else
435
+ remaining_lines << line
436
+ end
437
+ end
438
+
439
+ # Flush any plain text
440
+ plain_text = remaining_lines.join.strip
441
+ current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
442
+
443
+ # Emit one synthetic tool_calls message per detected tool
444
+ pending_tool_entries.each do |entry|
445
+ current_round[:events] << {
446
+ role: "assistant",
447
+ content: "",
448
+ tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
449
+ }
450
+ end
451
+ else
452
+ current_round[:events] << { role: "tool", content: text }
453
+ end
454
+ end
455
+ end
456
+
457
+ rounds
458
+ rescue => e
459
+ Octo::Logger.warn("parse_chunk_md_to_rounds failed for #{chunk_path}: #{e.message}")
460
+ []
461
+ end
462
+
463
+
464
+ # Render a single non-user message into the UI.
465
+ # Used by both the normal round-based replay and the compressed-session fallback.
466
+ def _replay_single_message(msg, ui)
467
+ return if msg[:system_injected]
468
+
469
+ case msg[:role].to_s
470
+ when "assistant"
471
+ # Text content — prepend reasoning/thinking content wrapped in <think> tags
472
+ # so the Web UI renders it as a collapsible thinking block
473
+ text = extract_text_from_content(msg[:content]).to_s.strip
474
+ reasoning = msg[:reasoning_content]
475
+ if reasoning && !reasoning.to_s.strip.empty?
476
+ text = "<think>\n#{reasoning}\n</think>\n#{text}"
477
+ end
478
+ ui.show_assistant_message(text, files: []) unless text.empty?
479
+
480
+ # Tool calls embedded in assistant message
481
+ Array(msg[:tool_calls]).each do |tc|
482
+ name = tc[:name] || tc.dig(:function, :name) || ""
483
+ args_raw = tc[:arguments] || tc.dig(:function, :arguments) || {}
484
+ args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
485
+
486
+ # Special handling: request_user_feedback question is shown as an
487
+ # assistant message (matching real-time behavior), not as a tool call.
488
+ # Reconstruct the full formatted message including options (mirrors RequestUserFeedback#execute).
489
+ if name == "request_user_feedback"
490
+ question = args.is_a?(Hash) ? (args[:question] || args["question"]).to_s : ""
491
+ context = args.is_a?(Hash) ? (args[:context] || args["context"]).to_s : ""
492
+ options = args.is_a?(Hash) ? (args[:options] || args["options"]) : nil
493
+
494
+ unless question.empty?
495
+ parts = []
496
+ parts << "**Context:** #{context.strip}" << "" unless context.strip.empty?
497
+ parts << "**Question:** #{question.strip}"
498
+ # Guard: options must be an Array to iterate with each_with_index
499
+ if options.is_a?(Array) && !options.empty?
500
+ parts << "" << "**Options:**"
501
+ options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
502
+ end
503
+ ui.show_assistant_message(parts.join("\n"), files: [])
504
+ end
505
+ else
506
+ ui.show_tool_call(name, args)
507
+ end
508
+ end
509
+
510
+ # Emit token usage stored on this message (for history replay display)
511
+ ui.show_token_usage(msg[:token_usage]) if msg[:token_usage]
512
+
513
+ when "user"
514
+ # Anthropic-format tool results (role: user, content: array of tool_result blocks)
515
+ return unless msg[:content].is_a?(Array)
516
+
517
+ msg[:content].each do |blk|
518
+ next unless blk.is_a?(Hash) && blk[:type] == "tool_result"
519
+
520
+ ui.show_tool_result(blk[:content].to_s)
521
+ end
522
+
523
+ when "tool"
524
+ # OpenAI-format tool result
525
+ ui.show_tool_result(msg[:content].to_s)
526
+ end
527
+ end
528
+
529
+ # Replace the system message in @messages with a freshly built system prompt.
530
+ # Called after restore_session so newly installed skills and any other
531
+ # configuration changes since the session was saved take effect immediately.
532
+ # If no system message exists yet (shouldn't happen in practice), a new one
533
+ # is prepended so the conversation stays well-formed.
534
+ def refresh_system_prompt
535
+ # Reload skills from disk to pick up anything installed since the session was saved
536
+ @skill_loader.load_all
537
+
538
+ fresh_prompt = build_system_prompt
539
+ @history.replace_system_prompt(fresh_prompt)
540
+ rescue StandardError => e
541
+ # Log and continue — a stale system prompt is better than a broken restore
542
+ Octo::Logger.warn("refresh_system_prompt failed during session restore: #{e.message}")
543
+ end
544
+
545
+ # Extract base64 data URLs from multipart content (image blocks).
546
+ # Returns an empty array when there are no images or content is plain text.
547
+ # @param content [String, Array, Object] Message content
548
+ # @return [Array<String>] Array of data URLs (e.g. "data:image/png;base64,...")
549
+ def extract_images_from_content(content)
550
+ return [] unless content.is_a?(Array)
551
+
552
+ content.filter_map do |block|
553
+ next unless block.is_a?(Hash)
554
+
555
+ case block[:type].to_s
556
+ when "image_url"
557
+ # OpenAI format: { type: "image_url", image_url: { url: "data:image/png;base64,..." } }
558
+ block.dig(:image_url, :url)
559
+ when "image"
560
+ # Anthropic format: { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
561
+ source = block[:source]
562
+ next unless source.is_a?(Hash) && source[:type].to_s == "base64"
563
+
564
+ "data:#{source[:media_type]};base64,#{source[:data]}"
565
+ when "document"
566
+ # Anthropic PDF document block — return a sentinel string for frontend display
567
+ source = block[:source]
568
+ next unless source.is_a?(Hash) && source[:media_type].to_s == "application/pdf"
569
+
570
+ # Return a special marker so the frontend can render a PDF badge instead of an <img>
571
+ "pdf:#{source[:data]&.then { |d| d[0, 32] }}" # prefix to identify without full payload
572
+ end
573
+ end
574
+ end
575
+
576
+ # Extract text from message content (handles string and array formats)
577
+ # @param content [String, Array, Object] Message content
578
+ # @return [String] Extracted text
579
+ def extract_text_from_content(content)
580
+ if content.is_a?(String)
581
+ content
582
+ elsif content.is_a?(Array)
583
+ # Extract text from content array (may contain text and images)
584
+ text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" }
585
+ text_parts.map { |c| c[:text] }.join("\n")
586
+ else
587
+ content.to_s
588
+ end
589
+ end
590
+
591
+ # Extract images from a multipart content array and return them as file entries.
592
+ # Returns an array of { name:, mime_type:, data_url: } hashes — the same structure
593
+ # that the frontend sends via `files` in a message, and that show_user_message(files:) expects.
594
+ # Only includes inline data_url images (not remote URLs).
595
+ def extract_image_files_from_content(content)
596
+ return [] unless content.is_a?(Array)
597
+
598
+ content.each_with_index.filter_map do |block, idx|
599
+ next unless block.is_a?(Hash)
600
+ # OpenAI-style: { type: "image_url", image_url: { url: "data:image/png;base64,..." } }
601
+ next unless block[:type] == "image_url"
602
+
603
+ url = block.dig(:image_url, :url)
604
+ # image_path is stored at send-time so replay can reconstruct the image from tmp
605
+ path = block[:image_path]
606
+
607
+ next unless url&.start_with?("data:") || path
608
+
609
+ mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
610
+ ext = mime_type.split("/").last
611
+ { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
612
+ end
613
+ end
614
+
615
+ # Inject a chunk index card into the conversation when archived chunks exist.
616
+ # Lists all chunk files (path + topics + turn count) so the AI knows where to
617
+ # look if it needs details from past conversations. The AI can load any chunk
618
+ # on demand using the existing file_reader tool — no new tools required.
619
+ #
620
+ # Only re-injects when a new chunk has been added since the last injection,
621
+ # keeping the message list clean across multiple compressions.
622
+ #
623
+ # Cache-safe: injected as a system_injected user message in the conversation
624
+ # turns, never touching the system prompt.
625
+ def inject_chunk_index_if_needed
626
+ # Collect all compressed_summary messages that carry a chunk_path
627
+ chunk_msgs = @history.to_a.select { |m| m[:compressed_summary] && m[:chunk_path] }
628
+ return if chunk_msgs.empty?
629
+
630
+ # Skip if we already injected an index for this exact chunk count
631
+ return if @history.last_injected_chunk_count == chunk_msgs.size
632
+
633
+ # Remove any previously injected chunk index (stale — chunk count changed)
634
+ @history.delete_where { |m| m[:chunk_index] }
635
+
636
+ # Build index card lines
637
+ lines = ["## Previous Session Archives (#{chunk_msgs.size} chunk#{"s" if chunk_msgs.size > 1} available)\n"]
638
+ chunk_msgs.each_with_index do |msg, i|
639
+ path = msg[:chunk_path].to_s
640
+ topics = read_chunk_topics(path)
641
+ turns = read_chunk_message_count(path)
642
+ lines << "[CHUNK-#{i + 1}] #{path}"
643
+ lines << " Topics: #{topics}" if topics
644
+ lines << " Turns: #{turns}" if turns
645
+ lines << ""
646
+ end
647
+ lines << "Use file_reader to load a chunk file when you need original conversation details."
648
+
649
+ @history.append({
650
+ role: "user",
651
+ content: lines.join("\n"),
652
+ system_injected: true,
653
+ chunk_index: true,
654
+ chunk_count: chunk_msgs.size
655
+ })
656
+ end
657
+
658
+ # Read the `topics` field from a chunk MD file's YAML front matter.
659
+ # Returns nil if the file is missing or has no topics field.
660
+ private def read_chunk_topics(chunk_path)
661
+ return nil unless chunk_path && File.exist?(chunk_path)
662
+ File.foreach(chunk_path) do |line|
663
+ return line.sub(/^topics:\s*/, "").strip if line.start_with?("topics:")
664
+ break if line.strip == "---" && $. > 1 # end of front matter
665
+ end
666
+ nil
667
+ rescue
668
+ nil
669
+ end
670
+
671
+ # Read the `message_count` field from a chunk MD file's YAML front matter.
672
+ # Returns nil if the file is missing or has no message_count field.
673
+ private def read_chunk_message_count(chunk_path)
674
+ return nil unless chunk_path && File.exist?(chunk_path)
675
+ File.foreach(chunk_path) do |line|
676
+ return line.sub(/^message_count:\s*/, "").strip.to_i if line.start_with?("message_count:")
677
+ break if line.strip == "---" && $. > 1
678
+ end
679
+ nil
680
+ rescue
681
+ nil
682
+ end
683
+ end
684
+ end
685
+ end