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,869 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
class Agent
|
|
5
|
+
# Message compression functionality for managing conversation history
|
|
6
|
+
# Handles automatic compression when token limits are exceeded
|
|
7
|
+
module MessageCompressorHelper
|
|
8
|
+
# Compression thresholds
|
|
9
|
+
COMPRESSION_THRESHOLD = 150_000 # Trigger compression when exceeding this (in tokens)
|
|
10
|
+
MESSAGE_COUNT_THRESHOLD = 200 # Trigger compression when exceeding this (in message count)
|
|
11
|
+
MAX_RECENT_MESSAGES = 20 # Keep this many recent message pairs intact
|
|
12
|
+
TARGET_COMPRESSED_TOKENS = 10_000 # Target size after compression
|
|
13
|
+
IDLE_COMPRESSION_THRESHOLD = 20_000 # Minimum messages needed for idle compression
|
|
14
|
+
|
|
15
|
+
# Trigger compression during idle time (user-friendly, interruptible)
|
|
16
|
+
# Returns true if compression was performed, false otherwise
|
|
17
|
+
def trigger_idle_compression
|
|
18
|
+
# Check if we should compress (force mode) BEFORE opening any UI, so
|
|
19
|
+
# "skipped" doesn't flash a spinner on screen.
|
|
20
|
+
compression_context = compress_messages_if_needed(force: true)
|
|
21
|
+
if compression_context.nil?
|
|
22
|
+
Octo::Logger.info(
|
|
23
|
+
"Idle compression skipped",
|
|
24
|
+
enable_compression: @config.enable_compression,
|
|
25
|
+
previous_total_tokens: @previous_total_tokens,
|
|
26
|
+
history_size: @history.size,
|
|
27
|
+
idle_threshold: IDLE_COMPRESSION_THRESHOLD,
|
|
28
|
+
max_recent_messages: MAX_RECENT_MESSAGES
|
|
29
|
+
)
|
|
30
|
+
return false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Own the progress indicator through +with_progress+: the ensure
|
|
34
|
+
# block guarantees the spinner/ticker is released even when the
|
|
35
|
+
# user interrupts mid-way (AgentInterrupted from current thread)
|
|
36
|
+
# or the LLM call fails. No more orphan gray tickers.
|
|
37
|
+
#
|
|
38
|
+
# When @ui is nil (tests / headless) we still need to run the
|
|
39
|
+
# compression work — safe-navigation with a block would silently
|
|
40
|
+
# skip it, so branch explicitly.
|
|
41
|
+
compression_message = compression_context[:compression_message]
|
|
42
|
+
@history.append(compression_message)
|
|
43
|
+
|
|
44
|
+
run_compression = lambda do |handle|
|
|
45
|
+
begin
|
|
46
|
+
response = call_llm
|
|
47
|
+
handle_compression_response(response, compression_context, progress: handle)
|
|
48
|
+
true
|
|
49
|
+
rescue Octo::AgentInterrupted => e
|
|
50
|
+
# User cancelled the idle compression — finish the quiet progress
|
|
51
|
+
# slot in place so the user sees exactly what happened (rather
|
|
52
|
+
# than the "Idle detected..." line being silently removed).
|
|
53
|
+
final = "Idle compression cancelled: #{e.message}"
|
|
54
|
+
if handle
|
|
55
|
+
handle.finish(final_message: final)
|
|
56
|
+
else
|
|
57
|
+
@ui&.log(final, level: :info)
|
|
58
|
+
end
|
|
59
|
+
@history.rollback_before(compression_message)
|
|
60
|
+
Octo::Logger.info("[idle-compress] cancelled: #{e.message}")
|
|
61
|
+
false
|
|
62
|
+
rescue => e
|
|
63
|
+
# Compression failed (most commonly: network errors after all
|
|
64
|
+
# LlmCaller retries exhausted). Previously this only wrote an
|
|
65
|
+
# @ui.log(:error) that was easy to miss — especially when no
|
|
66
|
+
# other output followed. Now we:
|
|
67
|
+
# 1. Replace the active quiet progress line with the error so
|
|
68
|
+
# the user always sees *something* where the spinner was.
|
|
69
|
+
# 2. Emit a show_warning for a more prominent entry.
|
|
70
|
+
# 3. Persist to Octo::Logger so post-mortem is possible even
|
|
71
|
+
# if the terminal scrollback has rolled past.
|
|
72
|
+
final = "Idle compression failed: #{e.message}"
|
|
73
|
+
if handle
|
|
74
|
+
handle.finish(final_message: final)
|
|
75
|
+
else
|
|
76
|
+
@ui&.log(final, level: :error)
|
|
77
|
+
end
|
|
78
|
+
@ui&.show_warning(final)
|
|
79
|
+
Octo::Logger.warn(
|
|
80
|
+
"[idle-compress] failed",
|
|
81
|
+
error_class: e.class.name,
|
|
82
|
+
error_message: e.message,
|
|
83
|
+
backtrace: e.backtrace&.first(5)
|
|
84
|
+
)
|
|
85
|
+
@history.rollback_before(compression_message)
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if @ui
|
|
91
|
+
result = nil
|
|
92
|
+
@ui.with_progress(
|
|
93
|
+
message: "Idle detected. Compressing conversation to optimize costs...",
|
|
94
|
+
style: :quiet
|
|
95
|
+
) do |handle|
|
|
96
|
+
result = run_compression.call(handle)
|
|
97
|
+
end
|
|
98
|
+
result
|
|
99
|
+
else
|
|
100
|
+
run_compression.call(nil)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if compression is needed and return compression context
|
|
105
|
+
# @param force [Boolean] Force compression even if thresholds not met
|
|
106
|
+
# @param pull_back_from_tail [Integer] Number of messages to temporarily pop
|
|
107
|
+
# from the tail of history before building the compression instruction.
|
|
108
|
+
# Used by the context-overflow recovery path: when the current history
|
|
109
|
+
# is already at/over the model's context window, we cannot append even
|
|
110
|
+
# a small compression instruction without overflowing. Popping K messages
|
|
111
|
+
# from the tail frees up token budget for the compression call itself.
|
|
112
|
+
#
|
|
113
|
+
# Cache-preservation note: thanks to the model's two-checkpoint prompt
|
|
114
|
+
# cache (cache#A at second-to-last, cache#B at last), pulling back K=1
|
|
115
|
+
# message keeps cache#A intact — the compression LLM call still hits the
|
|
116
|
+
# cached prefix [system, m1..m(N-1)]. K>=2 sacrifices cache hits but is
|
|
117
|
+
# only used as fallback when one message isn't enough headroom.
|
|
118
|
+
#
|
|
119
|
+
# The popped messages are NOT discarded — they ride along in the
|
|
120
|
+
# returned context and are reattached to the rebuilt history's tail by
|
|
121
|
+
# handle_compression_response, so recent task progress is preserved.
|
|
122
|
+
# @return [Hash, nil] Compression context or nil if not needed
|
|
123
|
+
def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
|
|
124
|
+
# Check if compression is enabled
|
|
125
|
+
return nil unless @config.enable_compression
|
|
126
|
+
|
|
127
|
+
# Use actual API-reported tokens from last request
|
|
128
|
+
total_tokens = @previous_total_tokens
|
|
129
|
+
message_count = @history.size
|
|
130
|
+
|
|
131
|
+
# Force compression (for idle compression) - use lower threshold
|
|
132
|
+
if force
|
|
133
|
+
# Only compress if we have more than MAX_RECENT_MESSAGES + system message
|
|
134
|
+
return nil unless message_count > MAX_RECENT_MESSAGES + 1
|
|
135
|
+
# Also require minimum message count to make compression worthwhile
|
|
136
|
+
return nil unless total_tokens >= IDLE_COMPRESSION_THRESHOLD
|
|
137
|
+
else
|
|
138
|
+
# Normal compression - check thresholds
|
|
139
|
+
# Either: token count exceeds threshold OR message count exceeds threshold
|
|
140
|
+
token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
|
|
141
|
+
message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
|
|
142
|
+
|
|
143
|
+
# Only compress if we exceed at least one threshold
|
|
144
|
+
return nil unless token_threshold_exceeded || message_count_exceeded
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Calculate how much we need to reduce
|
|
148
|
+
reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
|
|
149
|
+
|
|
150
|
+
# Don't compress if reduction is minimal (< 10% of current size)
|
|
151
|
+
# Only apply this check when triggered by token threshold (not for force mode)
|
|
152
|
+
if !force && token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# If only message count threshold is exceeded, force compression
|
|
157
|
+
# to keep conversation history manageable
|
|
158
|
+
|
|
159
|
+
# Calculate target size for recent messages based on compression level
|
|
160
|
+
target_recent_count = calculate_target_recent_count(reduction_needed)
|
|
161
|
+
|
|
162
|
+
# Increment compression level for progressive summarization
|
|
163
|
+
@compression_level += 1
|
|
164
|
+
|
|
165
|
+
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
166
|
+
all_messages = @history.to_a
|
|
167
|
+
|
|
168
|
+
# Pull back K messages from the tail (context-overflow recovery path).
|
|
169
|
+
# We *physically* remove them from @history so the next call_llm
|
|
170
|
+
# (which reads @history.to_api) doesn't include them in the prompt.
|
|
171
|
+
# They will be reattached to the rebuilt history's tail by
|
|
172
|
+
# handle_compression_response after compression succeeds. If compression
|
|
173
|
+
# fails, the caller is responsible for restoring them via the returned
|
|
174
|
+
# context (rollback path).
|
|
175
|
+
pulled_back_messages = []
|
|
176
|
+
if pull_back_from_tail > 0
|
|
177
|
+
k = [pull_back_from_tail, all_messages.size - 1].min # never pop the system message
|
|
178
|
+
k.times do
|
|
179
|
+
popped = @history.pop_last
|
|
180
|
+
pulled_back_messages.unshift(popped) if popped
|
|
181
|
+
end
|
|
182
|
+
# Recompute all_messages from the now-shrunk history so downstream
|
|
183
|
+
# logic (recent_messages selection, build_compression_message) sees
|
|
184
|
+
# the post-pop view.
|
|
185
|
+
all_messages = @history.to_a
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
|
|
189
|
+
recent_messages = [] if recent_messages.nil?
|
|
190
|
+
|
|
191
|
+
# Build compression instruction message (to be inserted into conversation)
|
|
192
|
+
compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)
|
|
193
|
+
|
|
194
|
+
return nil if compression_message.nil?
|
|
195
|
+
|
|
196
|
+
# Return compression context for agent to handle
|
|
197
|
+
{
|
|
198
|
+
compression_message: compression_message,
|
|
199
|
+
recent_messages: recent_messages,
|
|
200
|
+
pulled_back_messages: pulled_back_messages,
|
|
201
|
+
original_token_count: total_tokens,
|
|
202
|
+
original_message_count: @history.size,
|
|
203
|
+
compression_level: @compression_level
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Handle compression response and rebuild message list
|
|
208
|
+
# @param response [Hash] LLM response
|
|
209
|
+
# @param compression_context [Hash] context returned by +compress_messages_if_needed+
|
|
210
|
+
# @param progress [#finish, nil] Owned progress handle from the caller's
|
|
211
|
+
# with_progress block. When provided, the final summary message is
|
|
212
|
+
# delivered via +progress.finish(final_message: ...)+ instead of the
|
|
213
|
+
# legacy +show_progress(phase: "done")+ — this lets +ensure+ in the
|
|
214
|
+
# caller guarantee cleanup even if this method raises mid-way.
|
|
215
|
+
def handle_compression_response(response, compression_context, progress: nil)
|
|
216
|
+
# Extract compressed content from response
|
|
217
|
+
compressed_content = response[:content]
|
|
218
|
+
|
|
219
|
+
# Note: Cost tracking is already handled by call_llm, no need to track again here
|
|
220
|
+
|
|
221
|
+
# Rebuild message list with compression
|
|
222
|
+
# Note: we need to remove the compression instruction message we just added
|
|
223
|
+
original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
|
|
224
|
+
|
|
225
|
+
# Archive compressed messages to a chunk MD file before discarding them.
|
|
226
|
+
#
|
|
227
|
+
# IMPORTANT: chunk_index and previous_chunks MUST come from disk, not from
|
|
228
|
+
# message history. Each compression's rebuild_with_compression keeps only
|
|
229
|
+
# ONE compressed_summary message (the new one), dropping older summaries
|
|
230
|
+
# and embedding their references into the new summary's content. So
|
|
231
|
+
# counting compressed_summary messages in history caps at 1 from the
|
|
232
|
+
# second compression onward — causing chunk-2.md to be overwritten on
|
|
233
|
+
# every subsequent compression, and losing references to chunk-1.md.
|
|
234
|
+
#
|
|
235
|
+
# Disk is the only durable source of truth: chunk files survive process
|
|
236
|
+
# restarts, session reloads, and message rebuilds. SessionManager owns
|
|
237
|
+
# all chunk file I/O (naming, writing, discovery) — we just ask it.
|
|
238
|
+
sm = session_manager
|
|
239
|
+
existing_chunks = sm.chunks_for_current(@session_id, @created_at)
|
|
240
|
+
chunk_index = sm.next_chunk_index(@session_id, @created_at)
|
|
241
|
+
|
|
242
|
+
# Extract topics from the LLM response to store in both the chunk MD front
|
|
243
|
+
# matter and the compressed_summary message hash (for future chunk indexing).
|
|
244
|
+
topics = @message_compressor.parse_topics(compressed_content)
|
|
245
|
+
|
|
246
|
+
chunk_path = save_compressed_chunk(
|
|
247
|
+
original_messages,
|
|
248
|
+
compression_context[:recent_messages],
|
|
249
|
+
chunk_index: chunk_index,
|
|
250
|
+
compression_level: compression_context[:compression_level],
|
|
251
|
+
topics: topics
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Build previous_chunks index from the disk-discovered chunks (already
|
|
255
|
+
# sorted by index ascending). This gives the new summary a complete
|
|
256
|
+
# chronological index of all older archives so the AI can recall any
|
|
257
|
+
# past chunk via file_reader, not just the most recent one.
|
|
258
|
+
previous_chunks = existing_chunks.map do |c|
|
|
259
|
+
{ basename: c[:basename], path: c[:path], topics: c[:topics] }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
@history.replace_all(@message_compressor.rebuild_with_compression(
|
|
263
|
+
compressed_content,
|
|
264
|
+
original_messages: original_messages,
|
|
265
|
+
recent_messages: compression_context[:recent_messages],
|
|
266
|
+
chunk_path: chunk_path,
|
|
267
|
+
topics: topics,
|
|
268
|
+
previous_chunks: previous_chunks,
|
|
269
|
+
pulled_back_messages: compression_context[:pulled_back_messages] || []
|
|
270
|
+
))
|
|
271
|
+
|
|
272
|
+
# Reset to the estimated size of the rebuilt (small) history.
|
|
273
|
+
# The compression call_llm reported the OLD large token count, so
|
|
274
|
+
# @previous_total_tokens would still be above COMPRESSION_THRESHOLD —
|
|
275
|
+
# without this reset the very next think() would re-trigger compression
|
|
276
|
+
# immediately, causing an infinite loop (especially after image uploads
|
|
277
|
+
# where base64 data inflates token counts dramatically).
|
|
278
|
+
@previous_total_tokens = @history.estimate_tokens
|
|
279
|
+
|
|
280
|
+
# Track this compression
|
|
281
|
+
@compressed_summaries << {
|
|
282
|
+
level: compression_context[:compression_level],
|
|
283
|
+
message_count: compression_context[:original_message_count],
|
|
284
|
+
timestamp: Time.now.iso8601,
|
|
285
|
+
strategy: :insert_then_compress,
|
|
286
|
+
chunk_path: chunk_path
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# Show compression info (use estimated tokens from rebuilt history)
|
|
290
|
+
compression_summary = "History compressed (~#{compression_context[:original_token_count]} -> ~#{@history.estimate_tokens} tokens, " \
|
|
291
|
+
"level #{compression_context[:compression_level]})"
|
|
292
|
+
if progress
|
|
293
|
+
# Owned-handle path: the caller's ensure block will still call
|
|
294
|
+
# handle.finish; finishing here with a final_message means that
|
|
295
|
+
# later finish (with no final_message) is a no-op (idempotent).
|
|
296
|
+
progress.finish(final_message: compression_summary)
|
|
297
|
+
else
|
|
298
|
+
@ui&.show_progress(compression_summary, progress_type: "idle_compress", phase: "done")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Get recent messages while preserving tool_calls/tool_results pairs.
|
|
303
|
+
# Handles both canonical format (role: "tool") and legacy Anthropic-native
|
|
304
|
+
# format (role: "user" with tool_result content blocks).
|
|
305
|
+
# @param messages [Array] All messages
|
|
306
|
+
# @param count [Integer] Target number of recent messages to keep
|
|
307
|
+
# @return [Array] Recent messages with complete tool pairs
|
|
308
|
+
def get_recent_messages_with_tool_pairs(messages, count)
|
|
309
|
+
return [] if messages.nil? || messages.empty?
|
|
310
|
+
|
|
311
|
+
messages_to_include = Set.new
|
|
312
|
+
i = messages.size - 1
|
|
313
|
+
messages_collected = 0
|
|
314
|
+
|
|
315
|
+
while i >= 0 && messages_collected < count
|
|
316
|
+
msg = messages[i]
|
|
317
|
+
|
|
318
|
+
# Never include the system message — it is always prepended separately
|
|
319
|
+
# by rebuild_with_compression. Including it here would cause it to appear
|
|
320
|
+
# twice in the rebuilt history, inflating token counts on every compression.
|
|
321
|
+
if msg[:role] == "system"
|
|
322
|
+
i -= 1
|
|
323
|
+
next
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
if messages_to_include.include?(i)
|
|
327
|
+
i -= 1
|
|
328
|
+
next
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
messages_to_include.add(i)
|
|
332
|
+
messages_collected += 1
|
|
333
|
+
|
|
334
|
+
# assistant with tool_calls → also pull in all following tool results
|
|
335
|
+
if msg[:role] == "assistant" && msg[:tool_calls]&.any?
|
|
336
|
+
pull_tool_results_after(messages, i, messages_to_include)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# tool result (canonical or legacy Anthropic) → also pull in its assistant
|
|
340
|
+
if tool_result_message?(msg)
|
|
341
|
+
pull_assistant_before(messages, i, messages_to_include) do |added|
|
|
342
|
+
messages_collected += 1 if added
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
i -= 1
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
recent_messages = messages_to_include.to_a.sort.map { |idx| messages[idx] }
|
|
350
|
+
|
|
351
|
+
# Truncate large tool results to prevent token bloat
|
|
352
|
+
recent_messages.map do |msg|
|
|
353
|
+
truncate_tool_result(msg)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Returns true if msg is a tool result, regardless of storage format.
|
|
359
|
+
# Canonical: role:"tool" | Legacy Anthropic-native: role:"user" + tool_result blocks
|
|
360
|
+
def tool_result_message?(msg)
|
|
361
|
+
MessageFormat::OpenAI.tool_result_message?(msg) ||
|
|
362
|
+
MessageFormat::Anthropic.tool_result_message?(msg)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Returns the tool_call IDs referenced in a tool result message.
|
|
366
|
+
def tool_result_ids(msg)
|
|
367
|
+
if MessageFormat::OpenAI.tool_result_message?(msg)
|
|
368
|
+
MessageFormat::OpenAI.tool_call_ids(msg)
|
|
369
|
+
else
|
|
370
|
+
MessageFormat::Anthropic.tool_use_ids(msg)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Returns true if msg is a tool result that matches any of the given call IDs.
|
|
375
|
+
def tool_result_for?(msg, call_ids)
|
|
376
|
+
tool_result_message?(msg) && (tool_result_ids(msg) & call_ids).any?
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Mark all tool results immediately following messages[assistant_idx].
|
|
380
|
+
# Stops at the first non-tool-result message.
|
|
381
|
+
def pull_tool_results_after(messages, assistant_idx, include_set)
|
|
382
|
+
call_ids = messages[assistant_idx][:tool_calls].map { |tc| tc[:id] }
|
|
383
|
+
j = assistant_idx + 1
|
|
384
|
+
while j < messages.size
|
|
385
|
+
nxt = messages[j]
|
|
386
|
+
if tool_result_for?(nxt, call_ids)
|
|
387
|
+
include_set.add(j)
|
|
388
|
+
elsif !tool_result_message?(nxt)
|
|
389
|
+
break
|
|
390
|
+
end
|
|
391
|
+
j += 1
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Walk backwards from tool_result_idx to find and mark its assistant message.
|
|
396
|
+
# Also marks all sibling tool results for that assistant.
|
|
397
|
+
# Yields true if the assistant was newly added (for caller to increment count).
|
|
398
|
+
def pull_assistant_before(messages, tool_result_idx, include_set)
|
|
399
|
+
result_ids = tool_result_ids(messages[tool_result_idx])
|
|
400
|
+
|
|
401
|
+
j = tool_result_idx - 1
|
|
402
|
+
while j >= 0
|
|
403
|
+
prev = messages[j]
|
|
404
|
+
if prev[:role] == "assistant" && prev[:tool_calls]&.any?
|
|
405
|
+
call_ids = prev[:tool_calls].map { |tc| tc[:id] }
|
|
406
|
+
if (call_ids & result_ids).any?
|
|
407
|
+
newly_added = include_set.add?(j)
|
|
408
|
+
yield newly_added
|
|
409
|
+
|
|
410
|
+
# Also pull all sibling tool results for this assistant
|
|
411
|
+
pull_tool_results_after(messages, j, include_set)
|
|
412
|
+
break
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
j -= 1
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Truncate oversized tool result content to avoid token bloat.
|
|
420
|
+
def truncate_tool_result(msg)
|
|
421
|
+
if MessageFormat::OpenAI.tool_result_message?(msg) &&
|
|
422
|
+
msg[:content].is_a?(String) && msg[:content].length > 2000
|
|
423
|
+
msg.merge(content: msg[:content][0..2000] + "...\n[Content truncated - exceeded 2000 characters]")
|
|
424
|
+
else
|
|
425
|
+
msg
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Lazy accessor for a SessionManager instance used by compression chunk I/O.
|
|
430
|
+
# We keep this local to the helper rather than threading a manager instance
|
|
431
|
+
# through the Agent constructor — Agent itself doesn't persist sessions
|
|
432
|
+
# (CLI / HTTP server do that), but the compression archive lives in the
|
|
433
|
+
# same directory under SessionManager's ownership.
|
|
434
|
+
#
|
|
435
|
+
# NOTE: Uses Octo::SessionManager::SESSIONS_DIR by default. Tests can
|
|
436
|
+
# stub that constant to point at a tmpdir.
|
|
437
|
+
private def session_manager
|
|
438
|
+
@session_manager ||= Octo::SessionManager.new
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Save the messages being compressed to a chunk MD file for future recall.
|
|
442
|
+
# The filesystem concerns (path, write, chmod) are delegated to SessionManager;
|
|
443
|
+
# this method is responsible only for the business rules of WHAT gets archived.
|
|
444
|
+
#
|
|
445
|
+
# @param original_messages [Array<Hash>] All messages before compression (excluding compression instruction)
|
|
446
|
+
# @param recent_messages [Array<Hash>] Recent messages being kept (to exclude from chunk)
|
|
447
|
+
# @param chunk_index [Integer] Sequential chunk number
|
|
448
|
+
# @param compression_level [Integer] Compression level
|
|
449
|
+
# @param topics [String, nil] Short topic description for chunk index card
|
|
450
|
+
# @return [String, nil] Path to saved chunk file, or nil if save failed
|
|
451
|
+
def save_compressed_chunk(original_messages, recent_messages, chunk_index:, compression_level:, topics: nil)
|
|
452
|
+
return nil unless @session_id && @created_at
|
|
453
|
+
|
|
454
|
+
# Messages being compressed = original minus system message minus recent messages
|
|
455
|
+
# Also exclude system-injected scaffolding (session context, memory prompts, etc.)
|
|
456
|
+
# — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
|
|
457
|
+
# Also exclude previous compressed_summary messages: they are index cards pointing
|
|
458
|
+
# to older chunk files and must NOT be embedded inside a new chunk, otherwise
|
|
459
|
+
# parse_chunk_md_to_rounds would follow the nested reference and create circular
|
|
460
|
+
# chunk chains (chunk-2 → chunk-1 → ... → chunk-2).
|
|
461
|
+
recent_set = recent_messages.to_a
|
|
462
|
+
messages_to_archive = original_messages.reject do |m|
|
|
463
|
+
m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
return nil if messages_to_archive.empty?
|
|
467
|
+
|
|
468
|
+
md_content = build_chunk_md(messages_to_archive,
|
|
469
|
+
chunk_index: chunk_index,
|
|
470
|
+
compression_level: compression_level,
|
|
471
|
+
topics: topics)
|
|
472
|
+
|
|
473
|
+
# Delegate filesystem concerns (path assembly, write, chmod) to SessionManager —
|
|
474
|
+
# it owns the on-disk layout for sessions and their chunk archives.
|
|
475
|
+
session_manager.write_chunk(@session_id, @created_at, chunk_index, md_content)
|
|
476
|
+
rescue => e
|
|
477
|
+
@ui&.log("Failed to save chunk MD: #{e.message}", level: :warn)
|
|
478
|
+
nil
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Build markdown content from a list of messages
|
|
482
|
+
# @param messages [Array<Hash>] Messages to render
|
|
483
|
+
# @param chunk_index [Integer] Chunk number for metadata
|
|
484
|
+
# @param compression_level [Integer] Compression level
|
|
485
|
+
# @param topics [String, nil] Short topic description extracted from LLM summary
|
|
486
|
+
# @return [String] Markdown content
|
|
487
|
+
def build_chunk_md(messages, chunk_index:, compression_level:, topics: nil)
|
|
488
|
+
lines = []
|
|
489
|
+
|
|
490
|
+
# Front matter
|
|
491
|
+
lines << "---"
|
|
492
|
+
lines << "session_id: #{@session_id}"
|
|
493
|
+
lines << "chunk: #{chunk_index}"
|
|
494
|
+
lines << "compression_level: #{compression_level}"
|
|
495
|
+
lines << "archived_at: #{Time.now.iso8601}"
|
|
496
|
+
lines << "message_count: #{messages.size}"
|
|
497
|
+
lines << "topics: #{topics}" if topics
|
|
498
|
+
lines << "---"
|
|
499
|
+
lines << ""
|
|
500
|
+
lines << "# Session Chunk #{chunk_index}"
|
|
501
|
+
lines << ""
|
|
502
|
+
lines << "> This file contains the original conversation archived during compression."
|
|
503
|
+
lines << "> Use `file_reader` to recall specific details from this conversation."
|
|
504
|
+
lines << ""
|
|
505
|
+
|
|
506
|
+
messages.each do |msg|
|
|
507
|
+
role = msg[:role]
|
|
508
|
+
content = msg[:content]
|
|
509
|
+
|
|
510
|
+
case role
|
|
511
|
+
when "user"
|
|
512
|
+
lines << "## User"
|
|
513
|
+
lines << ""
|
|
514
|
+
lines << format_message_content(content)
|
|
515
|
+
lines << ""
|
|
516
|
+
when "assistant"
|
|
517
|
+
# If this message is itself a compressed summary, annotate the header
|
|
518
|
+
# so the reader knows the original conversation is in the referenced chunk
|
|
519
|
+
if msg[:compressed_summary] && msg[:chunk_path]
|
|
520
|
+
prev_chunk = File.basename(msg[:chunk_path])
|
|
521
|
+
lines << "## Assistant [Compressed Summary — original conversation at: #{prev_chunk}]"
|
|
522
|
+
else
|
|
523
|
+
lines << "## Assistant"
|
|
524
|
+
end
|
|
525
|
+
lines << ""
|
|
526
|
+
# Include tool calls summary if present
|
|
527
|
+
# Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
|
|
528
|
+
if msg[:tool_calls]&.any?
|
|
529
|
+
tc_parts = msg[:tool_calls].map do |tc|
|
|
530
|
+
name = tc.dig(:function, :name) || tc[:name] || ""
|
|
531
|
+
next nil if name.empty?
|
|
532
|
+
|
|
533
|
+
args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
|
|
534
|
+
args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
|
|
535
|
+
if args.is_a?(Hash) && !args.empty?
|
|
536
|
+
# Truncate large string values to keep chunk MD readable
|
|
537
|
+
compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
|
|
538
|
+
"#{name} | #{compact.to_json}"
|
|
539
|
+
else
|
|
540
|
+
name
|
|
541
|
+
end
|
|
542
|
+
end.compact
|
|
543
|
+
lines << "_Tool calls: #{tc_parts.join("; ")}_"
|
|
544
|
+
lines << ""
|
|
545
|
+
end
|
|
546
|
+
lines << format_message_content(content) if content
|
|
547
|
+
lines << ""
|
|
548
|
+
when "tool"
|
|
549
|
+
tool_name = msg[:name] || "tool"
|
|
550
|
+
lines << "### Tool Result: #{tool_name}"
|
|
551
|
+
lines << ""
|
|
552
|
+
lines << "```"
|
|
553
|
+
lines << truncate_content(content.to_s, max_length: 500)
|
|
554
|
+
lines << "```"
|
|
555
|
+
lines << ""
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
lines.join("\n")
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Format message content (handles string or array of content blocks)
|
|
563
|
+
def format_message_content(content)
|
|
564
|
+
return "" if content.nil?
|
|
565
|
+
return content.to_s if content.is_a?(String)
|
|
566
|
+
|
|
567
|
+
# Handle array of content blocks (e.g., text + images)
|
|
568
|
+
if content.is_a?(Array)
|
|
569
|
+
content.map do |block|
|
|
570
|
+
if block.is_a?(Hash) && block[:type] == "text"
|
|
571
|
+
block[:text].to_s
|
|
572
|
+
else
|
|
573
|
+
"[#{block[:type] || 'content'}]"
|
|
574
|
+
end
|
|
575
|
+
end.join("\n")
|
|
576
|
+
else
|
|
577
|
+
content.to_s
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Truncate long content with a note
|
|
582
|
+
def truncate_content(text, max_length: 500)
|
|
583
|
+
return text if text.length <= max_length
|
|
584
|
+
"#{text[0...max_length]}\n... [truncated, #{text.length} chars total]"
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Calculate how many recent messages to keep based on how much we need to compress
|
|
588
|
+
def calculate_target_recent_count(reduction_needed)
|
|
589
|
+
# We want recent messages to be around 20-30% of the total target
|
|
590
|
+
# This keeps the context window useful without being too large
|
|
591
|
+
tokens_per_message = 500 # Average estimate for a message with content
|
|
592
|
+
|
|
593
|
+
# Target recent messages budget (~20% of target compressed size)
|
|
594
|
+
recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
|
|
595
|
+
target_messages = (recent_budget / tokens_per_message).to_i
|
|
596
|
+
|
|
597
|
+
# Clamp to reasonable bounds
|
|
598
|
+
[[target_messages, 20].max, MAX_RECENT_MESSAGES].min
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Generate hierarchical summary based on compression level
|
|
602
|
+
# Level 1: Detailed summary with files, decisions, features
|
|
603
|
+
# Level 2: Concise summary with key items
|
|
604
|
+
# Level 3: Minimal summary (just project type)
|
|
605
|
+
# Level 4+: Ultra-minimal (single line)
|
|
606
|
+
def generate_hierarchical_summary(messages)
|
|
607
|
+
level = @compression_level
|
|
608
|
+
|
|
609
|
+
# Extract key information from messages
|
|
610
|
+
extracted = extract_key_information(messages)
|
|
611
|
+
|
|
612
|
+
summary_text = case level
|
|
613
|
+
when 1
|
|
614
|
+
generate_level1_summary(extracted)
|
|
615
|
+
when 2
|
|
616
|
+
generate_level2_summary(extracted)
|
|
617
|
+
when 3
|
|
618
|
+
generate_level3_summary(extracted)
|
|
619
|
+
else
|
|
620
|
+
generate_level4_summary(extracted)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
{
|
|
624
|
+
role: "user",
|
|
625
|
+
content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
|
|
626
|
+
system_injected: true,
|
|
627
|
+
compression_level: level
|
|
628
|
+
}
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Extract key information from messages for summarization
|
|
632
|
+
def extract_key_information(messages)
|
|
633
|
+
return empty_extraction_data if messages.nil?
|
|
634
|
+
|
|
635
|
+
{
|
|
636
|
+
# Message counts
|
|
637
|
+
user_msgs: messages.count { |m| m[:role] == "user" },
|
|
638
|
+
assistant_msgs: messages.count { |m| m[:role] == "assistant" },
|
|
639
|
+
tool_msgs: messages.count { |m| m[:role] == "tool" },
|
|
640
|
+
|
|
641
|
+
# Tools used
|
|
642
|
+
tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
|
|
643
|
+
|
|
644
|
+
# Files created/modified
|
|
645
|
+
files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
|
|
646
|
+
files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
|
|
647
|
+
|
|
648
|
+
# Key decisions (limit to first 5)
|
|
649
|
+
decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
|
|
650
|
+
|
|
651
|
+
# Completed tasks (from TODO results)
|
|
652
|
+
completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
|
|
653
|
+
|
|
654
|
+
# Current in-progress work
|
|
655
|
+
in_progress: find_in_progress(messages),
|
|
656
|
+
|
|
657
|
+
# Key results from shell commands
|
|
658
|
+
shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
|
|
659
|
+
}
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Helper: safely extract from messages with proper nil handling
|
|
663
|
+
def extract_from_messages(messages, role_filter = nil, &block)
|
|
664
|
+
return [] if messages.nil?
|
|
665
|
+
|
|
666
|
+
results = messages
|
|
667
|
+
.select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
|
|
668
|
+
.map(&block)
|
|
669
|
+
.compact
|
|
670
|
+
|
|
671
|
+
# Flatten if we have nested arrays (from methods returning arrays of items)
|
|
672
|
+
results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Helper: extract tool names from tool_calls
|
|
676
|
+
def extract_tool_names(tool_calls)
|
|
677
|
+
return [] unless tool_calls.is_a?(Array)
|
|
678
|
+
tool_calls.map { |tc| tc.dig(:function, :name) }
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Helper: filter write results by action
|
|
682
|
+
def filter_write_results(result, action)
|
|
683
|
+
result && result[:action] == action ? result[:file] : nil
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# Helper: filter todo results by status
|
|
687
|
+
def filter_todo_results(result, status)
|
|
688
|
+
result && result[:status] == status ? result[:task] : nil
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Helper: extract decision text from content (returns array of decisions or empty array)
|
|
692
|
+
def extract_decision_text(content)
|
|
693
|
+
return [] unless content.is_a?(String)
|
|
694
|
+
return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
|
|
695
|
+
|
|
696
|
+
sentences = content.split(/[.!?]/).select do |s|
|
|
697
|
+
s.include?("decision") || s.include?("chose") || s.include?("using") ||
|
|
698
|
+
s.include?("decided") || s.include?("will use") || s.include?("selected")
|
|
699
|
+
end
|
|
700
|
+
sentences.map(&:strip).map { |s| s[0..100] }
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Helper: find in-progress task
|
|
704
|
+
def find_in_progress(messages)
|
|
705
|
+
return nil if messages.nil?
|
|
706
|
+
|
|
707
|
+
messages.reverse_each do |m|
|
|
708
|
+
if m[:role] == "tool"
|
|
709
|
+
content = m[:content].to_s
|
|
710
|
+
if content.include?("in progress") || content.include?("working on")
|
|
711
|
+
return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
nil
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Helper: empty extraction data
|
|
719
|
+
def empty_extraction_data
|
|
720
|
+
{
|
|
721
|
+
user_msgs: 0,
|
|
722
|
+
assistant_msgs: 0,
|
|
723
|
+
tool_msgs: 0,
|
|
724
|
+
tools_used: [],
|
|
725
|
+
files_created: [],
|
|
726
|
+
files_modified: [],
|
|
727
|
+
decisions: [],
|
|
728
|
+
completed_tasks: [],
|
|
729
|
+
in_progress: nil,
|
|
730
|
+
shell_results: []
|
|
731
|
+
}
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def parse_write_result(content)
|
|
735
|
+
return nil unless content.is_a?(String)
|
|
736
|
+
|
|
737
|
+
# Check for "Created: path" or "Updated: path" patterns
|
|
738
|
+
if content.include?("Created:")
|
|
739
|
+
{ action: :created, file: content[/Created:\s*(.+)/, 1]&.strip }
|
|
740
|
+
elsif content.include?("Updated:") || content.include?("modified")
|
|
741
|
+
{ action: :modified, file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
|
|
742
|
+
else
|
|
743
|
+
nil
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def parse_todo_result(content)
|
|
748
|
+
return nil unless content.is_a?(String)
|
|
749
|
+
|
|
750
|
+
if content.include?("completed")
|
|
751
|
+
{ status: :completed, task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
|
|
752
|
+
elsif content.include?("added")
|
|
753
|
+
{ status: :added, task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
|
|
754
|
+
else
|
|
755
|
+
nil
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def parse_shell_result(content)
|
|
760
|
+
return nil unless content.is_a?(String)
|
|
761
|
+
|
|
762
|
+
if content.include?("passed") || content.include?("success")
|
|
763
|
+
"tests passed"
|
|
764
|
+
elsif content.include?("failed") || content.include?("error")
|
|
765
|
+
"command failed"
|
|
766
|
+
elsif content =~ /bundle install|npm install|go mod download/
|
|
767
|
+
"dependencies installed"
|
|
768
|
+
elsif content.include?("Installed")
|
|
769
|
+
content[/Installed:\s*(.+)/, 1]&.strip
|
|
770
|
+
else
|
|
771
|
+
nil
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Level 1: Detailed summary (for first compression)
|
|
776
|
+
def generate_level1_summary(data)
|
|
777
|
+
parts = []
|
|
778
|
+
|
|
779
|
+
parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
|
|
780
|
+
|
|
781
|
+
# Files created
|
|
782
|
+
if data[:files_created].any?
|
|
783
|
+
files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
|
|
784
|
+
parts << "Created: #{files_list}"
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# Files modified
|
|
788
|
+
if data[:files_modified].any?
|
|
789
|
+
files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
|
|
790
|
+
parts << "Modified: #{files_list}"
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Completed tasks
|
|
794
|
+
if data[:completed_tasks].any?
|
|
795
|
+
tasks_list = data[:completed_tasks].first(3).join(", ")
|
|
796
|
+
parts << "Completed: #{tasks_list}"
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# In progress
|
|
800
|
+
if data[:in_progress]
|
|
801
|
+
parts << "In Progress: #{data[:in_progress]}"
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Key decisions
|
|
805
|
+
if data[:decisions].any?
|
|
806
|
+
decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
|
|
807
|
+
parts << "Decisions: #{decisions_text}"
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Tools used
|
|
811
|
+
if data[:tools_used].any?
|
|
812
|
+
parts << "Tools: #{data[:tools_used].join(', ')}"
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
parts << "Continuing with recent conversation..."
|
|
816
|
+
parts.join("\n")
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
# Level 2: Concise summary (for second compression)
|
|
820
|
+
def generate_level2_summary(data)
|
|
821
|
+
parts = []
|
|
822
|
+
|
|
823
|
+
parts << "Conversation summary:"
|
|
824
|
+
|
|
825
|
+
# Key files (limit to most important)
|
|
826
|
+
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
827
|
+
if all_files.any?
|
|
828
|
+
key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
|
|
829
|
+
parts << "Files: #{key_files}"
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Key accomplishments
|
|
833
|
+
accomplishments = []
|
|
834
|
+
accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
|
|
835
|
+
accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
|
|
836
|
+
accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
|
|
837
|
+
|
|
838
|
+
parts << accomplishments.join(", ") if accomplishments.any?
|
|
839
|
+
|
|
840
|
+
parts << "Recent context follows..."
|
|
841
|
+
parts.join("\n")
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# Level 3: Minimal summary (for third compression)
|
|
845
|
+
def generate_level3_summary(data)
|
|
846
|
+
parts = []
|
|
847
|
+
|
|
848
|
+
parts << "Project progress:"
|
|
849
|
+
|
|
850
|
+
# Just counts and key items
|
|
851
|
+
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
852
|
+
parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
|
|
853
|
+
|
|
854
|
+
if data[:in_progress]
|
|
855
|
+
parts << "Currently: #{data[:in_progress]}"
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
parts << "See recent messages for details."
|
|
859
|
+
parts.join("\n")
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# Level 4: Ultra-minimal summary (for fourth+ compression)
|
|
863
|
+
def generate_level4_summary(data)
|
|
864
|
+
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
865
|
+
"Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
end
|