openclacky 1.3.4 → 1.3.6

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/message_compressor.rb +32 -8
  5. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent/tool_executor.rb +0 -12
  8. data/lib/clacky/agent.rb +74 -12
  9. data/lib/clacky/api_extension.rb +81 -0
  10. data/lib/clacky/api_extension_loader.rb +13 -1
  11. data/lib/clacky/cli.rb +0 -1
  12. data/lib/clacky/client.rb +14 -17
  13. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  14. data/lib/clacky/default_agents/base_prompt.md +1 -0
  15. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  16. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  17. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  18. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  19. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  20. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  21. data/lib/clacky/json_ui_controller.rb +1 -1
  22. data/lib/clacky/media/base.rb +60 -0
  23. data/lib/clacky/media/dashscope.rb +385 -21
  24. data/lib/clacky/media/gemini.rb +9 -0
  25. data/lib/clacky/media/generator.rb +52 -0
  26. data/lib/clacky/media/openai_compat.rb +166 -0
  27. data/lib/clacky/null_ui_controller.rb +13 -0
  28. data/lib/clacky/plain_ui_controller.rb +1 -1
  29. data/lib/clacky/providers.rb +50 -2
  30. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  31. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  32. data/lib/clacky/server/http_server.rb +260 -122
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +3 -2
  35. data/lib/clacky/session_manager.rb +35 -4
  36. data/lib/clacky/skill_loader.rb +14 -2
  37. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  38. data/lib/clacky/tools/terminal.rb +0 -43
  39. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  40. data/lib/clacky/ui2/layout_manager.rb +0 -5
  41. data/lib/clacky/ui2/progress_handle.rb +0 -3
  42. data/lib/clacky/ui2/ui_controller.rb +140 -40
  43. data/lib/clacky/ui_interface.rb +10 -1
  44. data/lib/clacky/utils/encoding.rb +25 -0
  45. data/lib/clacky/version.rb +1 -1
  46. data/lib/clacky/web/app.css +199 -23
  47. data/lib/clacky/web/components/onboard.js +1 -14
  48. data/lib/clacky/web/components/sidebar.js +1 -3
  49. data/lib/clacky/web/features/backup/store.js +23 -0
  50. data/lib/clacky/web/features/backup/view.js +49 -22
  51. data/lib/clacky/web/features/brand/view.js +8 -5
  52. data/lib/clacky/web/features/channels/store.js +1 -20
  53. data/lib/clacky/web/features/mcp/store.js +1 -20
  54. data/lib/clacky/web/features/profile/store.js +1 -13
  55. data/lib/clacky/web/features/profile/view.js +16 -4
  56. data/lib/clacky/web/features/skills/store.js +6 -21
  57. data/lib/clacky/web/features/tasks/view.js +77 -28
  58. data/lib/clacky/web/features/version/store.js +2 -0
  59. data/lib/clacky/web/i18n.js +46 -3
  60. data/lib/clacky/web/index.html +67 -26
  61. data/lib/clacky/web/sessions.js +177 -87
  62. data/lib/clacky/web/settings.js +34 -2
  63. data/lib/clacky/web/ws-dispatcher.js +12 -4
  64. data/lib/clacky.rb +12 -5
  65. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d5879a7f2c6eb0a1846dd3049fa79abd3ca53119750bd0a43b337339f146dad
4
- data.tar.gz: 12a39e3d860e9e9e691424289543b73813bcb4652e11fe0719e72aa9e6c72760
3
+ metadata.gz: 45a64960de249e34ee18e67f0ce38888b510a886988d545c58aca558d942bbdb
4
+ data.tar.gz: '008d7b7bec8fd7edf43cb9848949637d23783d2ad3a8a2b6199010f9b8ed660d'
5
5
  SHA512:
