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,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Octo
|
|
7
|
+
module Utils
|
|
8
|
+
# Manages user-space parsers in ~/.octo/parsers/.
|
|
9
|
+
#
|
|
10
|
+
# On first use, default parser scripts are copied from the gem's
|
|
11
|
+
# default_parsers/ directory into ~/.octo/parsers/. After that,
|
|
12
|
+
# the user-space version is always used — allowing the LLM to modify
|
|
13
|
+
# or extend parsers without touching the gem itself.
|
|
14
|
+
#
|
|
15
|
+
# CLI interface contract (all parsers must follow):
|
|
16
|
+
# ruby <parser>.rb <file_path>
|
|
17
|
+
# stdout → extracted text (UTF-8)
|
|
18
|
+
# stderr → error messages
|
|
19
|
+
# exit 0 → success
|
|
20
|
+
# exit 1 → failure
|
|
21
|
+
module ParserManager
|
|
22
|
+
PARSERS_DIR = File.expand_path("~/.octo/parsers").freeze
|
|
23
|
+
DEFAULT_PARSERS_DIR = File.expand_path("../default_parsers", __dir__).freeze
|
|
24
|
+
|
|
25
|
+
PARSER_FOR = {
|
|
26
|
+
".pdf" => "pdf_parser.rb",
|
|
27
|
+
".doc" => "doc_parser.rb",
|
|
28
|
+
".docx" => "docx_parser.rb",
|
|
29
|
+
".xlsx" => "xlsx_parser.rb",
|
|
30
|
+
".xls" => "xlsx_parser.rb",
|
|
31
|
+
".pptx" => "pptx_parser.rb",
|
|
32
|
+
".ppt" => "pptx_parser.rb",
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Ensure ~/.octo/parsers/ exists and all default parsers are present.
|
|
36
|
+
# Called at Agent startup (idempotent — safe to run every time).
|
|
37
|
+
#
|
|
38
|
+
# Copies every file from default_parsers/ (not just the entry-point .rb
|
|
39
|
+
# scripts listed in PARSER_FOR). A parser may ship companion helper
|
|
40
|
+
# scripts — e.g. pdf_parser_ocr.py sits next to pdf_parser.rb and is
|
|
41
|
+
# invoked by relative path — so those helpers must be distributed too.
|
|
42
|
+
#
|
|
43
|
+
# Version upgrade policy:
|
|
44
|
+
# Each bundled parser declares `VERSION: <n>` in a header comment
|
|
45
|
+
# (works for Ruby `# VERSION: 2` and Python `# VERSION: 2` alike,
|
|
46
|
+
# scanned in the first 40 lines of the file).
|
|
47
|
+
#
|
|
48
|
+
# On startup, per-file:
|
|
49
|
+
# - If the file does NOT exist in ~/.octo/parsers/ → copy it.
|
|
50
|
+
# - If it exists:
|
|
51
|
+
# * bundled has no VERSION → never touch (bundled file
|
|
52
|
+
# is opting out of managed upgrades).
|
|
53
|
+
# * installed has no VERSION → treat it as legacy v0 and
|
|
54
|
+
# upgrade (lenient mode — covers users who installed before
|
|
55
|
+
# the VERSION scheme existed). The old file is backed up.
|
|
56
|
+
# * both have VERSION, bundled > installed → upgrade, backing
|
|
57
|
+
# up the old copy as `<script>.v<old>.bak`.
|
|
58
|
+
# * bundled ≤ installed → leave the user's copy alone
|
|
59
|
+
# (preserves LLM/user modifications).
|
|
60
|
+
#
|
|
61
|
+
# Backups live alongside the parser so the user can inspect
|
|
62
|
+
# their own edits after an upgrade. They are never removed
|
|
63
|
+
# automatically.
|
|
64
|
+
def self.setup!
|
|
65
|
+
FileUtils.mkdir_p(PARSERS_DIR)
|
|
66
|
+
|
|
67
|
+
Dir.glob(File.join(DEFAULT_PARSERS_DIR, "**", "*")).each do |src|
|
|
68
|
+
next unless File.file?(src)
|
|
69
|
+
basename = File.basename(src)
|
|
70
|
+
next if basename.start_with?(".") || basename.end_with?(".bak")
|
|
71
|
+
|
|
72
|
+
rel = src.sub(/^#{Regexp.escape(DEFAULT_PARSERS_DIR)}\/?/, "")
|
|
73
|
+
dest = File.join(PARSERS_DIR, rel)
|
|
74
|
+
|
|
75
|
+
if !File.exist?(dest)
|
|
76
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
77
|
+
FileUtils.cp(src, dest)
|
|
78
|
+
# Preserve executable bit so sibling scripts can be run directly.
|
|
79
|
+
FileUtils.chmod(File.stat(src).mode, dest)
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
bundled_version = extract_version(src)
|
|
84
|
+
# Bundled file opts out of managed upgrades — never touch user copy.
|
|
85
|
+
next unless bundled_version
|
|
86
|
+
|
|
87
|
+
installed_version = extract_version(dest) || 0
|
|
88
|
+
|
|
89
|
+
if bundled_version > installed_version
|
|
90
|
+
backup = "#{dest}.v#{installed_version}.bak"
|
|
91
|
+
FileUtils.cp(dest, backup) unless File.exist?(backup)
|
|
92
|
+
FileUtils.cp(src, dest)
|
|
93
|
+
FileUtils.chmod(File.stat(src).mode, dest)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Read the VERSION marker from a parser script (e.g. "# VERSION: 2").
|
|
99
|
+
# Works for any script language that uses `#` for comments
|
|
100
|
+
# (Ruby, Python, shell). Returns Integer or nil.
|
|
101
|
+
def self.extract_version(path)
|
|
102
|
+
return nil unless File.exist?(path)
|
|
103
|
+
# Only scan the first 40 lines — the marker lives in the header.
|
|
104
|
+
File.foreach(path).with_index do |line, i|
|
|
105
|
+
break if i >= 40
|
|
106
|
+
if (m = line.match(/^\s*#\s*VERSION:\s*(\d+)/i))
|
|
107
|
+
return m[1].to_i
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
nil
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Run the appropriate parser for the given file path.
|
|
116
|
+
#
|
|
117
|
+
# @param file_path [String] path to the file to parse
|
|
118
|
+
# @return [Hash] { success: bool, text: String, error: String, parser_path: String }
|
|
119
|
+
def self.parse(file_path)
|
|
120
|
+
ext = File.extname(file_path.to_s).downcase
|
|
121
|
+
script = PARSER_FOR[ext]
|
|
122
|
+
|
|
123
|
+
unless script
|
|
124
|
+
return { success: false, text: nil,
|
|
125
|
+
error: "No parser available for #{ext} files",
|
|
126
|
+
parser_path: nil }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
parser_path = File.join(PARSERS_DIR, script)
|
|
130
|
+
|
|
131
|
+
unless File.exist?(parser_path)
|
|
132
|
+
return { success: false, text: nil,
|
|
133
|
+
error: "Parser not found: #{parser_path}",
|
|
134
|
+
parser_path: parser_path }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
raw_stdout, raw_stderr, status = Open3.capture3(RbConfig.ruby, parser_path, file_path)
|
|
138
|
+
|
|
139
|
+
# capture3 returns ASCII-8BIT across the subprocess boundary on Ruby 2.6+.
|
|
140
|
+
# Normalise both streams to UTF-8 immediately so all downstream code is clean.
|
|
141
|
+
stdout = Octo::Utils::Encoding.to_utf8(raw_stdout)
|
|
142
|
+
stderr = Octo::Utils::Encoding.to_utf8(raw_stderr)
|
|
143
|
+
|
|
144
|
+
# Filter out Ruby/Bundler version warnings that pollute stderr
|
|
145
|
+
clean_stderr = stderr.lines.reject { |l| l.match?(/warning:|already initialized constant/) }.join.strip
|
|
146
|
+
|
|
147
|
+
if status.success? && stdout.strip.length > 0
|
|
148
|
+
{ success: true, text: stdout.strip, error: nil, parser_path: parser_path }
|
|
149
|
+
else
|
|
150
|
+
{ success: false, text: nil,
|
|
151
|
+
error: clean_stderr.empty? ? "Parser exited with code #{status.exitstatus}" : clean_stderr,
|
|
152
|
+
parser_path: parser_path }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns the path to a parser script for a given extension.
|
|
157
|
+
# Used by agent to tell LLM where to find/modify the parser.
|
|
158
|
+
def self.parser_path_for(ext)
|
|
159
|
+
script = PARSER_FOR[ext.downcase]
|
|
160
|
+
return nil unless script
|
|
161
|
+
File.join(PARSERS_DIR, script)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module Utils
|
|
5
|
+
module PathHelper
|
|
6
|
+
# Safely get basename from path, return placeholder if path is nil
|
|
7
|
+
def self.safe_basename(path, placeholder: "?")
|
|
8
|
+
return placeholder if path.nil? || path.to_s.empty?
|
|
9
|
+
File.basename(path.to_s)
|
|
10
|
+
rescue StandardError
|
|
11
|
+
placeholder
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module Utils
|
|
7
|
+
# Manages user-space shell scripts in ~/.octo/scripts/.
|
|
8
|
+
#
|
|
9
|
+
# On first use, bundled scripts are copied from the gem's scripts/
|
|
10
|
+
# directory into ~/.octo/scripts/. The user-space copy is always
|
|
11
|
+
# used so users can customise scripts without modifying the gem.
|
|
12
|
+
#
|
|
13
|
+
# Bundled scripts are re-copied when the gem is upgraded (detected
|
|
14
|
+
# via gem version stamp in ~/.octo/scripts/.version).
|
|
15
|
+
module ScriptsManager
|
|
16
|
+
SCRIPTS_DIR = File.expand_path("~/.octo/scripts").freeze
|
|
17
|
+
DEFAULT_SCRIPTS_DIR = File.expand_path("../../../scripts", __dir__).freeze
|
|
18
|
+
VERSION_FILE = File.join(SCRIPTS_DIR, ".version").freeze
|
|
19
|
+
|
|
20
|
+
SCRIPTS = %w[
|
|
21
|
+
install_browser.sh
|
|
22
|
+
install_system_deps.sh
|
|
23
|
+
install_rails_deps.sh
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# Copy bundled scripts to ~/.octo/scripts/ if missing or outdated.
|
|
27
|
+
# Called once at agent startup — fast (no-op after first run).
|
|
28
|
+
def self.setup!
|
|
29
|
+
FileUtils.mkdir_p(SCRIPTS_DIR)
|
|
30
|
+
|
|
31
|
+
current_version = Octo::VERSION
|
|
32
|
+
stored_version = File.exist?(VERSION_FILE) ? File.read(VERSION_FILE).strip : nil
|
|
33
|
+
|
|
34
|
+
SCRIPTS.each do |script|
|
|
35
|
+
dest = File.join(SCRIPTS_DIR, script)
|
|
36
|
+
src = File.join(DEFAULT_SCRIPTS_DIR, script)
|
|
37
|
+
next unless File.exist?(src)
|
|
38
|
+
|
|
39
|
+
# Copy if missing or gem was upgraded
|
|
40
|
+
if !File.exist?(dest) || stored_version != current_version
|
|
41
|
+
FileUtils.cp(src, dest)
|
|
42
|
+
FileUtils.chmod(0o755, dest)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Write version stamp after successful copy
|
|
47
|
+
File.write(VERSION_FILE, current_version)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the full path to a managed script.
|
|
51
|
+
# @param name [String] script filename, e.g. "install_browser.sh"
|
|
52
|
+
# @return [String, nil]
|
|
53
|
+
def self.path_for(name)
|
|
54
|
+
dest = File.join(SCRIPTS_DIR, name)
|
|
55
|
+
File.exist?(dest) ? dest : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module Utils
|
|
5
|
+
# Utilities for finding and matching strings in file content.
|
|
6
|
+
# Used by the Edit tool and edit preview to apply a consistent
|
|
7
|
+
# layered matching strategy: exact → trim → unescape → smart line match.
|
|
8
|
+
module StringMatcher
|
|
9
|
+
# Find a matching string in content using a layered strategy.
|
|
10
|
+
#
|
|
11
|
+
# Strategy (applied in order):
|
|
12
|
+
# 1. Exact match (original old_string)
|
|
13
|
+
# 2. Trimmed match (leading/trailing whitespace stripped)
|
|
14
|
+
# 3. Unescaped match (over-escaped sequences normalised)
|
|
15
|
+
# 4. Combined trim + unescape
|
|
16
|
+
# 5. Smart line-by-line match (tolerates indent differences)
|
|
17
|
+
#
|
|
18
|
+
# @param content [String] File content to search in
|
|
19
|
+
# @param old_string [String] String to locate
|
|
20
|
+
# @return [Hash, nil] { matched_string: String, occurrences: Integer }
|
|
21
|
+
# or nil when nothing matches
|
|
22
|
+
def self.find_match(content, old_string)
|
|
23
|
+
# Defensive: if either side contains invalid UTF-8 bytes (binary files,
|
|
24
|
+
# mixed-encoding content, etc.), Regexp#scan / String#include? with a
|
|
25
|
+
# UTF-8-tagged candidate can raise `ArgumentError: invalid byte sequence
|
|
26
|
+
# in UTF-8`. Scrub once at the entry point so every matching layer —
|
|
27
|
+
# including callers like the edit preview — is safe.
|
|
28
|
+
content = Octo::Utils::Encoding.to_utf8(content) unless content.nil?
|
|
29
|
+
old_string = Octo::Utils::Encoding.to_utf8(old_string) unless old_string.nil?
|
|
30
|
+
|
|
31
|
+
candidates = generate_candidates(old_string)
|
|
32
|
+
|
|
33
|
+
# Simple string matching for each candidate
|
|
34
|
+
candidates.each do |candidate|
|
|
35
|
+
next if candidate.empty?
|
|
36
|
+
|
|
37
|
+
if content.include?(candidate)
|
|
38
|
+
return {
|
|
39
|
+
matched_string: candidate,
|
|
40
|
+
occurrences: count_occurrences(content, candidate)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Fall back to smart line-by-line matching (tabs vs spaces, etc.)
|
|
46
|
+
try_smart_match(content, old_string)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Count non-overlapping occurrences of `needle` in `haystack` without
|
|
50
|
+
# going through Regexp (safer on mixed-encoding strings and avoids an
|
|
51
|
+
# extra escape step).
|
|
52
|
+
def self.count_occurrences(haystack, needle)
|
|
53
|
+
return 0 if needle.empty?
|
|
54
|
+
count = 0
|
|
55
|
+
offset = 0
|
|
56
|
+
while (idx = haystack.index(needle, offset))
|
|
57
|
+
count += 1
|
|
58
|
+
offset = idx + needle.length
|
|
59
|
+
end
|
|
60
|
+
count
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Generate candidate strings by applying different transformations.
|
|
64
|
+
#
|
|
65
|
+
# @param old_string [String]
|
|
66
|
+
# @return [Array<String>] Unique list of candidates
|
|
67
|
+
def self.generate_candidates(old_string)
|
|
68
|
+
trimmed = old_string.strip
|
|
69
|
+
unescaped = unescape_over_escaped(old_string)
|
|
70
|
+
unescaped_trimmed = unescape_over_escaped(trimmed)
|
|
71
|
+
|
|
72
|
+
[
|
|
73
|
+
old_string, # Original
|
|
74
|
+
trimmed, # Trim leading/trailing whitespace
|
|
75
|
+
unescaped, # Unescape over-escaped sequences
|
|
76
|
+
unescaped_trimmed # Combined: trim + unescape
|
|
77
|
+
].uniq
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Convert over-escaped sequences back to their real characters.
|
|
81
|
+
# This handles the common case where LLMs double-escape backslashes.
|
|
82
|
+
#
|
|
83
|
+
# @param str [String]
|
|
84
|
+
# @return [String]
|
|
85
|
+
def self.unescape_over_escaped(str)
|
|
86
|
+
result = str.dup
|
|
87
|
+
|
|
88
|
+
# Unicode escapes: \uXXXX → actual Unicode character
|
|
89
|
+
result = result.gsub(/\\u([0-9a-fA-F]{4})/) { [$1.hex].pack("U") }
|
|
90
|
+
|
|
91
|
+
# Common escape sequences
|
|
92
|
+
result = result.gsub('\\n', "\n")
|
|
93
|
+
result = result.gsub('\\t', "\t")
|
|
94
|
+
result = result.gsub('\\r', "\r")
|
|
95
|
+
result = result.gsub('\\f', "\f")
|
|
96
|
+
result = result.gsub('\\b', "\b")
|
|
97
|
+
result = result.gsub('\\v', "\v")
|
|
98
|
+
result = result.gsub('\\"', '"')
|
|
99
|
+
result = result.gsub('\\\\', "\\")
|
|
100
|
+
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Try smart line-by-line matching that tolerates leading whitespace differences.
|
|
105
|
+
#
|
|
106
|
+
# @param content [String]
|
|
107
|
+
# @param old_string [String]
|
|
108
|
+
# @return [Hash, nil]
|
|
109
|
+
def self.try_smart_match(content, old_string)
|
|
110
|
+
candidates = generate_candidates(old_string)
|
|
111
|
+
|
|
112
|
+
candidates.each do |candidate|
|
|
113
|
+
next if candidate.empty?
|
|
114
|
+
|
|
115
|
+
candidate_lines = candidate.lines
|
|
116
|
+
next if candidate_lines.empty?
|
|
117
|
+
|
|
118
|
+
content_lines = content.lines
|
|
119
|
+
matches = []
|
|
120
|
+
|
|
121
|
+
(0..content_lines.length - candidate_lines.length).each do |start_idx|
|
|
122
|
+
slice = content_lines[start_idx, candidate_lines.length]
|
|
123
|
+
next unless slice
|
|
124
|
+
|
|
125
|
+
if lines_match_normalized?(slice, candidate_lines)
|
|
126
|
+
matches << { start: start_idx, matched_string: slice.join }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless matches.empty?
|
|
131
|
+
return {
|
|
132
|
+
matched_string: matches.first[:matched_string],
|
|
133
|
+
occurrences: matches.length
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Compare two arrays of lines after normalising leading whitespace.
|
|
142
|
+
#
|
|
143
|
+
# @param lines1 [Array<String>]
|
|
144
|
+
# @param lines2 [Array<String>]
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def self.lines_match_normalized?(lines1, lines2)
|
|
147
|
+
return false unless lines1.length == lines2.length
|
|
148
|
+
|
|
149
|
+
lines1.zip(lines2).all? do |line1, line2|
|
|
150
|
+
norm1 = line1.sub(/^\s+/, " ").chomp
|
|
151
|
+
norm2 = line2.sub(/^\s+/, " ").chomp
|
|
152
|
+
|
|
153
|
+
norm1 == norm2 || norm1 == unescape_over_escaped(norm2)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Octo
|
|
7
|
+
# Manages global trash directory at ~/.octo/trash
|
|
8
|
+
# Organizes trash by project directory using path hash
|
|
9
|
+
class TrashDirectory
|
|
10
|
+
GLOBAL_TRASH_ROOT = File.join(Dir.home, ".octo", "trash")
|
|
11
|
+
|
|
12
|
+
attr_reader :project_root, :trash_dir, :backup_dir
|
|
13
|
+
|
|
14
|
+
def initialize(project_root = Dir.pwd)
|
|
15
|
+
@project_root = File.expand_path(project_root)
|
|
16
|
+
@project_hash = generate_project_hash(@project_root)
|
|
17
|
+
@trash_dir = File.join(GLOBAL_TRASH_ROOT, @project_hash)
|
|
18
|
+
@backup_dir = File.join(@trash_dir, "backups")
|
|
19
|
+
|
|
20
|
+
setup_directories
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Generate a unique hash for project path
|
|
24
|
+
def generate_project_hash(path)
|
|
25
|
+
# Use MD5 hash of the absolute path, take first 16 chars for readability
|
|
26
|
+
hash = Digest::MD5.hexdigest(path)[0..15]
|
|
27
|
+
# Also include a readable suffix based on project name
|
|
28
|
+
project_name = File.basename(path).gsub(/[^a-zA-Z0-9_-]/, '_')[0..20]
|
|
29
|
+
"#{hash}_#{project_name}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Setup trash and backup directories with proper structure
|
|
33
|
+
def setup_directories
|
|
34
|
+
[@trash_dir, @backup_dir].each do |dir|
|
|
35
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
36
|
+
|
|
37
|
+
# Create .gitignore file to avoid trash files being committed
|
|
38
|
+
gitignore_path = File.join(dir, '.gitignore')
|
|
39
|
+
unless File.exist?(gitignore_path)
|
|
40
|
+
File.write(gitignore_path, "*\n!.gitignore\n")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Create project metadata file
|
|
45
|
+
create_project_metadata
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Create or update metadata about this project
|
|
49
|
+
def create_project_metadata
|
|
50
|
+
metadata_file = File.join(@trash_dir, '.project_metadata.json')
|
|
51
|
+
|
|
52
|
+
metadata = {
|
|
53
|
+
project_root: @project_root,
|
|
54
|
+
project_name: File.basename(@project_root),
|
|
55
|
+
project_hash: @project_hash,
|
|
56
|
+
created_at: File.exist?(metadata_file) ? JSON.parse(File.read(metadata_file))['created_at'] : Time.now.iso8601,
|
|
57
|
+
last_accessed: Time.now.iso8601
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
File.write(metadata_file, JSON.pretty_generate(metadata))
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
# Log warning but don't block operation
|
|
63
|
+
warn "Warning: Could not create project metadata: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get all project directories that have trash
|
|
67
|
+
def self.all_projects
|
|
68
|
+
return [] unless Dir.exist?(GLOBAL_TRASH_ROOT)
|
|
69
|
+
|
|
70
|
+
projects = []
|
|
71
|
+
Dir.glob(File.join(GLOBAL_TRASH_ROOT, "*", ".project_metadata.json")).each do |metadata_file|
|
|
72
|
+
begin
|
|
73
|
+
metadata = JSON.parse(File.read(metadata_file))
|
|
74
|
+
projects << {
|
|
75
|
+
project_root: metadata['project_root'],
|
|
76
|
+
project_name: metadata['project_name'],
|
|
77
|
+
project_hash: metadata['project_hash'],
|
|
78
|
+
trash_dir: File.dirname(metadata_file),
|
|
79
|
+
last_accessed: metadata['last_accessed']
|
|
80
|
+
}
|
|
81
|
+
rescue StandardError
|
|
82
|
+
# Skip corrupted metadata
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
projects.sort_by { |p| p[:last_accessed] }.reverse
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get trash directory for a specific project
|
|
90
|
+
def self.for_project(project_root)
|
|
91
|
+
new(project_root)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Clean up trash directories for non-existent projects
|
|
95
|
+
def self.cleanup_orphaned_projects
|
|
96
|
+
return 0 unless Dir.exist?(GLOBAL_TRASH_ROOT)
|
|
97
|
+
|
|
98
|
+
cleaned_count = 0
|
|
99
|
+
all_projects.each do |project|
|
|
100
|
+
unless Dir.exist?(project[:project_root])
|
|
101
|
+
# Project no longer exists, optionally remove trash
|
|
102
|
+
# For safety, we'll just mark it as orphaned
|
|
103
|
+
orphan_file = File.join(project[:trash_dir], '.orphaned')
|
|
104
|
+
File.write(orphan_file, "Original project path no longer exists: #{project[:project_root]}\n")
|
|
105
|
+
cleaned_count += 1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
cleaned_count
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module Utils
|
|
5
|
+
# Discovers .octorules files in the workspace.
|
|
6
|
+
# Used by both SystemPromptBuilder (rules injection) and WelcomeBanner (UI display).
|
|
7
|
+
module WorkspaceRules
|
|
8
|
+
RULES_FILENAMES = [".octorules", ".cursorrules", "CLAUDE.md"].freeze
|
|
9
|
+
SUB_PROJECT_SUMMARY_LINES = 5
|
|
10
|
+
|
|
11
|
+
# Find the main rules file in the given directory.
|
|
12
|
+
# @param dir [String] Directory to search
|
|
13
|
+
# @return [Hash, nil] { path:, name:, content: } or nil
|
|
14
|
+
def self.find_main(dir)
|
|
15
|
+
RULES_FILENAMES.each do |filename|
|
|
16
|
+
path = File.join(dir, filename)
|
|
17
|
+
next unless File.exist?(path)
|
|
18
|
+
|
|
19
|
+
content = File.read(path).strip
|
|
20
|
+
return { path: path, name: filename, content: content } unless content.empty?
|
|
21
|
+
end
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Find all sub-project .octorules in immediate subdirectories.
|
|
26
|
+
# @param dir [String] Parent directory to scan
|
|
27
|
+
# @return [Array<Hash>] Array of { sub_name:, relative_path:, content:, summary: }
|
|
28
|
+
def self.find_sub_projects(dir)
|
|
29
|
+
Dir.glob(File.join(dir, "*", ".octorules")).sort.filter_map do |rules_path|
|
|
30
|
+
content = File.read(rules_path).strip
|
|
31
|
+
next if content.empty?
|
|
32
|
+
|
|
33
|
+
sub_name = File.basename(File.dirname(rules_path))
|
|
34
|
+
summary = content.lines.first(SUB_PROJECT_SUMMARY_LINES).map(&:chomp).join("\n")
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
sub_name: sub_name,
|
|
38
|
+
relative_path: "#{sub_name}/.octorules",
|
|
39
|
+
content: content,
|
|
40
|
+
summary: summary
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|