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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +74 -12
- data/lib/clacky/api_extension.rb +81 -0
- data/lib/clacky/api_extension_loader.rb +13 -1
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/client.rb +14 -17
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +260 -122
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +140 -40
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +199 -23
- data/lib/clacky/web/components/onboard.js +1 -14
- data/lib/clacky/web/components/sidebar.js +1 -3
- data/lib/clacky/web/features/backup/store.js +23 -0
- data/lib/clacky/web/features/backup/view.js +49 -22
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +6 -21
- data/lib/clacky/web/features/tasks/view.js +77 -28
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/i18n.js +46 -3
- data/lib/clacky/web/index.html +67 -26
- data/lib/clacky/web/sessions.js +177 -87
- data/lib/clacky/web/settings.js +34 -2
- data/lib/clacky/web/ws-dispatcher.js +12 -4
- data/lib/clacky.rb +12 -5
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45a64960de249e34ee18e67f0ce38888b510a886988d545c58aca558d942bbdb
|
|
4
|
+
data.tar.gz: '008d7b7bec8fd7edf43cb9848949637d23783d2ad3a8a2b6199010f9b8ed660d'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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>
|
|
175
|
-
|
|
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(
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 =
|
|
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]
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
|
1041
|
-
#
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
data/lib/clacky/api_extension.rb
CHANGED
|
@@ -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)
|