6
- metadata.gz: cdb47cb9da7cccae4329aa0d4b98f03d9e5cadf763c12b9cd255060e2f230154eed3e7404e494f9a0b7e7b54cdb569f2dc25a1ea69c0b8af386a4a1122942d65
7
- data.tar.gz: 196c48e418e2119201664b920871aa489ff3531128a8aa8b981f73d6ddc95a7d3ef23ecb79da0611810b8af02735f83340ce0712ddb18ad30de0f94421dbd0a4
6
+ metadata.gz: 2f0e9c3fb21691cf9bbfdd6820faaaed86086bebb820278b6104356f92fec4c0e53584a8c3ca40bfd42fb7f82e1f3a484ed404c2b693a47467e9c43d517ed9e8
7
+ data.tar.gz: 83cb1fa9ac5a3ae4834bf5c02c344da9db09a5863a4970857f2d233656c66d9b20fde131f0af4b04d23e204a09a978968ae7d2310ae1c3c3ceb8bcb6d721e3c2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.6] - 2026-06-30
9
+
10
+ ### Added
11
+ - Cron task session isolation with independent pagination and stable task count
12
+ - Backup/restore UI in settings with WebSocket reconnect refresh and WSL open-folder support
13
+
14
+ ### Fixed
15
+ - Rewrite human-readable cron parser to handle comma-separated lists, hour ranges, and `*/1` normalization
16
+ - Setup submit button hover color follows accent theme variable
17
+ - Settings button no longer navigates away when already on settings page
18
+ - Use Unix epoch for old thread timestamps to avoid date parsing issues
19
+
20
+ ## [1.3.5] - 2026-06-29
21
+
22
+ ### Added
23
+ - Run agent inside meeting sessions; launch new meetings directly from session context
24
+ - Long video generation support
25
+ - Show raw LLM error message in error card with collapsible detail
26
+ - Fake tool call detector to catch hallucinated tool invocations
27
+ - Accent color customization for Web UI theme
28
+
29
+ ### Improved
30
+ - Refine auto-confirm wait TUI display
31
+ - Improve split button UX and billing period tab hover behavior
32
+
33
+ ### Fixed
34
+ - Terminal: adaptive GBK/UTF-8 decoding for PTY output to fix garbled text on Windows
35
+ - Resolve vision model detection against the actual request model
36
+ - Fix FrozenError when pressing Ctrl+U to clear text in modal
37
+ - Detect scrollbar-drag correctly to prevent unwanted auto-scroll in chat
38
+ - Fix split button dropdown closing when clicking inside button wrap area
39
+ - Show success toast when saving profile or memory
40
+ - Preserve single line breaks in profile/memory preview
41
+ - Make text selection visible in profile/memory editor
42
+ - Preserve memory card expand state across re-render
43
+ - Refresh time machine on task completion
44
+ - Re-check version on WebSocket reconnect so upgrade badge updates
45
+ - Harden DashScope TTS routing and error handling
46
+
8
47
  ## [1.3.4] - 2026-06-25
9
48
 
10
49
  ### Added
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ module FakeToolCallDetector
6
+ FAKE_TOOL_CALL_PATTERNS = [
7
+ /<\s*invoke\s+name\s*=\s*["'][\w\-]+["']/i,
8
+ /<\s*function_calls\s*>/i,
9
+ /<\s*tool_use\s*[\s>]/i,
10
+ /<\s*antml:invoke\s+name\s*=/i,
11
+ /<\s*antml:function_calls\s*>/i
12
+ ].freeze
13
+
14
+ MAX_FAKE_TOOL_CALL_RETRIES = 2
15
+
16
+ private def fake_tool_call_in_content?(content)
17
+ return false if content.nil? || content.empty?
18
+ FAKE_TOOL_CALL_PATTERNS.any? { |re| content.match?(re) }
19
+ end
20
+
21
+ private def handle_fake_tool_call(response)
22
+ @task_fake_tool_call_count = (@task_fake_tool_call_count || 0) + 1
23
+
24
+ Clacky::Logger.warn("agent.fake_tool_call_detected",
25
+ session_id: @session_id,
26
+ iteration: @iterations,
27
+ retry_count: @task_fake_tool_call_count,
28
+ content_head: response[:content].to_s[0, 200],
29
+ finish_reason: response[:finish_reason].to_s
30
+ )
31
+
32
+ if @task_fake_tool_call_count > MAX_FAKE_TOOL_CALL_RETRIES
33
+ @ui&.show_error("Model repeatedly emitted text-formatted tool calls instead of using the tool_calls API. Stopping.")
34
+ emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content]) if response[:content] && !response[:content].empty?
35
+ return :stop
36
+ end
37
+
38
+ @history.append({ role: "assistant", content: response[:content].to_s })
39
+ @history.append({
40
+ role: "user",
41
+ content: "Your previous reply contained tool-call XML written as text " \
42
+ "(e.g. `<invoke name=\"...\">`). That syntax is NOT executed — " \
43
+ "it was rendered to the user as raw text. " \
44
+ "Re-issue the call using the structured tool_calls field provided by the runtime, " \
45
+ "or, if no tool is needed, just answer normally.",
46
+ system_injected: true
47
+ })
48
+ :retry
49
+ end
50
+ end
51
+ end
52
+ end
@@ -39,10 +39,14 @@ module Clacky
39
39
 
