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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- 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
|