openclacky 0.9.7 → 0.9.9
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/.clacky/skills/gem-release/SKILL.md +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- data/scripts/install_agent_browser.sh +0 -67
|
@@ -51,16 +51,21 @@ module Clacky
|
|
|
51
51
|
skill_type: "subagent"
|
|
52
52
|
}
|
|
53
53
|
else
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
54
|
+
# Deferred injection path: enqueue the skill inject on the agent.
|
|
55
|
+
#
|
|
56
|
+
# Injecting inside execute() would produce an illegal message ordering for Bedrock:
|
|
57
|
+
# assistant: {toolUse: invoke_skill}
|
|
58
|
+
# assistant: {text: skill_instructions} ← injected here (breaks pairing)
|
|
59
|
+
# user: {toolResult: invoke_skill} ← observe() appends this too late
|
|
60
|
+
#
|
|
61
|
+
# Instead, enqueue the injection so the agent loop can flush it AFTER observe()
|
|
62
|
+
# appends the toolResult, producing the correct sequence:
|
|
63
|
+
# assistant: {toolUse: invoke_skill}
|
|
64
|
+
# user: {toolResult: ...} ← observe() appends first
|
|
65
|
+
# assistant: {text: skill_instructions} ← flush_pending_injections runs here
|
|
66
|
+
# user: "[SYSTEM] please proceed"
|
|
67
|
+
agent.enqueue_injection(skill, task)
|
|
68
|
+
"Skill '#{skill_name}' instructions expanded. Proceed to execute the task."
|
|
64
69
|
end
|
|
65
70
|
end
|
|
66
71
|
|
|
@@ -76,7 +81,9 @@ module Clacky
|
|
|
76
81
|
# @param result [Hash] Tool execution result
|
|
77
82
|
# @return [String] Formatted result summary
|
|
78
83
|
def format_result(result)
|
|
79
|
-
if result
|
|
84
|
+
if result.is_a?(String)
|
|
85
|
+
result
|
|
86
|
+
elsif result[:error]
|
|
80
87
|
"Error: #{result[:error]}"
|
|
81
88
|
elsif result[:skill_type] == "subagent"
|
|
82
89
|
"Subagent executed successfully"
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -339,10 +339,13 @@ module Clacky
|
|
|
339
339
|
}
|
|
340
340
|
end
|
|
341
341
|
|
|
342
|
-
# Truncate output to max_lines, adding a truncation notice if needed
|
|
342
|
+
# Truncate output to max_lines and max line length, adding a truncation notice if needed
|
|
343
343
|
def truncate_output(output, max_lines)
|
|
344
344
|
return output if output.nil? || output.empty?
|
|
345
345
|
|
|
346
|
+
# First truncate individual long lines (e.g. minified JS/CSS, expanded JSON)
|
|
347
|
+
output = truncate_long_lines(output, MAX_LINE_CHARS)
|
|
348
|
+
|
|
346
349
|
lines = output.lines
|
|
347
350
|
return output if lines.length <= max_lines
|
|
348
351
|
|
|
@@ -390,13 +393,18 @@ module Clacky
|
|
|
390
393
|
# Return error info as-is if command failed or timed out
|
|
391
394
|
return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
|
|
392
395
|
|
|
393
|
-
|
|
394
|
-
stderr
|
|
396
|
+
# Ensure all string fields are valid UTF-8 before JSON serialization.
|
|
397
|
+
# stdout/stderr are already scrubbed by EncodingSafeBuffer, but :command
|
|
398
|
+
# (and any other string field) may still carry ASCII-8BIT encoding when
|
|
399
|
+
# the caller built the command from binary paths or ENV values.
|
|
400
|
+
enc = Clacky::Utils::Encoding
|
|
401
|
+
stdout = enc.to_utf8(result[:stdout] || "")
|
|
402
|
+
stderr = enc.to_utf8(result[:stderr] || "")
|
|
395
403
|
exit_code = result[:exit_code] || 0
|
|
396
404
|
|
|
397
405
|
# Build compact result with truncated output
|
|
398
406
|
compact = {
|
|
399
|
-
command: result[:command],
|
|
407
|
+
command: enc.to_utf8(result[:command].to_s),
|
|
400
408
|
exit_code: exit_code,
|
|
401
409
|
success: result[:success]
|
|
402
410
|
}
|
|
@@ -405,7 +413,7 @@ module Clacky
|
|
|
405
413
|
compact[:elapsed] = result[:elapsed] if result[:elapsed]
|
|
406
414
|
|
|
407
415
|
# Extract command name for temp file naming
|
|
408
|
-
command_name = extract_command_name(
|
|
416
|
+
command_name = extract_command_name(compact[:command])
|
|
409
417
|
|
|
410
418
|
# Process stdout: truncate and optionally save to temp file
|
|
411
419
|
stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
|
|
@@ -433,8 +441,9 @@ module Clacky
|
|
|
433
441
|
def truncate_and_save(output, max_chars, label, command_name)
|
|
434
442
|
return { content: "", temp_file: nil } if output.empty?
|
|
435
443
|
|
|
436
|
-
# Truncate individual long lines
|
|
437
|
-
#
|
|
444
|
+
# Truncate individual long lines (e.g., minified CSS/JS files)
|
|
445
|
+
# truncate_output already does this, but truncate_and_save may be called
|
|
446
|
+
# with raw output that hasn't gone through truncate_output
|
|
438
447
|
output = truncate_long_lines(output, MAX_LINE_CHARS)
|
|
439
448
|
|
|
440
449
|
return { content: output, temp_file: nil } if output.length <= max_chars
|
|
@@ -119,7 +119,9 @@ module Clacky
|
|
|
119
119
|
raise "Too many redirects" if redirects > max_redirects
|
|
120
120
|
|
|
121
121
|
location = response["location"]
|
|
122
|
-
|
|
122
|
+
new_uri = URI.parse(location)
|
|
123
|
+
# Handle relative redirects by merging with the current URI
|
|
124
|
+
uri = new_uri.relative? ? uri.merge(new_uri) : new_uri
|
|
123
125
|
else
|
|
124
126
|
raise "HTTP error: #{response.code} #{response.message}"
|
|
125
127
|
end
|
data/lib/clacky/ui2/README.md
CHANGED
|
@@ -68,7 +68,7 @@ The Agent calls these methods directly:
|
|
|
68
68
|
```ruby
|
|
69
69
|
# Show messages
|
|
70
70
|
@ui.show_assistant_message("Hello!")
|
|
71
|
-
@ui.show_user_message("Hi there",
|
|
71
|
+
@ui.show_user_message("Hi there", files: [])
|
|
72
72
|
|
|
73
73
|
# Show tool operations
|
|
74
74
|
@ui.show_tool_call("file_reader", { path: "test.rb" })
|
|
@@ -292,7 +292,9 @@ module Clacky
|
|
|
292
292
|
@files.each_with_index do |f, idx|
|
|
293
293
|
move_cursor(current_row, 0)
|
|
294
294
|
filename = f[:name] || f["name"] || "file"
|
|
295
|
-
|
|
295
|
+
size = f[:size] || f["size"]
|
|
296
|
+
size_str = size ? " #{format_filesize(size)}" : ""
|
|
297
|
+
content = @pastel.dim("[File #{idx + 1}] #{filename}#{size_str} (Ctrl+D to delete)")
|
|
296
298
|
print_with_padding(content)
|
|
297
299
|
current_row += 1
|
|
298
300
|
end
|
|
@@ -881,7 +883,8 @@ module Clacky
|
|
|
881
883
|
if pasted[:type] == :image
|
|
882
884
|
path = pasted[:path]
|
|
883
885
|
mime_type = pasted[:mime_type] || "image/png"
|
|
884
|
-
|
|
886
|
+
size = File.exist?(path) ? File.size(path) : 0
|
|
887
|
+
@files << { name: File.basename(path), mime_type: mime_type, path: path, size: size }
|
|
885
888
|
clear_tips
|
|
886
889
|
else
|
|
887
890
|
insert_text(pasted[:text])
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "pastel"
|
|
4
4
|
require_relative "../../version"
|
|
5
5
|
require_relative "../block_font"
|
|
6
|
+
require_relative "../../utils/workspace_rules"
|
|
6
7
|
|
|
7
8
|
module Clacky
|
|
8
9
|
module UI2
|
|
@@ -85,10 +86,32 @@ module Clacky
|
|
|
85
86
|
lines << ""
|
|
86
87
|
lines << info_line("Working Directory", working_dir)
|
|
87
88
|
lines << info_line("Permission Mode", mode)
|
|
89
|
+
|
|
90
|
+
# Show loaded project rules file if present
|
|
91
|
+
main = Utils::WorkspaceRules.find_main(working_dir)
|
|
92
|
+
lines << info_line("Project Rules", "#{main[:name]} ✓") if main
|
|
93
|
+
|
|
88
94
|
lines << ""
|
|
89
95
|
lines << theme.format_text("[!] Type 'exit' or 'quit' to terminate session", :thinking)
|
|
90
96
|
lines << separator("-")
|
|
91
97
|
lines << ""
|
|
98
|
+
|
|
99
|
+
# Show sub-project agents block if any sub-dirs have .clackyrules
|
|
100
|
+
sub_projects = Utils::WorkspaceRules.find_sub_projects(working_dir)
|
|
101
|
+
unless sub_projects.empty?
|
|
102
|
+
lines << @pastel.bright_cyan("[>] SUB-PROJECT AGENT MODE")
|
|
103
|
+
lines << @pastel.dim(" #{sub_projects.size} sub-project(s) detected with rules:")
|
|
104
|
+
sub_projects.each do |sp|
|
|
105
|
+
first_line = sp[:summary].lines.first&.strip&.delete_prefix("#")&.strip
|
|
106
|
+
label = @pastel.cyan(" • #{sp[:sub_name]}/")
|
|
107
|
+
desc = first_line && !first_line.empty? ? @pastel.dim(" — #{first_line}") : ""
|
|
108
|
+
lines << "#{label}#{desc}"
|
|
109
|
+
end
|
|
110
|
+
lines << @pastel.dim(" AI will read each sub-project's full .clackyrules before working in it.")
|
|
111
|
+
lines << separator("-")
|
|
112
|
+
lines << ""
|
|
113
|
+
end
|
|
114
|
+
|
|
92
115
|
lines.join("\n")
|
|
93
116
|
end
|
|
94
117
|
|
|
@@ -290,9 +290,15 @@ module Clacky
|
|
|
290
290
|
token_info << pastel.dim("Total: #{token_data[:total_tokens]}")
|
|
291
291
|
|
|
292
292
|
# Cost for this iteration with color coding (red/yellow for high cost, dim for normal)
|
|
293
|
-
|
|
293
|
+
# :api => "$0.001234" (exact, from API)
|
|
294
|
+
# :price => "~$0.001234" (estimated from pricing table)
|
|
295
|
+
# :default => "N/A" (model not in pricing table, unknown cost)
|
|
296
|
+
cost_source = token_data[:cost_source]
|
|
297
|
+
if cost_source == :default
|
|
298
|
+
token_info << pastel.dim("Cost: N/A")
|
|
299
|
+
elsif token_data[:cost]
|
|
294
300
|
cost = token_data[:cost]
|
|
295
|
-
cost_value = "$#{cost.round(6)}"
|
|
301
|
+
cost_value = cost_source == :price ? "~$#{cost.round(6)}" : "$#{cost.round(6)}"
|
|
296
302
|
if cost >= 0.1
|
|
297
303
|
# High cost - red warning
|
|
298
304
|
colored_cost = pastel.decorate(cost_value, :red, :dim)
|
|
@@ -585,6 +591,14 @@ module Clacky
|
|
|
585
591
|
append_output(output)
|
|
586
592
|
end
|
|
587
593
|
|
|
594
|
+
# Show idle compression status (two-phase: start → end).
|
|
595
|
+
# In terminal mode, only the final state is printed.
|
|
596
|
+
def show_idle_status(phase:, message:)
|
|
597
|
+
return unless phase.to_s == "end"
|
|
598
|
+
output = @renderer.render_system_message(message)
|
|
599
|
+
append_output(output)
|
|
600
|
+
end
|
|
601
|
+
|
|
588
602
|
# Show warning message
|
|
589
603
|
# @param message [String] Warning message
|
|
590
604
|
def show_warning(message)
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -38,6 +38,12 @@ module Clacky
|
|
|
38
38
|
def set_working_status; end
|
|
39
39
|
def set_idle_status; end
|
|
40
40
|
|
|
41
|
+
# === Idle compression status ===
|
|
42
|
+
# Emits a two-phase idle compression status update.
|
|
43
|
+
# phase: :start → show "Idle detected. Compressing..." (with spinner)
|
|
44
|
+
# phase: :end → update same element with final result (skipped / compressed)
|
|
45
|
+
def show_idle_status(phase:, message:); end
|
|
46
|
+
|
|
41
47
|
# === Blocking interaction ===
|
|
42
48
|
def request_confirmation(message, default: true); end
|
|
43
49
|
|
|
@@ -45,6 +51,7 @@ module Clacky
|
|
|
45
51
|
def clear_input; end
|
|
46
52
|
def set_input_tips(message, type: :info); end
|
|
47
53
|
|
|
54
|
+
# === Path redaction (for encrypted brand skill tmpdirs) ===
|
|
48
55
|
# === Lifecycle ===
|
|
49
56
|
def stop; end
|
|
50
57
|
end
|
|
@@ -5,29 +5,23 @@ require "fileutils"
|
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "stringio"
|
|
7
7
|
|
|
8
|
-
require_relative "
|
|
9
|
-
|
|
10
|
-
require_relative "file_parser/pptx_parser"
|
|
11
|
-
require_relative "file_parser/zip_parser"
|
|
8
|
+
require_relative "parser_manager"
|
|
9
|
+
require "zip"
|
|
12
10
|
|
|
13
11
|
module Clacky
|
|
14
12
|
module Utils
|
|
15
|
-
#
|
|
13
|
+
# File processing pipeline.
|
|
16
14
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
15
|
+
# Two entry points:
|
|
16
|
+
# FileProcessor.save(body:, filename:)
|
|
17
|
+
# → Store raw bytes to disk only. Returns { name:, path: }.
|
|
18
|
+
# Used by http_server and channel adapters — no parsing here.
|
|
21
19
|
#
|
|
22
|
-
#
|
|
20
|
+
# FileProcessor.process_path(path, name: nil)
|
|
21
|
+
# → Parse an already-saved file. Returns FileRef (with preview_path or parse_error).
|
|
22
|
+
# Used by agent.run when building the file prompt.
|
|
23
23
|
#
|
|
24
|
-
#
|
|
25
|
-
# Type: document
|
|
26
|
-
# Original: /tmp/clacky-uploads/abc123.docx
|
|
27
|
-
# Preview (Markdown): /tmp/clacky-uploads/abc123.docx.preview.md
|
|
28
|
-
#
|
|
29
|
-
# This gives the LLM structure-aware content immediately while keeping the
|
|
30
|
-
# original available for deeper processing via shell tools.
|
|
24
|
+
# (FileProcessor.process = save + process_path in one call, for convenience.)
|
|
31
25
|
module FileProcessor
|
|
32
26
|
UPLOAD_DIR = File.join(Dir.tmpdir, "clacky-uploads").freeze
|
|
33
27
|
MAX_FILE_BYTES = 32 * 1024 * 1024 # 32 MB
|
|
@@ -46,12 +40,10 @@ module Clacky
|
|
|
46
40
|
.db .sqlite .bin .dat
|
|
47
41
|
].freeze
|
|
48
42
|
|
|
49
|
-
# Binary files that glob should still return (useful as file references even if unreadable)
|
|
50
43
|
GLOB_ALLOWED_BINARY_EXTENSIONS = %w[
|
|
51
44
|
.pdf .doc .docx .ppt .pptx .xls .xlsx .odt .odp .ods
|
|
52
45
|
].freeze
|
|
53
46
|
|
|
54
|
-
# Extensions that can be sent to LLM as base64 (images + PDF)
|
|
55
47
|
LLM_BINARY_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .pdf].freeze
|
|
56
48
|
|
|
57
49
|
MIME_TYPES = {
|
|
@@ -63,175 +55,130 @@ module Clacky
|
|
|
63
55
|
".pdf" => "application/pdf"
|
|
64
56
|
}.freeze
|
|
65
57
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
58
|
+
FILE_TYPES = {
|
|
59
|
+
".docx" => :document, ".doc" => :document,
|
|
60
|
+
".xlsx" => :spreadsheet, ".xls" => :spreadsheet,
|
|
61
|
+
".pptx" => :presentation, ".ppt" => :presentation,
|
|
62
|
+
".pdf" => :pdf,
|
|
63
|
+
".zip" => :zip, ".gz" => :zip, ".tar" => :zip, ".rar" => :zip, ".7z" => :zip,
|
|
64
|
+
".png" => :image, ".jpg" => :image, ".jpeg" => :image,
|
|
65
|
+
".gif" => :image, ".webp" => :image
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
# FileRef: result of process / process_path.
|
|
69
|
+
FileRef = Struct.new(:name, :type, :original_path, :preview_path, :parse_error, :parser_path, keyword_init: true) do
|
|
70
|
+
def parse_failed?
|
|
71
|
+
preview_path.nil? && !parse_error.nil?
|
|
74
72
|
end
|
|
75
73
|
end
|
|
76
74
|
|
|
77
|
-
#
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Public API
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# Store raw bytes to disk — no parsing.
|
|
80
|
+
# Used by http_server upload endpoint and channel adapters.
|
|
78
81
|
#
|
|
79
|
-
# @
|
|
80
|
-
|
|
81
|
-
# @return [FileRef]
|
|
82
|
-
def self.process(body:, filename:)
|
|
82
|
+
# @return [Hash] { name: String, path: String }
|
|
83
|
+
def self.save(body:, filename:)
|
|
83
84
|
FileUtils.mkdir_p(UPLOAD_DIR)
|
|
84
|
-
|
|
85
|
-
ext = File.extname(filename.to_s).downcase
|
|
86
85
|
safe_name = sanitize_filename(filename)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
process_office(body, file_id, safe_name, :document) { FileParser::DocxParser.parse(body) }
|
|
92
|
-
|
|
93
|
-
when ".xlsx", ".xls"
|
|
94
|
-
process_office(body, file_id, safe_name, :spreadsheet) { FileParser::XlsxParser.parse(body) }
|
|
86
|
+
dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}")
|
|
87
|
+
File.binwrite(dest, body)
|
|
88
|
+
{ name: safe_name, path: dest }
|
|
89
|
+
end
|
|
95
90
|
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
# Parse an already-saved file and return a FileRef.
|
|
92
|
+
# Called by agent.run for each disk file before building the prompt.
|
|
93
|
+
#
|
|
94
|
+
# @param path [String] Path to the file on disk
|
|
95
|
+
# @param name [String] Display name (defaults to basename)
|
|
96
|
+
# @return [FileRef]
|
|
97
|
+
def self.process_path(path, name: nil)
|
|
98
|
+
name ||= File.basename(path.to_s)
|
|
99
|
+
ext = File.extname(path.to_s).downcase
|
|
100
|
+
type = FILE_TYPES[ext] || :file
|
|
98
101
|
|
|
102
|
+
case ext
|
|
99
103
|
when ".zip"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
body = File.binread(path)
|
|
105
|
+
preview_content = parse_zip_listing(body)
|
|
106
|
+
preview_path = save_preview(preview_content, path)
|
|
107
|
+
FileRef.new(name: name, type: :zip, original_path: path, preview_path: preview_path)
|
|
104
108
|
|
|
105
109
|
when ".png", ".jpg", ".jpeg", ".gif", ".webp"
|
|
106
|
-
|
|
110
|
+
FileRef.new(name: name, type: :image, original_path: path)
|
|
107
111
|
|
|
108
112
|
else
|
|
109
|
-
|
|
113
|
+
result = Utils::ParserManager.parse(path)
|
|
114
|
+
if result[:success]
|
|
115
|
+
preview_path = save_preview(result[:text], path)
|
|
116
|
+
FileRef.new(name: name, type: type, original_path: path, preview_path: preview_path)
|
|
117
|
+
else
|
|
118
|
+
FileRef.new(name: name, type: type, original_path: path,
|
|
119
|
+
parse_error: result[:error], parser_path: result[:parser_path])
|
|
120
|
+
end
|
|
110
121
|
end
|
|
111
122
|
end
|
|
112
123
|
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
def self.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
preview_content = yield
|
|
120
|
-
preview_path = save_preview(preview_content, file_id, safe_name)
|
|
121
|
-
|
|
122
|
-
FileRef.new(name: safe_name, type: type, original_path: original_path, preview_path: preview_path)
|
|
123
|
-
rescue => e
|
|
124
|
-
# If preview generation fails, still return the original path
|
|
125
|
-
FileRef.new(name: safe_name, type: type, original_path: original_path,
|
|
126
|
-
preview_path: nil)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# ZIP: save original + generate directory listing as preview.
|
|
130
|
-
def self.process_zip(body, file_id, safe_name)
|
|
131
|
-
original_path = save_original(body, file_id, safe_name)
|
|
132
|
-
preview_content = FileParser::ZipParser.parse(body)
|
|
133
|
-
preview_path = save_preview(preview_content, file_id, safe_name)
|
|
134
|
-
|
|
135
|
-
FileRef.new(name: safe_name, type: :zip, original_path: original_path, preview_path: preview_path)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# Binary files (PDF, images, unknown): save original only.
|
|
139
|
-
def self.process_binary(body, file_id, safe_name, type)
|
|
140
|
-
original_path = save_original(body, file_id, safe_name)
|
|
141
|
-
FileRef.new(name: safe_name, type: type, original_path: original_path, preview_path: nil)
|
|
124
|
+
# Save + parse in one call (convenience method).
|
|
125
|
+
#
|
|
126
|
+
# @return [FileRef]
|
|
127
|
+
def self.process(body:, filename:)
|
|
128
|
+
saved = save(body: body, filename: filename)
|
|
129
|
+
process_path(saved[:path], name: saved[:name])
|
|
142
130
|
end
|
|
143
131
|
|
|
144
|
-
|
|
145
|
-
|
|
132
|
+
# Save raw image bytes to disk and return a FileRef.
|
|
133
|
+
# Used by agent when an image exceeds MAX_IMAGE_BYTES and must be downgraded to disk.
|
|
134
|
+
def self.save_image_to_disk(body:, mime_type:, filename: "image.jpg")
|
|
135
|
+
FileUtils.mkdir_p(UPLOAD_DIR)
|
|
136
|
+
safe_name = sanitize_filename(filename)
|
|
137
|
+
dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}")
|
|
146
138
|
File.binwrite(dest, body)
|
|
147
|
-
dest
|
|
139
|
+
FileRef.new(name: safe_name, type: :image, original_path: dest)
|
|
148
140
|
end
|
|
149
141
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
dest
|
|
154
|
-
end
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# File type helpers (used by tools and agent)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
155
145
|
|
|
156
|
-
def self.sanitize_filename(name)
|
|
157
|
-
base = File.basename(name.to_s).gsub(/[^\w.\-]/, "_")
|
|
158
|
-
base.empty? ? "upload" : base
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Returns true if the file is binary (non-text).
|
|
162
146
|
def self.binary_file_path?(path)
|
|
163
147
|
ext = File.extname(path).downcase
|
|
164
148
|
return true if BINARY_EXTENSIONS.include?(ext)
|
|
165
|
-
|
|
166
|
-
# Fallback: sniff first 512 bytes for null bytes
|
|
167
|
-
sample = File.binread(path, 512).to_s
|
|
168
|
-
sample.include?("\x00")
|
|
149
|
+
File.binread(path, 512).to_s.include?("\x00")
|
|
169
150
|
rescue
|
|
170
151
|
false
|
|
171
152
|
end
|
|
172
153
|
|
|
173
|
-
# Returns true if the file is binary but should still appear in glob results.
|
|
174
|
-
# (e.g. PDF, Office docs — useful as file references even if content is unreadable)
|
|
175
154
|
def self.glob_allowed_binary?(path)
|
|
176
|
-
|
|
177
|
-
GLOB_ALLOWED_BINARY_EXTENSIONS.include?(ext)
|
|
155
|
+
GLOB_ALLOWED_BINARY_EXTENSIONS.include?(File.extname(path).downcase)
|
|
178
156
|
end
|
|
179
157
|
|
|
180
|
-
# Returns true if the binary file can be sent to LLM as base64.
|
|
181
158
|
def self.supported_binary_file?(path)
|
|
182
|
-
|
|
183
|
-
LLM_BINARY_EXTENSIONS.include?(ext)
|
|
159
|
+
LLM_BINARY_EXTENSIONS.include?(File.extname(path).downcase)
|
|
184
160
|
end
|
|
185
161
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# @param body [String] Raw image bytes
|
|
189
|
-
# @param mime_type [String] e.g. "image/jpeg"
|
|
190
|
-
# @param filename [String] Suggested filename
|
|
191
|
-
# @return [FileRef]
|
|
192
|
-
def self.save_image_to_disk(body:, mime_type:, filename: "image.jpg")
|
|
193
|
-
FileUtils.mkdir_p(UPLOAD_DIR)
|
|
194
|
-
ext = File.extname(filename).downcase
|
|
195
|
-
ext = ".#{mime_type.split('/').last}" if ext.empty? && mime_type.to_s.start_with?("image/")
|
|
196
|
-
ext = ".jpg" if ext.empty?
|
|
197
|
-
safe_name = sanitize_filename(filename)
|
|
198
|
-
file_id = SecureRandom.hex(8)
|
|
199
|
-
process_binary(body, file_id, safe_name, :image)
|
|
162
|
+
def self.detect_mime_type(path, _data = nil)
|
|
163
|
+
MIME_TYPES[File.extname(path).downcase] || "application/octet-stream"
|
|
200
164
|
end
|
|
201
165
|
|
|
202
|
-
# Convert a binary file (image/PDF) to base64 for LLM consumption.
|
|
203
|
-
# @return [Hash] { format:, mime_type:, size_bytes:, base64_data: }
|
|
204
166
|
def self.file_to_base64(path)
|
|
205
167
|
require "base64"
|
|
206
168
|
ext = File.extname(path).downcase
|
|
207
169
|
size = File.size(path)
|
|
208
|
-
|
|
209
170
|
raise ArgumentError, "File too large: #{path}" if size > MAX_FILE_BYTES
|
|
210
|
-
|
|
211
171
|
mime = MIME_TYPES[ext] || "application/octet-stream"
|
|
212
172
|
data = Base64.strict_encode64(File.binread(path))
|
|
213
|
-
|
|
214
173
|
{ format: ext[1..], mime_type: mime, size_bytes: size, base64_data: data }
|
|
215
174
|
end
|
|
216
175
|
|
|
217
|
-
# Detect MIME type from file extension (and optionally data bytes).
|
|
218
|
-
def self.detect_mime_type(path, _data = nil)
|
|
219
|
-
MIME_TYPES[File.extname(path).downcase] || "application/octet-stream"
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
# Convert a local image file path to a data: URL for vision APIs.
|
|
223
|
-
# Used by CLI --image flag path → agent format_user_content.
|
|
224
|
-
#
|
|
225
|
-
# @param path [String] Local image file path
|
|
226
|
-
# @return [String] "data:<mime>;base64,<encoded>"
|
|
227
176
|
def self.image_path_to_data_url(path)
|
|
228
177
|
raise ArgumentError, "Image file not found: #{path}" unless File.exist?(path)
|
|
229
|
-
|
|
230
178
|
size = File.size(path)
|
|
231
179
|
if size > MAX_IMAGE_BYTES
|
|
232
|
-
raise ArgumentError, "Image too large (#{size / 1024
|
|
180
|
+
raise ArgumentError, "Image too large (#{size / 1024}KB > #{MAX_IMAGE_BYTES / 1024}KB): #{path}"
|
|
233
181
|
end
|
|
234
|
-
|
|
235
182
|
require "base64"
|
|
236
183
|
ext = File.extname(path).downcase.delete(".")
|
|
237
184
|
mime = case ext
|
|
@@ -241,12 +188,42 @@ module Clacky
|
|
|
241
188
|
when "webp" then "image/webp"
|
|
242
189
|
else "image/#{ext}"
|
|
243
190
|
end
|
|
244
|
-
|
|
245
191
|
"data:#{mime};base64,#{Base64.strict_encode64(File.binread(path))}"
|
|
246
192
|
end
|
|
247
193
|
|
|
248
|
-
|
|
249
|
-
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Private helpers
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def self.parse_zip_listing(body)
|
|
199
|
+
lines = ["# ZIP Contents\n"]
|
|
200
|
+
Zip::InputStream.open(StringIO.new(body)) do |zis|
|
|
201
|
+
while (entry = zis.get_next_entry)
|
|
202
|
+
size = entry.size ? " (#{entry.size} bytes)" : ""
|
|
203
|
+
lines << "- #{entry.name}#{size}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
lines.join("\n")
|
|
207
|
+
rescue => e
|
|
208
|
+
"# ZIP Contents\n(could not list entries: #{e.message})"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.save_preview(content, original_path)
|
|
212
|
+
dest = "#{original_path}.preview.md"
|
|
213
|
+
File.write(dest, content, encoding: "UTF-8")
|
|
214
|
+
dest
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def self.sanitize_filename(name)
|
|
218
|
+
# Keep Unicode letters/digits (including CJK), ASCII word chars, dots, hyphens, spaces.
|
|
219
|
+
# Only strip characters that are unsafe on common filesystems: / \ : * ? " < > | \0
|
|
220
|
+
base = File.basename(name.to_s)
|
|
221
|
+
.gsub(/[\/\\\:\*\?"<>|\x00]/, "_")
|
|
222
|
+
.strip
|
|
223
|
+
base.empty? ? "upload" : base
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private_class_method :parse_zip_listing, :save_preview, :sanitize_filename
|
|
250
227
|
end
|
|
251
228
|
end
|
|
252
229
|
end
|
|
@@ -224,13 +224,14 @@ module Clacky
|
|
|
224
224
|
return model if PRICING_TABLE.key?(model)
|
|
225
225
|
|
|
226
226
|
# Check for Claude model variations
|
|
227
|
-
# Support both dot and dash separators (e.g., "4.5"
|
|
227
|
+
# Support both dot and dash separators (e.g., "4.5", "4-5", "4-6")
|
|
228
|
+
# Also handles Bedrock cross-region prefixes (e.g. "jp.anthropic.claude-sonnet-4-6")
|
|
228
229
|
case model
|
|
229
|
-
when /claude.*opus.*4[.-]?
|
|
230
|
+
when /claude.*opus.*4[.-]?[56]/i
|
|
230
231
|
"claude-opus-4.5"
|
|
231
|
-
when /claude.*sonnet.*4[.-]?
|
|
232
|
+
when /claude.*sonnet.*4[.-]?[56]/i
|
|
232
233
|
"claude-sonnet-4.5"
|
|
233
|
-
when /claude.*haiku.*4[.-]?
|
|
234
|
+
when /claude.*haiku.*4[.-]?[56]/i
|
|
234
235
|
"claude-haiku-4.5"
|
|
235
236
|
when /claude-3-5-sonnet-20241022/i
|
|
236
237
|
"claude-3-5-sonnet-20241022"
|