40
40
  REQUIRED RESPONSE FORMAT:
41
41
  First output a <topics> line listing 3-6 key topic phrases (comma-separated, concise).
42
+ Then output a <continues_previous> line: "true" if this conversation is a direct
43
+ continuation of the SAME task/topic as the PREVIOUS chunk shown below, "false" if it
44
+ has moved on to a different task or topic. If there is no previous chunk, output "false".
42
45
  Then output the full summary wrapped in <summary> tags.
43
46
 
44
47
  Example format:
45
48
  <topics>Rails setup, database config, deploy pipeline, Tailwind CSS</topics>
49
+ <continues_previous>false</continues_previous>
46
50
  <summary>
47
51
  ...full summary text...
48
52
  </summary>
@@ -54,7 +58,8 @@ module Clacky
54
58
  - Errors encountered and fixes applied
55
59
  - Current work status and pending tasks
56
60
 
57
- Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then <summary>.
61
+ Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then
62
+ <continues_previous> then <summary>.
58
63
  PROMPT
59
64
 
60
65
  def initialize(client, model: nil)
@@ -72,18 +77,25 @@ module Clacky
72
77
  #
73
78
  # @param messages [Array<Hash>] Original conversation messages
74
79
  # @param recent_messages [Array<Hash>] Recent messages to keep uncompressed (optional)
80
+ # @param previous_topics [String, nil] Topics of the most recent chunk on disk,
81
+ # shown to the LLM so it can decide whether the current conversation is a
82
+ # continuation (drives the <continues_previous> output for chunk merging).
75
83
  # @return [Hash] Compression instruction message to insert, or nil if nothing to compress
76
- def build_compression_message(messages, recent_messages: [])
84
+ def build_compression_message(messages, recent_messages: [], previous_topics: nil)
77
85
  # Get messages to compress (exclude system message and recent messages)
78
86
  messages_to_compress = messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
79
87
 
80
88
  # If nothing to compress, return nil
81
89
  return nil if messages_to_compress.empty?
82
90
 
83
- # Simple compression instruction - LLM can see the history already
84
- {
85
- role: "user",
86
- content: COMPRESSION_PROMPT,
91
+ content = COMPRESSION_PROMPT
92
+ if previous_topics && !previous_topics.strip.empty?
93
+ content = "#{COMPRESSION_PROMPT}\n\nPREVIOUS CHUNK TOPICS (for <continues_previous> judgement): #{previous_topics}"
94
+ end
95
+
96
+ {
97
+ role: "user",
98
+ content: content,
87
99
  system_injected: true
88
100
  }
89
101
  end
@@ -142,6 +154,15 @@ module Clacky
142
154
  m ? m[1].strip : nil
143
155
  end
144
156
 
157
+ # Parse the <continues_previous> tag. Returns true only when the LLM
158
+ # explicitly says "true"; missing tag or any other value → false.
159
+ # This conservative default ensures we never merge unless the model is sure.
160
+ def parse_continues_previous(content)
161
+ return false if content.nil? || content.to_s.empty?
162
+ m = content.to_s.match(/<continues_previous>(.*?)<\/continues_previous>/m)
163
+ m ? m[1].strip.downcase == "true" : false
164
+ end
165
+
145
166
  def parse_compressed_result(result, chunk_path: nil, topics: nil, previous_chunks: [])
146
167
  # Return the compressed result as a single user message (role: "user").
147
168
  #
@@ -171,8 +192,11 @@ module Clacky
171
192
  if content.empty?
172
193
  []
173
194
  else
174
- # Strip out the <topics> blockit's metadata for the chunk file, not for AI context
175
- content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "").strip
195
+ # Strip out the <topics> and <continues_previous> blocks they're
196
+ # metadata for chunk handling, not for AI context.
197
+ content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "")
198
+ .gsub(/<continues_previous>.*?<\/continues_previous>\n*/m, "")
199
+ .strip
176
200
 
177
201
  # Build previous chunks index section — links to older chunk files so the AI
