openclacky 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/commit/SKILL.md +29 -4
  3. data/.clackyrules +3 -1
  4. data/CHANGELOG.md +103 -2
  5. data/README.md +70 -161
  6. data/bin/clarky +11 -0
  7. data/docs/HOW-TO-USE-CN.md +96 -0
  8. data/docs/HOW-TO-USE.md +94 -0
  9. data/docs/config.example.yml +27 -0
  10. data/docs/deploy_subagent_design.md +540 -0
  11. data/docs/time_machine_design.md +247 -0
  12. data/docs/why-openclacky.md +0 -1
  13. data/lib/clacky/agent/cost_tracker.rb +180 -0
  14. data/lib/clacky/agent/llm_caller.rb +54 -0
  15. data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
  16. data/lib/clacky/agent/message_compressor_helper.rb +534 -0
  17. data/lib/clacky/agent/session_serializer.rb +152 -0
  18. data/lib/clacky/agent/skill_manager.rb +138 -0
  19. data/lib/clacky/agent/system_prompt_builder.rb +96 -0
  20. data/lib/clacky/agent/time_machine.rb +199 -0
  21. data/lib/clacky/agent/tool_executor.rb +434 -0
  22. data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
  23. data/lib/clacky/agent.rb +260 -1370
  24. data/lib/clacky/agent_config.rb +447 -10
  25. data/lib/clacky/cli.rb +275 -98
  26. data/lib/clacky/client.rb +12 -2
  27. data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
  28. data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
  29. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
  30. data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
  31. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
  32. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
  33. data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
  34. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
  35. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
  36. data/lib/clacky/default_skills/new/SKILL.md +2 -2
  37. data/lib/clacky/json_ui_controller.rb +195 -0
  38. data/lib/clacky/providers.rb +107 -0
  39. data/lib/clacky/skill.rb +48 -7
  40. data/lib/clacky/skill_loader.rb +7 -0
  41. data/lib/clacky/tools/edit.rb +105 -48
  42. data/lib/clacky/tools/file_reader.rb +44 -73
  43. data/lib/clacky/tools/invoke_skill.rb +89 -0
  44. data/lib/clacky/tools/list_tasks.rb +54 -0
  45. data/lib/clacky/tools/redo_task.rb +41 -0
  46. data/lib/clacky/tools/safe_shell.rb +1 -1
  47. data/lib/clacky/tools/shell.rb +74 -62
  48. data/lib/clacky/tools/trash_manager.rb +1 -1
  49. data/lib/clacky/tools/undo_task.rb +32 -0
  50. data/lib/clacky/tools/web_fetch.rb +2 -1
  51. data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
  52. data/lib/clacky/ui2/components/inline_input.rb +23 -2
  53. data/lib/clacky/ui2/components/input_area.rb +65 -21
  54. data/lib/clacky/ui2/components/modal_component.rb +199 -62
  55. data/lib/clacky/ui2/layout_manager.rb +75 -25
  56. data/lib/clacky/ui2/line_editor.rb +23 -2
  57. data/lib/clacky/ui2/markdown_renderer.rb +31 -10
  58. data/lib/clacky/ui2/screen_buffer.rb +2 -0
  59. data/lib/clacky/ui2/ui_controller.rb +316 -37
  60. data/lib/clacky/ui2.rb +2 -0
  61. data/lib/clacky/ui_interface.rb +50 -0
  62. data/lib/clacky/utils/arguments_parser.rb +31 -3
  63. data/lib/clacky/utils/file_processor.rb +13 -18
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky.rb +19 -9
  66. data/scripts/install.sh +274 -97
  67. data/scripts/uninstall.sh +12 -12
  68. metadata +40 -13
  69. data/.clacky/skills/test-skill/SKILL.md +0 -15
  70. data/lib/clacky/compression/base.rb +0 -231
  71. data/lib/clacky/compression/standard.rb +0 -339
  72. data/lib/clacky/config.rb +0 -117
  73. /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
  74. /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
  75. /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
  76. /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
  77. /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
  78. /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # Skill management and execution
