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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. 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
- # Unified injection path: delegate to SkillManager#inject_skill_as_assistant_message.
55
- # This ensures brand skill transient handling, template expansion, and message
56
- # structure are identical to the slash command path single source of truth.
57
- task_id = agent.instance_variable_get(:@current_task_id)
58
- agent.send(:inject_skill_as_assistant_message, skill, task, task_id)
59
-
60
- {
61
- message: "Skill '#{skill_name}' instructions expanded. Proceed to execute the task.",
62
- skill_type: "inline"
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[:error]
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"
@@ -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
- stdout = result[:stdout] || ""
394
- stderr = result[: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(result[:command])
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 first (e.g., minified CSS/JS files)
437
- # This prevents a single huge line from consuming all token budget
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
- uri = URI.parse(location)
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
@@ -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", images: [])
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
- content = @pastel.dim("[File #{idx + 1}] #{filename} (Ctrl+D to delete)")
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
- @files << { name: File.basename(path), mime_type: mime_type, path: path }
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
- if token_data[:cost]
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)
@@ -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 "file_parser/docx_parser"
9
- require_relative "file_parser/xlsx_parser"
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
- # Unified file processing pipeline.
13
+ # File processing pipeline.
16
14
  #
17
- # For every uploaded file we:
18
- # 1. Save the original to disk (so the agent can always access the raw bytes).
19
- # 2. Generate a structured preview (Markdown) where possible.
20
- # 3. Return a FileRef struct describing both paths.
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
- # The agent prompt receives a concise block like:
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
- # [File: contract.docx]
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
- # Result struct returned by .process
67
- FileRef = Struct.new(:name, :type, :original_path, :preview_path, keyword_init: true) do
68
- # Returns a formatted string to inject into the agent prompt.
69
- def to_prompt
70
- lines = ["[File: #{name}]", "Type: #{type}"]
71
- lines << "Original: #{original_path}" if original_path
72
- lines << "Preview (Markdown): #{preview_path}" if preview_path
73
- lines.join("\n")
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
- # Process an uploaded file.
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
- # @param body [String] Raw file bytes
80
- # @param filename [String] Original filename (used for extension detection + display)
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
- file_id = SecureRandom.hex(8)
88
-
89
- case ext
90
- when ".docx", ".doc"
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
- when ".pptx", ".ppt"
97
- process_office(body, file_id, safe_name, :presentation) { FileParser::PptxParser.parse(body) }
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
- process_zip(body, file_id, safe_name)
101
-
102
- when ".pdf"
103
- process_binary(body, file_id, safe_name, :pdf)
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
- process_binary(body, file_id, safe_name, :image)
110
+ FileRef.new(name: name, type: :image, original_path: path)
107
111
 
108
112
  else
109
- process_binary(body, file_id, safe_name, :file)
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
- # --- private ---
114
-
115
- # Save original + generate markdown preview via the given block.
116
- def self.process_office(body, file_id, safe_name, type)
117
- original_path = save_original(body, file_id, safe_name)
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
- def self.save_original(body, file_id, safe_name)
145
- dest = File.join(UPLOAD_DIR, "#{file_id}_#{safe_name}")
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
- def self.save_preview(content, file_id, safe_name)
151
- dest = File.join(UPLOAD_DIR, "#{file_id}_#{safe_name}.preview.md")
152
- File.write(dest, content, encoding: "UTF-8")
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
- ext = File.extname(path).downcase
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
- ext = File.extname(path).downcase
183
- LLM_BINARY_EXTENSIONS.include?(ext)
159
+ LLM_BINARY_EXTENSIONS.include?(File.extname(path).downcase)
184
160
  end
185
161
 
186
- # Save raw image bytes to disk and return a FileRef.
187
- # Used by agent when an image exceeds MAX_IMAGE_BYTES and must be downgraded to a file.
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 / 1024}MB > #{MAX_IMAGE_BYTES / 1024 / 1024}MB): #{path}"
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
- private_class_method :process_office, :process_zip, :process_binary,
249
- :save_original, :save_preview, :sanitize_filename
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" or "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[.-]?5/i
230
+ when /claude.*opus.*4[.-]?[56]/i
230
231
  "claude-opus-4.5"
231
- when /claude.*sonnet.*4[.-]?5/i
232
+ when /claude.*sonnet.*4[.-]?[56]/i
232
233
  "claude-sonnet-4.5"
233
- when /claude.*haiku.*4[.-]?5/i
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"