178
202
  # can find earlier conversations without keeping all prior compressed_summary
@@ -193,8 +193,18 @@ module Clacky
193
193
  recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
194
194
  recent_messages = [] if recent_messages.nil?
195
195
 
196
+ # Surface the most recent chunk's topics so the compression LLM can judge
197
+ # whether this conversation continues the same task (drives chunk merging).
198
+ previous_topics = nil
199
+ if @session_id && @created_at
200
+ latest = session_manager.chunks_for_current(@session_id, @created_at).last
201
+ previous_topics = latest && latest[:topics]
202
+ end
203
+
196
204
  # Build compression instruction message (to be inserted into conversation)
197
- compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)
205
+ compression_message = @message_compressor.build_compression_message(
206
+ all_messages, recent_messages: recent_messages, previous_topics: previous_topics
207
+ )
198
208
 
199
209
  return nil if compression_message.nil?
200
210
 
@@ -242,25 +252,52 @@ module Clacky
242
252
  # all chunk file I/O (naming, writing, discovery) — we just ask it.
243
253
  sm = session_manager
244
254
  existing_chunks = sm.chunks_for_current(@session_id, @created_at)
245
- chunk_index = sm.next_chunk_index(@session_id, @created_at)
246
255
 
247
256
  # Extract topics from the LLM response to store in both the chunk MD front
248
257
  # matter and the compressed_summary message hash (for future chunk indexing).
249
258
  topics = @message_compressor.parse_topics(compressed_content)
250
259
 
251
- chunk_path = save_compressed_chunk(
252
- original_messages,
253
- compression_context[:recent_messages],
254
- chunk_index: chunk_index,
255
- compression_level: compression_context[:compression_level],
256
- topics: topics
257
- )
260
+ # Decide whether to MERGE into the previous chunk or create a NEW one.
261
+ # The LLM judges (via <continues_previous>) whether this conversation is a
262
+ # direct continuation of the previous chunk's task. Merging avoids tiny
263
+ # fragmented chunks (e.g. a long task compressed mid-flight into 2-message
264
+ # chunks) that pollute the topics index and degrade recall.
265
+ latest_chunk = existing_chunks.last
266
+ continues = latest_chunk && @message_compressor.parse_continues_previous(compressed_content)
267
+
268
+ if continues
269
+ chunk_path = merge_into_previous_chunk(
270
+ latest_chunk,
271
+ original_messages,
272
+ compression_context[:recent_messages],
273
+ compression_level: compression_context[:compression_level],
274
+ topics: topics
275
+ )
276
+ # Fallback to new chunk if the merge could not be performed.
277
+ chunk_path ||= save_compressed_chunk(
278
+ original_messages, compression_context[:recent_messages],
279
+ chunk_index: sm.next_chunk_index(@session_id, @created_at),
280
+ compression_level: compression_context[:compression_level], topics: topics
281
+ )
282
+ # The merged chunk is the current chunk — exclude it from previous_chunks.
283
+ index_chunks = existing_chunks.reject { |c| c[:index] == latest_chunk[:index] }
284
+ else
285
+ chunk_index = sm.next_chunk_index(@session_id, @created_at)
286
+ chunk_path = save_compressed_chunk(
287
+ original_messages,
288
+ compression_context[:recent_messages],
289
+ chunk_index: chunk_index,
290
+ compression_level: compression_context[:compression_level],
291
+ topics: topics
292
+ )
293
+ index_chunks = existing_chunks
294
+ end
258
295
 
259
296
  # Build previous_chunks index from the disk-discovered chunks (already
260
297
  # sorted by index ascending). This gives the new summary a complete
261
298
  # chronological index of all older archives so the AI can recall any
262
299
  # past chunk via file_reader, not just the most recent one.
263
- previous_chunks = existing_chunks.map do |c|
300
+ previous_chunks = index_chunks.map do |c|
264
301
  { basename: c[:basename], path: c[:path], topics: c[:topics] }
265
302
  end
266
303
 
@@ -483,6 +520,62 @@ module Clacky
483
520
  nil
484
521
  end
485
522
 