6
+ # Handles skill loading, command parsing, and subagent execution
7
+ module SkillManager
8
+ # Load all skills from configured locations
9
+ # @return [Array<Skill>]
10
+ def load_skills
11
+ @skill_loader.load_all
12
+ end
13
+
14
+ # Check if input is a skill command and process it
15
+ # @param input [String] User input
16
+ # @return [Hash, nil] Returns { skill: Skill, arguments: String } if skill command, nil otherwise
17
+ def parse_skill_command(input)
18
+ # Check for slash command pattern
19
+ if input.start_with?("/")
20
+ # Extract command and arguments
21
+ match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
22
+ return nil unless match
23
+
24
+ skill_name = match[1]
25
+ arguments = match[2] || ""
26
+
27
+ # Find skill by command
28
+ skill = @skill_loader.find_by_command("/#{skill_name}")
29
+ return nil unless skill
30
+
31
+ # Check if user can invoke this skill
32
+ unless skill.user_invocable?
33
+ return nil
34
+ end
35
+
36
+ { skill: skill, arguments: arguments }
37
+ else
38
+ nil
39
+ end
40
+ end
41
+
42
+ # Execute a skill command
43
+ # @param input [String] User input (should be a skill command)
44
+ # @return [String] The expanded prompt with skill content
45
+ def execute_skill_command(input)
46
+ parsed = parse_skill_command(input)
47
+ return input unless parsed
48
+
49
+ skill = parsed[:skill]
50
+ arguments = parsed[:arguments]
51
+
52
+ # Check if skill requires forking a subagent
53
+ if skill.fork_agent?
54
+ return execute_skill_with_subagent(skill, arguments)
55
+ end
56
+
57
+ # Process skill content with arguments (normal skill execution)
58
+ expanded_content = skill.process_content(arguments)
59
+
60
+ # Log skill usage
61
+ @ui&.log("Executing skill: #{skill.identifier}", level: :info)
62
+
63
+ expanded_content
64
+ end
65
+
66
+ # Generate skill context - loads all auto-invocable skills
67
+ # @return [String] Skill context to add to system prompt
68
+ def build_skill_context
69
+ # Load all auto-invocable skills
70
+ all_skills = @skill_loader.load_all
71
+ auto_invocable = all_skills.select(&:model_invocation_allowed?)
72
+
73
+ return "" if auto_invocable.empty?
74
+
75
+ context = "\n\n" + "=" * 80 + "\n"
76
+ context += "AVAILABLE SKILLS:\n"
77
+ context += "=" * 80 + "\n\n"
78
+ context += "CRITICAL SKILL USAGE RULES:\n"
79
+ context += "- When user's request matches a skill description, you MUST use invoke_skill tool\n"
80
+ context += "- NEVER implement skill functionality yourself - always delegate to the skill\n"
81
+ context += "- Example: invoke_skill(skill_name: 'code-explorer', task: 'Analyze project structure')\n\n"
82
+ context += "Available skills:\n\n"
83
+
84
+ auto_invocable.each do |skill|
85
+ context += "- name: #{skill.identifier}\n"
86
+ context += " description: #{skill.context_description}\n\n"
87
+ end
88
+
89
+ context += "\n"
90
+ context
91
+ end
92
+
93
+ private
94
+
95
+ # Execute a skill in a forked subagent
96
+ # @param skill [Skill] The skill to execute
97
+ # @param arguments [String] Arguments for the skill
98
+ # @return [String] Summary of subagent execution
99
+ def execute_skill_with_subagent(skill, arguments)
100
+ # Log subagent fork
101
+ @ui&.show_info("Subagent start: #{skill.identifier}")
102
+
103
+ # Build task content from skill
104
+ task_content = skill.process_content(arguments)
105
+
106
+ # Fork subagent with skill configuration
107
+ subagent = fork_subagent(
108
+ model: skill.subagent_model,
109
+ forbidden_tools: skill.forbidden_tools_list,
110
+ system_prompt_suffix: task_content
111
+ )
112
+
113
+ # Run subagent
114
+ result = subagent.run(arguments)
115
+
116
+ # Generate summary
117
+ summary = generate_subagent_summary(subagent)
118
+
119
+ # Insert summary back to parent agent messages (replacing the instruction message)
120
+ # Find and replace the last message with subagent_instructions flag
121
+ messages_with_instructions = @messages.select { |m| m[:subagent_instructions] }
122
+ if messages_with_instructions.any?
123
+ instruction_msg = messages_with_instructions.last
124
+ instruction_msg[:content] = summary
125
+ instruction_msg.delete(:subagent_instructions)
126
+ instruction_msg[:subagent_result] = true
127
+ instruction_msg[:skill_name] = skill.identifier
128
+ end
129
+
130
+ # Log completion
131
+ @ui&.show_info("Subagent completed: #{result[:iterations]} iterations, $#{result[:total_cost_usd].round(4)}")
132
+
133
+ # Return summary as the skill execution result
134
+ summary
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # System prompt construction
6
+ # Builds system prompt with project rules and skill context
7
+ module SystemPromptBuilder
8
+ # System prompt for the coding agent
9
+ SYSTEM_PROMPT = <<~PROMPT.freeze
10
+ You are OpenClacky, an AI coding assistant and technical co-founder, designed to help non-technical
11
+ users complete software development projects. You are responsible for development in the current project.
12
+
13
+ Your role is to:
14
+ - Understand project requirements and translate them into technical solutions
15
+ - Write clean, maintainable, and well-documented code
16
+ - Follow best practices and industry standards
17
+ - Explain technical concepts in simple terms when needed
18
+ - Proactively identify potential issues and suggest improvements
19
+ - Help with debugging, testing, and deployment
20
+
21
+ Working process:
22
+ 1. **For complex tasks with multiple steps**:
23
+ - Use todo_manager to create a complete TODO list FIRST
24
+ - After creating the TODO list, START EXECUTING each task immediately
25
+ - Don't stop after planning - continue to work on the tasks!
26
+ 2. Always read existing code before making changes (use file_reader/glob/grep or invoke code-explorer skill)
27
+ 3. Ask clarifying questions if requirements are unclear
28
+ 4. Break down complex tasks into manageable steps
29
+ 5. **USE TOOLS to create/modify files** - don't just return code
30
+ 6. Write code that is secure, efficient, and easy to understand
31
+ 7. Test your changes using the shell tool when appropriate
32
+ 8. **IMPORTANT**: After completing each step, mark the TODO as completed and continue to the next one
33
+ 9. Keep working until ALL TODOs are completed or you need user input
34
+ 10. Provide brief explanations after completing actions
35
+
36
+ IMPORTANT: You should frequently refer to the existing codebase. For unclear instructions,
37
+ prioritize understanding the codebase first before answering or taking action.
38
+ Always read relevant code files to understand the project structure, patterns, and conventions.
39
+
40
+ CRITICAL RULE FOR TODO MANAGER:
41
+ When using todo_manager to add tasks, you MUST continue working immediately after adding ALL todos.
42
+ Adding todos is NOT completion - it's just the planning phase!
43
+ Workflow: add todo 1 → add todo 2 → add todo 3 → START WORKING on todo 1 → complete(1) → work on todo 2 → complete(2) → etc.
44
+ NEVER stop after just adding todos without executing them!
45
+
46
+ NOTE: Available skills are listed below in the AVAILABLE SKILLS section.
47
+ When a user's request matches a skill, you MUST use the skill tool instead of implementing it yourself.
48
+ PROMPT
49
+
50
+ # Build complete system prompt with project rules and skills
51
+ # @return [String] Complete system prompt
52
+ def build_system_prompt
53
+ prompt = SYSTEM_PROMPT.dup
54
+
55
+ # Try to load project rules from multiple sources (in order of priority)
56
+ rules_files = [
57
+ { path: ".clackyrules", name: ".clackyrules" },
58
+ { path: ".cursorrules", name: ".cursorrules" },
59
+ { path: "CLAUDE.md", name: "CLAUDE.md" }
60
+ ]
61
+
62
+ rules_content = nil
63
+ rules_source = nil
64
+
65
+ rules_files.each do |file_info|
66
+ full_path = File.join(@working_dir, file_info[:path])
67
+ if File.exist?(full_path)
68
+ content = File.read(full_path).strip
69
+ unless content.empty?
70
+ rules_content = content
71
+ rules_source = file_info[:name]
72
+ break
73
+ end
74
+ end
75
+ end
76
+
77
+ # Add rules to prompt if found
78
+ if rules_content && rules_source
79
+ prompt += "\n\n" + "=" * 80 + "\n"
80
+ prompt += "PROJECT-SPECIFIC RULES (from #{rules_source}):\n"
81
+ prompt += "=" * 80 + "\n"
82
+ prompt += rules_content
83
+ prompt += "\n" + "=" * 80 + "\n"
84
+ prompt += "⚠️ IMPORTANT: Follow these project-specific rules at all times!\n"
85
+ prompt += "=" * 80
86
+ end
87
+
88
+ # Add all loaded skills to system prompt
89
+ skill_context = build_skill_context
90
+ prompt += skill_context if skill_context && !skill_context.empty?
91
+
92
+ prompt
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # Time Machine module for task history management with undo/redo support
6
+ # Stores complete file snapshots (AFTER state) to support message compression
7
+ module TimeMachine
8
+ # Initialize Time Machine state
9
+ private def init_time_machine
10
+ @task_parents ||= {} # { task_id => parent_id }
11
+ @current_task_id ||= 0 # Latest created task ID
12
+ @active_task_id ||= 0 # Current active task ID (for undo/redo)
13
+ end
14
+
15
+ # Start a new task and establish parent relationship
16
+ # Made public for testing
17
+ def start_new_task
18
+ parent_id = @active_task_id
19
+ @current_task_id += 1
20
+ @active_task_id = @current_task_id
21
+ @task_parents[@current_task_id] = parent_id
22
+
23
+ @current_task_id
24
+ end
25
+
26
+ # Save snapshots of modified files (AFTER state)
27
+ # @param modified_files [Array<String>] List of file paths that were modified
28
+ # Made public for testing
29
+ def save_modified_files_snapshot(modified_files)
30
+ return if modified_files.nil? || modified_files.empty?
31
+
32
+ snapshot_dir = File.join(
33
+ Dir.home,
34
+ ".clacky",
35
+ "snapshots",
36
+ @session_id,
37
+ "task-#{@current_task_id}"
38
+ )
39
+ FileUtils.mkdir_p(snapshot_dir)
40
+
41
+ modified_files.each do |file_path|
42
+ next unless File.exist?(file_path)
43
+
44
+ # Save file content to snapshot
45
+ relative_path = file_path.start_with?(@working_dir) ?
46
+ file_path.sub(@working_dir + "/", "") : File.basename(file_path)
47
+
48
+ snapshot_file = File.join(snapshot_dir, relative_path)
49
+ FileUtils.mkdir_p(File.dirname(snapshot_file))
50
+ FileUtils.cp(file_path, snapshot_file)
51
+ end
52
+ rescue StandardError => e
53
+ # Silently handle errors in tests
54
+ end
55
+
56
+ # Restore files to the state at given task
57
+ # @param task_id [Integer] Target task ID
58
+ # Made public for testing
59
+ def restore_to_task_state(task_id)
60
+ # Collect all modified files from task 1 to target task
61
+ files_to_restore = {}
62
+
63
+ (1..task_id).each do |tid|
64
+ snapshot_dir = File.join(
65
+ Dir.home,
66
+ ".clacky",
67
+ "snapshots",
68
+ @session_id,
69
+ "task-#{tid}"
70
+ )
71
+
72
+ next unless Dir.exist?(snapshot_dir)
73
+
74
+ Dir.glob(File.join(snapshot_dir, "**", "*")).each do |snapshot_file|
75
+ next if File.directory?(snapshot_file)
76
+
77
+ relative_path = snapshot_file.sub(snapshot_dir + "/", "")
78
+ files_to_restore[relative_path] = snapshot_file
79
+ end
80
+ end
81
+
82
+ # Restore files
83
+ files_to_restore.each do |relative_path, snapshot_file|
84
+ target_file = File.join(@working_dir, relative_path)
85
+ FileUtils.mkdir_p(File.dirname(target_file))
86
+ FileUtils.cp(snapshot_file, target_file)
87
+ end
88
+ rescue StandardError => e
89
+ # Silently handle errors in tests
90
+ raise
91
+ end
92
+
93
+ # Filter messages to only show tasks up to active_task_id
94
+ # This hides "future" messages when user has undone
95
+ # Made public for testing
96
+ def active_messages
97
+ return @messages if @active_task_id == @current_task_id
98
+
99
+ @messages.select do |msg|
100
+ msg_task_id = msg[:task_id] || 0
101
+ msg_task_id <= @active_task_id
102
+ end
103
+ end
104
+
105
+ # Undo to parent task
106
+ def undo_last_task
107
+ parent_id = @task_parents[@active_task_id]
108
+ return { success: false, message: "Already at root task" } if parent_id.nil? || parent_id == 0
109
+
110
+ restore_to_task_state(parent_id)
111
+ @active_task_id = parent_id
112
+
113
+ {
114
+ success: true,
115
+ message: "⏪ Undone to task #{parent_id}",
116
+ task_id: parent_id
117
+ }
118
+ end
119
+
120
+ # Switch to specific task (for redo or branch switching)
121
+ def switch_to_task(target_task_id)
122
+ if target_task_id > @current_task_id || target_task_id < 1
123
+ return { success: false, message: "Invalid task ID: #{target_task_id}" }
124
+ end
125
+
126
+ restore_to_task_state(target_task_id)
127
+ @active_task_id = target_task_id
128
+
129
+ {
130
+ success: true,
131
+ message: "⏩ Switched to task #{target_task_id}",
132
+ task_id: target_task_id
133
+ }
134
+ end
135
+
136
+ # Get children of a task (for branch detection)
137
+ def get_child_tasks(task_id)
138
+ @task_parents.select { |_, parent| parent == task_id }.keys
139
+ end
140
+
141
+ # Get task history with summaries for UI display
142
+ # @param limit [Integer] Maximum number of recent tasks to return
143
+ # @return [Array<Hash>] Task history with metadata
144
+ def get_task_history(limit: 10)
145
+ return [] if @current_task_id == 0
146
+
147
+ tasks = []
148
+ (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
149
+ # Find first user message for this task
150
+ first_user_msg = @messages.find do |msg|
151
+ msg[:task_id] == task_id && msg[:role] == "user"
152
+ end
153
+
154
+ summary = if first_user_msg
155
+ content = extract_message_text(first_user_msg[:content])
156
+ # Truncate to 60 characters (including "...")
157
+ content.length > 60 ? "#{content[0...57]}..." : content
158
+ else
159
+ "Task #{task_id}"
160
+ end
161
+
162
+ # Determine task status
163
+ status = if task_id == @active_task_id
164
+ :current
165
+ elsif task_id < @active_task_id
166
+ :past
167
+ else
168
+ :future
169
+ end
170
+
171
+ # Check if task has branches (multiple children)
172
+ children = get_child_tasks(task_id)
173
+ has_branches = children.length > 1
174
+
175
+ tasks << {
176
+ task_id: task_id,
177
+ summary: summary,
178
+ status: status,
179
+ has_branches: has_branches
180
+ }
181
+ end
182
+
183
+ tasks
184
+ end
185
+
186
+ # Extract text from message content (handles both string and array formats)
187
+ private def extract_message_text(content)
188
+ if content.is_a?(String)
189
+ content
190
+ elsif content.is_a?(Array)
191
+ text_parts = content.select { |part| part[:type] == "text" }
192
+ text_parts.map { |part| part[:text] }.join(" ")
193
+ else
194
+ ""
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end