523
+ # Merge the current batch of compressed messages INTO an existing chunk
524
+ # (overwrite-in-place, same chunk index). Used when the LLM judged this
525
+ # conversation as a continuation of the previous chunk's task. Keeps the
526
+ # archive on a single, growing, well-formed chunk instead of fragmenting
527
+ # into tiny standalone files that pollute the topics index.
528
+ #
529
+ # Every write hits disk immediately, so a crash never loses archived
530
+ # messages — there is no in-memory buffering.
531
+ #
532
+ # @param prev_chunk [Hash] disk-discovered chunk hash ({ index:, path:, topics: })
533
+ # @return [String, nil] the chunk path on success, nil if merge not possible
534
+ def merge_into_previous_chunk(prev_chunk, original_messages, recent_messages, compression_level:, topics: nil)
535
+ return nil unless @session_id && @created_at
536
+
537
+ recent_set = recent_messages.to_a
538
+ messages_to_archive = original_messages.reject do |m|
539
+ m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
540
+ end
541
+ return nil if messages_to_archive.empty?
542
+
543
+ sm = session_manager
544
+ raw = sm.read_chunk(prev_chunk[:path])
545
+ return nil unless raw
546
+
547
+ fm, body = sm.split_chunk_md(raw)
548
+ return nil unless fm
549
+
550
+ new_sections = render_message_sections(messages_to_archive)
551
+
552
+ fm["compression_level"] = compression_level.to_s
553
+ fm["archived_at"] = Time.now.iso8601
554
+ fm["message_count"] = (fm["message_count"].to_i + messages_to_archive.size).to_s
555
+ fm["merged_count"] = (fm.fetch("merged_count", "1").to_i + 1).to_s
556
+ fm["topics"] = merge_topics(fm["topics"], topics)
557
+
558
+ lines = ["---"]
559
+ fm.each { |k, v| lines << "#{k}: #{v}" }
560
+ lines << "---"
561
+ lines << body.rstrip
562
+ lines << ""
563
+ lines.concat(new_sections)
564
+
565
+ sm.write_chunk(@session_id, @created_at, prev_chunk[:index], lines.join("\n"))
566
+ rescue => e
567
+ @ui&.log("Failed to merge chunk MD: #{e.message}", level: :warn)
568
+ nil
569
+ end
570
+
571
+ # Union two comma-separated topic strings, preserving order, dropping dups.
572
+ private def merge_topics(existing, incoming)
573
+ a = (existing || "").split(/\s*,\s*/).map(&:strip).reject(&:empty?)
574
+ b = (incoming || "").split(/\s*,\s*/).map(&:strip).reject(&:empty?)
575
+ merged = (a + b).uniq
576
+ merged.empty? ? nil : merged.join(", ")
577
+ end
578
+
486
579
  # Build markdown content from a list of messages
487
580
  # @param messages [Array<Hash>] Messages to render
488
581
  # @param chunk_index [Integer] Chunk number for metadata
@@ -508,6 +601,15 @@ module Clacky
508
601
  lines << "> Use `file_reader` to recall specific details from this conversation."
509
602
  lines << ""
510
603
 
604
+ lines.concat(render_message_sections(messages))
605
+
606
+ lines.join("\n")
607
+ end
608
+
609
+ # Render messages into chunk MD body sections (no front matter / header).
610
+ # Shared by build_chunk_md and the chunk-merge path.
611
+ def render_message_sections(messages)
612
+ lines = []
511
613
  messages.each do |msg|
512
614
  role = msg[:role]
513
615
  content = msg[:content]
@@ -560,8 +662,7 @@ module Clacky
560
662
  lines << ""
561
663
  end
562
664
  end
563
-
564
- lines.join("\n")
665
+ lines
565
666
  end
566
667
 
567
668
  # Format message content (handles string or array of content blocks)
@@ -153,7 +153,7 @@ module Clacky
153
153
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
154
154
  # @param error_message [String] Error message if status is :error
155
155
  # @return [Hash] Session data ready for serialization
156
- def to_session_data(status: :success, error_message: nil, updated_at: nil, preserve_updated_at: false)
156
+ def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil, preserve_updated_at: false)
157
157
  stats_data = {
158
158
  total_tasks: @total_tasks,
159
159
  total_iterations: @iterations,
@@ -167,7 +167,8 @@ module Clacky
167
167
  }
168
168
 
169
169
  # Add error message if status is error
170
- stats_data[:last_error] = error_message if status == :error && error_message
170
+ stats_data[:last_error] = error_message if status == :error && error_message
171
+ stats_data[:last_error_raw] = raw_message if status == :error && raw_message
171
172
 
172
173
  {
173
174
  session_id: @session_id,
@@ -254,18 +254,6 @@ module Clacky
254
254
  }
255
255
  end
256
256
 
257
- # Show countdown before auto-executing in auto_approve mode.
258
- # Gives the user time to see what's happening and Ctrl+C to cancel.
259
- # @param seconds [Integer] Countdown duration
260
- private def auto_approve_countdown(seconds: 10)
261
- return unless @ui
262
-
263
- seconds.downto(1) do |remaining|
264
- @ui.show_info(" Auto-executing in #{remaining}s... (Ctrl+C to cancel)", prefix_newline: false)
265
- sleep 1
266
- end
267
- end
268
-
269
257
  # Check if a tool is potentially slow and should show progress
270
258
  # @param tool_name [String] Name of the tool
271
259
  # @param args [Hash] Tool arguments
data/lib/clacky/agent.rb CHANGED
@@ -5,6 +5,7 @@ require "json"
5
5
  require "cgi"
6
6
  require "tty-prompt"
7
7
  require "set"
8
+ require_relative "null_ui_controller"
8
9
  require_relative "utils/arguments_parser"
9
10
  require_relative "utils/file_processor"
10
11
  require_relative "utils/environment_detector"
@@ -23,6 +24,7 @@ require_relative "agent/memory_updater"
23
24
  require_relative "agent/skill_evolution"
24
25
  require_relative "agent/skill_reflector"
25
26
  require_relative "agent/skill_auto_creator"
27
+ require_relative "agent/fake_tool_call_detector"
26
28
 
27
29
  module Clacky
28
30
  class Agent
@@ -39,6 +41,7 @@ module Clacky
39
41
  include SkillEvolution
40
42
  include SkillReflector
41
43
  include SkillAutoCreator
44
+ include FakeToolCallDetector
42
45
 
43
46
  attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
44
47
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
@@ -274,6 +277,7 @@ module Clacky
274
277
  else
275
278
  @start_time = Time.now
276
279
  @task_truncation_count = 0 # Reset truncation counter for each task
280
+ @task_fake_tool_call_count = 0 # Reset fake tool-call counter for each task
277
281
  @task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
278
282
  @task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
279
283
  @task_cost_source = :estimated # Reset for new task
@@ -488,6 +492,18 @@ module Clacky
488
492
  Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
489
493
  end
490
494
 
495
+ # Detect fake tool-calls written as XML/text in content (model bug
496
+ # where it emits `<invoke name="...">` instead of using the
497
+ # structured tool_calls field). Only triggers when tool_calls is
498
+ # absent — a real call alongside stray XML is not our problem here.
499
+ if (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
500
+ fake_tool_call_in_content?(response[:content])
501
+ case handle_fake_tool_call(response)
502
+ when :retry then next
503
+ when :stop then break
504
+ end
505
+ end
506
+
491
507
  # Check if done (no more tool calls needed).
492
508
  #
493
509
  # Defensive rule: we ONLY exit on empty/missing tool_calls.
@@ -667,9 +683,6 @@ module Clacky
667
683
  raise
668
684
  ensure
669
685
  # Safety net: ensure any lingering progress spinner is stopped.
670
- # Normal paths close their own spinners; this guards against exceptions
671
- # raised between a progress slot's active/done pair.
672
- Clacky::Logger.warn("[ph_debug] agent_run_ensure")
673
686
  @ui&.show_progress(phase: "done")
674
687
 
675
688
  # Fire-and-forget telemetry after every agent run.
@@ -949,10 +962,9 @@ module Clacky
949
962
  end
950
963
 
951
964
  # Special handling for request_user_feedback
952
- if call[:name] == "request_user_feedback"
953
- # In auto_approve mode, give user time to see and cancel before auto-answering
954
- auto_approve_countdown(seconds: 10) if @config.permission_mode == :auto_approve
955
- else
965
+ # The interactive countdown (auto_approve) is handled after the tool
966
+ # executes, once the question itself has been rendered to the user.
967
+ unless call[:name] == "request_user_feedback"
956
968
  @ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
957
969
  end
958
970
 
@@ -1037,11 +1049,26 @@ module Clacky
1037
1049
  @ui&.show_tool_call(call[:name], call[:arguments])
1038
1050
 
1039
1051
  if @config.permission_mode == :auto_approve
1040
- # auto_approve means no human is watching (unattended/scheduled tasks).
1041
- # Inject an auto_reply so the LLM makes a reasonable decision and keeps going.
1042
- result = result.merge(
1043
- auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1044
- )
1052
+ # auto_approve means the agent runs unattended by default, but a
1053
+ # human MAY be watching the terminal. Show a short interactive
1054
+ # countdown: if the user steps in, hand control over and wait for
1055
+ # their answer; otherwise auto-decide and keep going.
1056
+ countdown = @ui&.request_feedback_with_countdown(seconds: 10)
1057
+
1058
+ if @ui.nil? || countdown == :timeout
1059
+ result = result.merge(
1060
+ auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1061
+ )
1062
+ elsif countdown.is_a?(String) && !countdown.strip.empty?
1063
+ # User stepped in and typed an answer right away. Route it through
1064
+ # the denied+feedback path so the agent responds to it immediately
1065
+ # instead of breaking and forcing the user to re-type.
1066
+ denied = true
1067
+ feedback = countdown
1068
+ else
1069
+ # User stepped in but gave no text — hand control back to the CLI.
1070
+ awaiting_feedback = true
1071
+ end
1045
1072
  else
1046
1073
  # confirm_all / confirm_safes — a human is present, truly wait for user input.
1047
1074
  awaiting_feedback = true
@@ -1288,6 +1315,41 @@ module Clacky
1288
1315
  @tool_registry.register(Tools::Browser.new)
1289
1316
  end
1290
1317
 
1318
+ # Run a one-off task on a forked subagent and return its final reply text,
1319
+ # WITHOUT mutating this (parent) agent's history. Used by extensions that
1320
+ # need a side analysis (e.g. meeting annotate) which must reuse the parent's
1321
+ # cached context + unified billing, but must NOT pollute the main conversation.
1322
+ #
1323
+ # The subagent deep-clones the parent history (cache prefix + task state), runs
1324
+ # to completion, and is discarded. Only the cost is merged back into the parent.
1325
+ #
1326
+ # @param task [String] The task/prompt for the subagent
1327
+ # @param model [String, nil] Model name ("lite" for the lite companion, nil = current)
1328
+ # @param forbidden_tools [Array<String>] Tool names to block at runtime
1329
+ # @return [String] Subagent's final assistant reply (empty string if none)
1330
+ def run_detached(task, model: nil, forbidden_tools: [])
1331
+ subagent = fork_subagent(
1332
+ model: model,
1333
+ forbidden_tools: forbidden_tools,
1334
+ system_prompt_suffix: "You are running a one-off background analysis. Do the task and return only the requested output. Do not ask follow-up questions."
1335
+ )
1336
+ # Detached runs must stay invisible: a real UI (e.g. WebUIController bound
1337
+ # to the parent's session_id) would broadcast the subagent's raw output
1338
+ # into the parent chat transcript. Swap in a no-op UI so nothing leaks.
1339
+ subagent.instance_variable_set(:@ui, NullUIController.new)
1340
+ parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1341
+ result = subagent.run(task)
1342
+
1343
+ @total_cost += result[:total_cost_usd] || 0.0
1344
+
1345
+ new_messages = subagent.history.to_a[parent_count..] || []
1346
+ new_messages
1347
+ .reverse
1348
+ .find { |m| m[:role] == "assistant" && m[:content] && !m[:content].to_s.empty? }
1349
+ &.dig(:content)
1350
+ .to_s
1351
+ end
1352
+
1291
1353
  # Fork a subagent with specified configuration
1292
1354
  # The subagent inherits all messages and tools from parent agent
1293
1355
  # Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks
@@ -238,6 +238,87 @@ module Clacky
238
238
  @http_server&.instance_variable_get(:@agent_config)
239
239
  end
240
240
 
241
+ def registry
242
+ @http_server&.instance_variable_get(:@registry)
243
+ end
244
+
245
+ # Create a brand-new session and optionally kick off its first task.
246
+ # Returns the new session_id. When a prompt is given, the task is
247
+ # submitted immediately (the session starts running); display_message
248
+ # controls the user-facing bubble shown in place of the raw prompt.
249
+ def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
250
+ source: :manual, display_message: nil)
251
+ error!("server not ready", status: 503) unless @http_server
252
+
253
+ session_id = @http_server.send(
254
+ :build_session,
255
+ name: name,
256
+ working_dir: working_dir,
257
+ profile: profile,
258
+ source: source
259
+ )
260
+
261
+ submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
262
+
263
+ session_id
264
+ end
265
+
266
+ # Submit a prompt to an existing session for execution.
267
+ # The session must be idle; returns the session_id on success.
268
+ # Raises Halt (409) if the session is already running.
269
+ def submit_task(session_id, prompt, display_message: nil)
270
+ reg = registry
271
+ error!("server not ready", status: 503) unless reg
272
+
273
+ unless reg.exist?(session_id)
274
+ reg.ensure(session_id)
275
+ error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
276
+ end
277
+
278
+ session = reg.get(session_id)
279
+ error!("session is busy", status: 409) if session[:status] == :running
280
+
281
+ @http_server.send(:run_session_task, session_id, prompt, display_message: display_message)
282
+ session_id
283
+ end
284
+
285
+ # Run a one-off side task on an existing session's agent and return its
286
+ # reply text SYNCHRONOUSLY, without polluting the main conversation.
287
+ #
288
+ # Unlike submit_task (which enqueues a turn into the live conversation and
289
+ # returns immediately), this forks the session's agent — reusing its cached
290
+ # context and unified billing — runs the task to completion on the fork, and
291
+ # returns the fork's final reply. The main conversation is never touched.
292
+ #
293
+ # Strategy A (parent-busy → skip): if the session is currently running, or the
294
+ # server is at its concurrency limit, this returns { busy: true } without
295
+ # running. Callers (e.g. periodic analysis) should treat that as "try later".
296
+ #
297
+ # @param session_id [String]
298
+ # @param prompt [String]
299
+ # @param model [String, nil] "lite" for the lite companion, nil = current
300
+ # @param forbidden_tools [Array<String>] tool names blocked in the fork
301
+ # @return [Hash] { text: "..." } on success, or { busy: true } when skipped
302
+ def dispatch_to_session(session_id, prompt, model: nil, forbidden_tools: [])
303
+ reg = registry
304
+ error!("server not ready", status: 503) unless reg
305
+
306
+ unless reg.exist?(session_id)
307
+ reg.ensure(session_id)
308
+ error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
309
+ end
310
+
311
+ return { busy: true } if reg.respond_to?(:running_full?) && reg.running_full?
312
+
313
+ session = reg.get(session_id)
314
+ return { busy: true } if session[:status] == :running
315
+
316
+ agent = session[:agent]
317
+ error!("session agent not available", status: 503) unless agent
318
+
319
+ { text: agent.run_detached(prompt, model: model, forbidden_tools: forbidden_tools) }
320
+ end
321
+
241
322
  def server_start_time
242
323
  @http_server&.instance_variable_get(:@start_time)
243
324
  end
@@ -12,15 +12,27 @@ module Clacky
12
12
  # isolated: skipped with a logged warning, never aborts the load of others.
13
13
  module ApiExtensionLoader
14
14
  DEFAULT_DIR = File.expand_path("~/.clacky/api_ext")
15
+ BUILTIN_DIR = File.expand_path("../default_extensions", __FILE__)
15
16
  DISABLED_DIR = "_disabled"
16
17
 
17
18
  Result = Struct.new(:loaded, :skipped, keyword_init: true)
18
19
 
19
20
  class << self
20
- def load_all(dir: DEFAULT_DIR)
21
+ def load_all(dir: DEFAULT_DIR, builtin: true)
21
22
  result = Result.new(loaded: [], skipped: [])
22
23
  Clacky::ApiExtension.reset_registry!
23
24
 
25
+ # Load built-in (gem-shipped) extensions first (lowest priority)
26
+ if builtin && Dir.exist?(BUILTIN_DIR)
27
+ Dir.glob(File.join(BUILTIN_DIR, "*", "handler.rb")).sort.each do |handler_path|
28
+ ext_dir = File.dirname(handler_path)
29
+ ext_id = File.basename(ext_dir)
30
+ next if ext_id.start_with?("_")
31
+ load_one(ext_id, ext_dir, handler_path, result)
32
+ end
33
+ end
34
+
35
+ # Load user extensions (higher priority — same ext_id overwrites built-in)
24
36
  if Dir.exist?(dir)
25
37
  Dir.glob(File.join(dir, "*", "handler.rb")).sort.each do |handler_path|
26
38
  ext_dir = File.dirname(handler_path)