openclacky 0.9.20 → 0.9.22

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 803023a686f9005d8bbed56fa2604d6cb4a426ba2ff1c24ddf6e297242c7faed
4
- data.tar.gz: ee8738600a36146e4f56b8605a917066746b31e370a41c3e245066ec4e4e961c
3
+ metadata.gz: 636621284dfcd7f7329f793bc80f2364f16a2777dedacd2fdf310b2a7f3be58a
4
+ data.tar.gz: 5f3a12e563e36445d3aa32ae050f5b759b204325323cac119b8692bea7d3657b
5
5
  SHA512:
6
- metadata.gz: b5b27887365a698d193fee92d0672315c48963a9748509cae8a6b5426d0169f16b9fad709afa2232d80542999fc931ee48e09f2cee35100b39273b33c324b558
7
- data.tar.gz: 5c5572d8a7b34af4eb03220dbf0e566ed0d2ab7b7278a89d8801f0c9ca58b100c90dd27fd17d1548016b21c20e1e458889a97a54983309c409479b863fc8b6ca
6
+ metadata.gz: 23e59f8ae883ded129b4fee900c0ffdf6cec3ada4bd019e9a0243a3942abc70f5bbc8b9cc4830a34d7e66e22e4c663988295eed972ad1df63247c6f0bdbf1a68
7
+ data.tar.gz: 218bd984151af6f087c6ac44f3aa213e19ee30981a1a69484344cdbd00774611465fc3847ecfc0933243e2ae6277792116d96d0aed04b9148a9cfed14f09e14e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.22] - 2026-03-31
11
+
12
+ ### Added
13
+ - **ClackyAI provider (Bedrock with prompt caching)**: added `clackyai` as a first-class provider — uses AWS Bedrock under the hood with prompt caching enabled, normalising token usage to Anthropic semantics so cost calculation works correctly
14
+ - **Browser auto-install script**: `browser-setup` skill can now detect the Chrome/Edge version and automatically download and run the install script, reducing manual setup steps
15
+
16
+ ### Fixed
17
+ - **Feishu setup timeout**: `navigate` method was using `open` (new tab) instead of `navigate` (current tab), causing intermittent timeouts on macOS when opening feishu.cn
18
+ - **Cron task schedule YAML format**: fixed a YAML serialisation bug in the scheduler that produced invalid schedule files
19
+
20
+ ## [0.9.21] - 2026-03-30
21
+
22
+ ### Fixed
23
+ - **Feishu channel setup compatibility with v2.6**: fixed Ruby 3.1 syntax incompatibility in the Feishu setup script that caused failures on newer Feishu API versions
24
+
25
+ ### Improved
26
+ - **skill-creator YAML validation**: added frontmatter schema validation for skill files, catching malformed skill definitions before they cause runtime errors
27
+
28
+ ### More
29
+ - Removed `install_simple.sh` (consolidated into `install.sh`)
30
+
10
31
  ## [0.9.20] - 2026-03-30
11
32
 
12
33
  ### Added
data/lib/clacky/agent.rb CHANGED
@@ -102,6 +102,9 @@ module Clacky
102
102
 
103
103
  # Ensure user-space parsers are in place (~/.clacky/parsers/)
104
104
  Utils::ParserManager.setup!
105
+
106
+ # Ensure bundled shell scripts are in place (~/.clacky/scripts/)
107
+ Utils::ScriptsManager.setup!
105
108
  end
106
109
 
107
110
  # Restore from a saved session
data/lib/clacky/client.rb CHANGED
@@ -88,7 +88,7 @@ module Clacky
88
88
  cloned = deep_clone(messages)
89
89
 
90
90
  if bedrock?
91
- send_bedrock_request(cloned, model, tools, max_tokens)
91
+ send_bedrock_request(cloned, model, tools, max_tokens, caching_enabled)
92
92
  elsif anthropic_format?
93
93
  send_anthropic_request(cloned, model, tools, max_tokens, caching_enabled)
94
94
  else
@@ -124,8 +124,8 @@ module Clacky
124
124
 
125
125
  # ── Bedrock Converse request / response ───────────────────────────────────
126
126
 
127
- def send_bedrock_request(messages, model, tools, max_tokens)
128
- body = MessageFormat::Bedrock.build_request_body(messages, model, tools, max_tokens)
127
+ def send_bedrock_request(messages, model, tools, max_tokens, caching_enabled)
128
+ body = MessageFormat::Bedrock.build_request_body(messages, model, tools, max_tokens, caching_enabled)
129
129
  response = bedrock_connection.post(bedrock_endpoint(model)) { |r| r.body = body.to_json }
130
130
 
131
131
  raise_error(response) unless response.status == 200
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  name: browser-setup
3
3
  description: |
4
- Configure the browser tool for Clacky. Guides the user through Chrome setup,
4
+ Configure the browser tool for Clacky. Guides the user through Chrome or Edge setup,
5
5
  verifies the connection, and writes ~/.clacky/browser.yml.
6
+ Supports macOS, Linux, and WSL (Windows Chrome/Edge via remote debugging).
6
7
  Trigger on: "browser setup", "setup browser", "配置浏览器", "browser config",
7
8
  "browser doctor".
8
9
  Subcommands: setup, doctor.
@@ -31,45 +32,43 @@ If no subcommand is clear, default to `setup`.
31
32
 
32
33
  ## `setup`
33
34
 
34
- ### Step 1 — Check Node.js & install chrome-devtools-mcp
35
+ ### Step 1 — Ensure chrome-devtools-mcp is installed
35
36
 
36
- Run:
37
+ Check if already installed:
37
38
  ```bash
38
- node --version
39
+ chrome-devtools-mcp --version 2>/dev/null
39
40
  ```
40
41
 
41
- If Node.js is missing or version < 20, tell the user and stop:
42
+ If found and exits 0 skip to Step 2.
42
43
 
43
- > The browser tool requires Node.js 20+.
44
- > Please install it first: https://nodejs.org/
45
- > Let me know when done and I'll retry.
46
-
47
- Then install/update `chrome-devtools-mcp`:
44
+ If missing, run the bundled installer (handles Node.js + chrome-devtools-mcp + CN/global mirrors automatically):
48
45
  ```bash
49
- npm install -g chrome-devtools-mcp@latest
46
+ bash ~/.clacky/scripts/install_browser.sh
50
47
  ```
51
48
 
52
- If this fails, stop and tell the user:
49
+ If the script exits non-zero or `~/.clacky/scripts/install_browser.sh` doesn't exist, stop and tell the user:
53
50
 
54
- > ❌ Failed to install chrome-devtools-mcp. Please run manually:
51
+ > ❌ Failed to install chrome-devtools-mcp automatically.
52
+ > Please run manually:
55
53
  > ```
56
54
  > npm install -g chrome-devtools-mcp@latest
57
55
  > ```
56
+ > (Requires Node.js 20+. If Node.js is missing, install it first, then retry.)
58
57
  > Let me know when done.
59
58
 
60
- ### Step 2 — Try to connect to Chrome
59
+ ### Step 2 — Try to connect to the browser
61
60
 
62
61
  Immediately attempt to connect — do **not** ask the user anything first:
63
62
 
64
63
  ```
65
- browser(action="act", kind="evaluate", js="navigator.userAgentData?.brands?.find(b => b.brand === 'Google Chrome')?.version || navigator.userAgent.match(/Chrome\\/([\\d]+)/)?.[1] || 'unknown'")
64
+ browser(action="act", kind="evaluate", js="navigator.userAgentData?.brands?.find(b => b.brand === 'Google Chrome' || b.brand === 'Microsoft Edge')?.version || navigator.userAgent.match(/Chrome\/([\d]+)/)?.[1] || 'unknown'")
66
65
  ```
67
66
 
68
- **If connection succeeds** → parse the Chrome version and jump to Step 3.
67
+ **If connection succeeds** → parse the browser version and jump to Step 3.
69
68
 
70
69
  **If connection fails** → inspect the error message from the evaluate result to diagnose:
71
70
 
72
- **Case A — error contains `"timed out"`**: The MCP daemon failed to start — Chrome is not running or remote debugging is not enabled. Try to open the page for the user:
71
+ **Case A — error contains `"timed out"`**: The MCP daemon failed to start — Chrome or Edge is not running or remote debugging is not enabled. Try to open the page for the user:
73
72
 
74
73
  ```bash
75
74
  open "chrome://inspect/#remote-debugging"
@@ -77,35 +76,38 @@ open "chrome://inspect/#remote-debugging"
77
76
 
78
77
  Tell the user:
79
78
 
80
- > I've opened `chrome://inspect/#remote-debugging` in Chrome.
79
+ > I've opened `chrome://inspect/#remote-debugging` in your browser.
81
80
  > Please click **"Allow remote debugging for this browser instance"** and let me know when done.
82
81
 
83
- If `open` fails, fall back to:
82
+ If `open` fails (e.g. on WSL or Linux), fall back to:
84
83
 
85
- > Please open this URL in Chrome:
84
+ > Please open this URL in Chrome or Edge:
86
85
  > `chrome://inspect/#remote-debugging`
87
86
  > Then click **"Allow remote debugging for this browser instance"** and let me know when done.
87
+ >
88
+ > On WSL: launch your browser from PowerShell with remote debugging enabled:
89
+ > - Edge: `Start-Process msedge --ArgumentList "--remote-debugging-port=9222"`
90
+ > - Chrome: `Start-Process chrome --ArgumentList "--remote-debugging-port=9222"`
88
91
 
89
92
  Wait for the user to confirm, then retry the connection once. If still failing, stop:
90
93
 
91
- > ❌ Could not connect to Chrome. Please make sure Chrome is open and remote debugging is enabled, then run `/browser-setup` again.
94
+ > ❌ Could not connect to the browser. Please make sure Chrome or Edge is open and remote debugging is enabled, then run `/browser-setup` again.
92
95
 
93
- **Case B — error contains `"Chrome MCP error:"`**: The MCP daemon is alive but Chrome's CDP connection is broken — this is a known Chrome issue after long sessions. Tell the user:
96
+ **Case B — error contains `"Chrome MCP error:"`**: The MCP daemon is alive but the browser's CDP connection is broken — this is a known issue after long sessions. Tell the user:
94
97
 
95
- > Chrome's remote debugging connection is unstable.
96
- > Please restart Chrome and let me know when done.
98
+ > The browser's remote debugging connection is unstable.
99
+ > Please restart your browser and let me know when done.
97
100
 
98
101
  Wait for the user to confirm, then retry once. If still failing, stop with the same error message as Case A.
99
102
 
100
- ### Step 3 — Check Chrome version
103
+ ### Step 3 — Check browser version
101
104
 
102
- Parse the version number from Step 2:
105
+ Parse the version number from Step 2 (works for both Chrome and Edge, both are Chromium-based):
103
106
  - version >= 146 → proceed
104
107
  - version 144–145 → warn but proceed:
105
- > ⚠️ Your Chrome version is vXXX. Version 146+ is recommended. Continuing anyway...
108
+ > ⚠️ Your browser version is vXXX. Version 146+ is recommended. Continuing anyway...
106
109
  - version < 144 or unknown → stop:
107
- > ❌ Chrome vXXX is too old. Please upgrade to Chrome 146+: https://www.google.com/chrome/
108
- > Let me know when you've upgraded and I'll retry.
110
+ > ❌ Browser vXXX is too old. Please upgrade Chrome or Edge to v146+, then let me know and I'll retry.
109
111
 
110
112
  ### Step 4 — Save config and start daemon
111
113
 
@@ -122,7 +124,7 @@ If this fails (server not running), skip silently — the daemon will start lazi
122
124
 
123
125
  > ✅ Browser configured.
124
126
  >
125
- > Chrome v<VERSION> is connected and ready to use.
127
+ > Chrome/Edge v<VERSION> is connected and ready to use.
126
128
 
127
129
  ---
128
130
 
@@ -71,9 +71,9 @@ BOT_PERMISSIONS = %w[
71
71
  # Logging helpers
72
72
  # ---------------------------------------------------------------------------
73
73
 
74
- def step(msg) = puts("[feishu-setup] #{msg}")
75
- def ok(msg) = puts("[feishu-setup] ✅ #{msg}")
76
- def warn(msg) = puts("[feishu-setup] ⚠️ #{msg}")
74
+ def step(msg); puts("[feishu-setup] #{msg}"); end
75
+ def ok(msg); puts("[feishu-setup] ✅ #{msg}"); end
76
+ def warn(msg); puts("[feishu-setup] ⚠️ #{msg}"); end
77
77
  def fail!(msg)
78
78
  puts("[feishu-setup] ❌ #{msg}")
79
79
  exit 1
@@ -125,7 +125,7 @@ class BrowserSession
125
125
  end
126
126
 
127
127
  def navigate(url)
128
- @client.call("open", url: url)
128
+ @client.call("navigate", url: url)
129
129
  sleep 2
130
130
  snapshot
131
131
  end
@@ -501,7 +501,7 @@ def run_setup(browser, api)
501
501
  id = s["id"].to_s
502
502
  name_to_id[name] = id if name && !id.empty?
503
503
  end
504
- ids = BOT_PERMISSIONS.filter_map { |n| name_to_id[n] }
504
+ ids = BOT_PERMISSIONS.map { |n| name_to_id[n] }.compact
505
505
  missing = BOT_PERMISSIONS.reject { |n| name_to_id.key?(n) }
506
506
  warn "#{missing.size} permissions not matched: #{missing.join(", ")}" unless missing.empty?
507
507
  fail! "No permission IDs matched. API response keys: #{name_to_id.keys.first(5).inspect}" if ids.empty?
@@ -53,6 +53,7 @@ Answer the user's question using the official documentation below. Always fetch
53
53
  | Built-in skills, default skills, what skills ship with OpenClacky | https://www.openclacky.com/docs/built-in-skills |
54
54
  | Memory system, long-term memory, ~/.clacky/memories | https://www.openclacky.com/docs/memory-system |
55
55
  | Session management, conversation history, context window | https://www.openclacky.com/docs/session-management |
56
+ | Browser automation, browser tool, Chrome, Edge, CDP, remote debugging, WSL browser, browser-setup skill | https://www.openclacky.com/docs/browser-tool |
56
57
 
57
58
  ## Workflow
58
59
 
@@ -86,6 +86,12 @@ Components to fill in:
86
86
  >
87
87
  > **YAML description gotcha**: If the description contains `word: value` patterns (colons followed by space), YAML treats them as key-value pairs and the frontmatter parse fails silently. Always wrap description values in single quotes. Avoid embedded double-quotes inside single-quoted strings (use rephrasing instead).
88
88
 
89
+ > **After writing SKILL.md — always validate and auto-fix**: Run this immediately after creating or updating any skill file:
90
+ > ```bash
91
+ > ruby SKILL_DIR/scripts/validate_skill_frontmatter.rb /path/to/new-skill/SKILL.md
92
+ > ```
93
+ > The script validates the YAML frontmatter and auto-fixes common issues (unquoted descriptions, multi-line block scalars with colons). If it prints `OK:` — you're done. If it prints `Auto-fixed and saved` — it repaired the file automatically. If it prints `ERROR` — manual fix required.
94
+
89
95
  ### Skill Writing Guide
90
96
 
91
97
  #### Anatomy of a Skill
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # validate_skill_frontmatter.rb
5
+ #
6
+ # Validates and auto-fixes the YAML frontmatter of a SKILL.md file.
7
+ #
8
+ # Usage:
9
+ # ruby validate_skill_frontmatter.rb <path/to/SKILL.md>
10
+ #
11
+ # What it does:
12
+ # 1. Parses the frontmatter between --- delimiters
13
+ # 2. If YAML is invalid OR description is not a plain String:
14
+ # - Extracts name/description via regex fallback
15
+ # - Re-wraps description in single quotes (collapsed to one line)
16
+ # - Rewrites the frontmatter in the file
17
+ # 3. Exits 0 on success (with or without auto-fix), 1 on unrecoverable error
18
+
19
+ require "yaml"
20
+
21
+ path = ARGV[0]
22
+
23
+ if path.nil? || path.strip.empty?
24
+ warn "Usage: ruby validate_skill_frontmatter.rb <path/to/SKILL.md>"
25
+ exit 1
26
+ end
27
+
28
+ unless File.exist?(path)
29
+ warn "File not found: #{path}"
30
+ exit 1
31
+ end
32
+
33
+ content = File.read(path)
34
+
35
+ # Extract frontmatter block
36
+ fm_match = content.match(/\A(---\n)(.*?)(\n---[ \t]*\n?)/m)
37
+ unless fm_match
38
+ warn "ERROR: No frontmatter block found in #{path}"
39
+ exit 1
40
+ end
41
+
42
+ prefix = fm_match[1] # "---\n"
43
+ yaml_raw = fm_match[2] # raw YAML text
44
+ suffix = fm_match[3] # "\n---\n"
45
+ body = content[fm_match.end(0)..] # rest of file after frontmatter
46
+
47
+ # Attempt normal YAML parse
48
+ parse_ok = false
49
+ data = nil
50
+ begin
51
+ data = YAML.safe_load(yaml_raw) || {}
52
+ parse_ok = data["description"].is_a?(String)
53
+ rescue Psych::Exception => e
54
+ warn "YAML parse error: #{e.message}"
55
+ end
56
+
57
+ if parse_ok
58
+ puts "OK: name=#{data['name'].inspect} description_length=#{data['description'].length}"
59
+ exit 0
60
+ end
61
+
62
+ # --- Auto-fix ---
63
+ puts "Frontmatter invalid or description broken — attempting auto-fix..."
64
+
65
+ # Regex fallback: extract name and description lines
66
+ name_match = yaml_raw.match(/^name:\s*(.+)$/)
67
+ unless name_match
68
+ warn "ERROR: Cannot extract 'name' field from frontmatter. Manual fix required."
69
+ exit 1
70
+ end
71
+ name_value = name_match[1].strip.gsub(/\A['"]|['"]\z/, "")
72
+
73
+ # description may be:
74
+ # description: some text (unquoted)
75
+ # description: 'some text' (single-quoted)
76
+ # description: "some text" (double-quoted)
77
+ # description: first line\n continuation (multi-line block scalar)
78
+ desc_match = yaml_raw.match(/^description:\s*(.+?)(?=\n[a-z]|\z)/m)
79
+ unless desc_match
80
+ warn "ERROR: Cannot extract 'description' field from frontmatter. Manual fix required."
81
+ exit 1
82
+ end
83
+
84
+ raw_desc = desc_match[1].strip
85
+
86
+ # Strip existing outer quotes if present (simple single-line quoted values)
87
+ if raw_desc.start_with?("'") && raw_desc.end_with?("'")
88
+ raw_desc = raw_desc[1..-2]
89
+ elsif raw_desc.start_with?('"') && raw_desc.end_with?('"')
90
+ raw_desc = raw_desc[1..-2]
91
+ end
92
+
93
+ # Collapse multi-line: strip leading whitespace from continuation lines
94
+ description_value = raw_desc.gsub(/\n\s+/, " ").strip
95
+
96
+ # Escape any single quotes inside the description value
97
+ description_value_escaped = description_value.gsub("'", "''")
98
+
99
+ # Extract all other frontmatter lines (everything except name: and description:)
100
+ other_lines = yaml_raw.each_line.reject do |line|
101
+ line.match?(/^(name|description):/) || line.match?(/^\s+\S/) && yaml_raw.match?(/^description:.*\n(\s+.+\n)*/m)
102
+ end
103
+
104
+ # More precise: collect lines that are not part of the name/description block
105
+ remaining = []
106
+ skip_continuation = false
107
+ yaml_raw.each_line do |line|
108
+ if line.match?(/^(name|description):/)
109
+ skip_continuation = true
110
+ next
111
+ end
112
+ if skip_continuation && line.match?(/^\s+\S/)
113
+ next # continuation of a multi-line block value
114
+ end
115
+ skip_continuation = false
116
+ remaining << line unless line.strip.empty? && remaining.empty?
117
+ end
118
+
119
+ # Rebuild frontmatter
120
+ fixed_fm_lines = []
121
+ fixed_fm_lines << "name: #{name_value}"
122
+ fixed_fm_lines << "description: '#{description_value_escaped}'"
123
+ remaining.each { |l| fixed_fm_lines << l.chomp }
124
+
125
+ # Remove trailing blank lines from remaining
126
+ fixed_fm = fixed_fm_lines.join("\n").strip
127
+
128
+ new_content = "#{prefix}#{fixed_fm}#{suffix}#{body}"
129
+
130
+ File.write(path, new_content)
131
+ puts "Auto-fixed and saved: #{path}"
132
+
133
+ # Final verification
134
+ begin
135
+ verify_content = File.read(path)
136
+ verify_match = verify_content.match(/\A---\n(.*?)\n---/m)
137
+ verify_data = YAML.safe_load(verify_match[1])
138
+ raise "description not a String" unless verify_data["description"].is_a?(String)
139
+ puts "OK: name=#{verify_data['name'].inspect} description_length=#{verify_data['description'].length}"
140
+ rescue => e
141
+ warn "ERROR: Auto-fix failed, manual intervention required: #{e.message}"
142
+ exit 1
143
+ end
@@ -32,13 +32,21 @@ module Clacky
32
32
  # @param max_tokens [Integer]
33
33
  # @param caching_enabled [Boolean] (currently unused for Bedrock)
34
34
  # @return [Hash] ready to serialize as JSON body
35
- def build_request_body(messages, model, tools, max_tokens, _caching_enabled = false)
35
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled = false)
36
36
  system_messages = messages.select { |m| m[:role] == "system" }
37
37
  regular_messages = messages.reject { |m| m[:role] == "system" }
38
38
 
39
39
  # Merge consecutive same-role messages (Bedrock requires alternating roles)
40
40
  api_messages = merge_consecutive_tool_results(regular_messages.map { |msg| to_api_message(msg) })
41
41
 
42
+ # Inject cachePoint blocks AFTER conversion to Bedrock API format.
43
+ # Doing this on canonical messages (before to_api_message) is incorrect because
44
+ # tool-result messages (role: "tool") are converted to toolResult blocks, and
45
+ # Bedrock does not support cachePoint inside toolResult.content.
46
+ # Operating on the final Bedrock format ensures cachePoint is always a top-level
47
+ # sibling block in the message's content array, which is what Bedrock expects.
48
+ api_messages = apply_api_caching(api_messages) if caching_enabled
49
+
42
50
  body = { messages: api_messages }
43
51
 
44
52
  # Add system prompt if present
@@ -86,11 +94,24 @@ module Clacky
86
94
  else data["stopReason"]
87
95
  end
88
96
 
97
+ cache_read = usage["cacheReadInputTokens"].to_i
98
+ cache_write = usage["cacheWriteInputTokens"].to_i
99
+
100
+ # Bedrock `inputTokens` = non-cached input only.
101
+ # Anthropic direct `input_tokens` = all input including cache_read.
102
+ # Normalise to Anthropic semantics so ModelPricing.calculate_cost works correctly:
103
+ # prompt_tokens = inputTokens + cacheReadInputTokens
104
+ # (calculate_cost subtracts cache_read_tokens from prompt_tokens to get
105
+ # the billable non-cached portion; that arithmetic requires the Anthropic convention.)
106
+ prompt_tokens = usage["inputTokens"].to_i + cache_read
107
+
89
108
  usage_data = {
90
- prompt_tokens: usage["inputTokens"].to_i,
109
+ prompt_tokens: prompt_tokens,
91
110
  completion_tokens: usage["outputTokens"].to_i,
92
111
  total_tokens: usage["totalTokens"].to_i
93
112
  }
113
+ usage_data[:cache_read_input_tokens] = cache_read if cache_read > 0
114
+ usage_data[:cache_creation_input_tokens] = cache_write if cache_write > 0
94
115
 
95
116
  { content: content, tool_calls: tool_calls, finish_reason: finish_reason,
96
117
  usage: usage_data, raw_api_usage: usage }
@@ -185,6 +206,8 @@ module Clacky
185
206
  when "image"
186
207
  block # already Bedrock format
187
208
  else
209
+ # Pass through Bedrock-native blocks (e.g. cachePoint) unchanged
210
+ return block if block[:cachePoint]
188
211
  # Fallback: try to extract text
189
212
  { text: (block[:text] || block.to_s) }
190
213
  end
@@ -252,6 +275,38 @@ module Clacky
252
275
  end
253
276
  merged
254
277
  end
278
+
279
+ # Inject cachePoint blocks into already-converted Bedrock API format messages.
280
+ # Marks the last 2 messages (from the tail) so Bedrock can cache the conversation
281
+ # prefix up to those points.
282
+ #
283
+ # Why operate on Bedrock API format (not canonical):
284
+ # - tool-result canonical messages (role: "tool") become toolResult blocks inside
285
+ # a user message. Bedrock does NOT allow cachePoint inside toolResult.content.
286
+ # - After merge_consecutive_tool_results, message boundaries may differ from canonical.
287
+ # - Operating here guarantees cachePoint is always a top-level sibling block.
288
+ private_class_method def self.apply_api_caching(api_messages)
289
+ return api_messages if api_messages.empty?
290
+
291
+ candidate_indices = []
292
+ (api_messages.length - 1).downto(0) do |i|
293
+ break if candidate_indices.length >= 2
294
+ candidate_indices << i
295
+ end
296
+
297
+ api_messages.map.with_index do |msg, idx|
298
+ next msg unless candidate_indices.include?(idx)
299
+
300
+ content = msg[:content]
301
+ next msg unless content.is_a?(Array)
302
+
303
+ # Don't double-add cachePoint if already present
304
+ already_marked = content.last.is_a?(Hash) && content.last[:cachePoint]
305
+ next msg if already_marked
306
+
307
+ msg.merge(content: content + [{ cachePoint: { type: "default" } }])
308
+ end
309
+ end
255
310
  end
256
311
  end
257
312
  end
@@ -48,16 +48,16 @@ module Clacky
48
48
  "website_url" => "https://console.anthropic.com/settings/keys"
49
49
  }.freeze,
50
50
 
51
- "bedrock-jp" => {
52
- "name" => "AWS Bedrock (JP)",
53
- "base_url" => "https://bedrock-runtime.ap-northeast-1.amazonaws.com",
51
+ "clackyai" => {
52
+ "name" => "Clacky AI",
53
+ "base_url" => "https://api.clacky.ai",
54
54
  "api" => "bedrock",
55
55
  "default_model" => "jp.anthropic.claude-sonnet-4-6",
56
56
  "models" => [
57
57
  "jp.anthropic.claude-sonnet-4-6",
58
58
  "jp.anthropic.claude-haiku-4-6"
59
59
  ],
60
- "website_url" => "https://console.aws.amazon.com/iam/home#/security_credentials"
60
+ "website_url" => "https://clacky.ai"
61
61
  }.freeze
62
62
 
63
63
  }.freeze
@@ -300,7 +300,8 @@ module Clacky
300
300
  return [] unless File.exist?(SCHEDULES_FILE)
301
301
 
302
302
  data = YAMLCompat.load_file(SCHEDULES_FILE, permitted_classes: [Symbol])
303
- Array(data)
303
+ raw = data.is_a?(Hash) ? data["schedules"] : data
304
+ Array(raw).select { |s| s.is_a?(Hash) }
304
305
  rescue => e
305
306
  Clacky::Logger.error("scheduler_load_schedules_error", error: e)
306
307
  []
@@ -542,9 +542,33 @@ module Clacky
542
542
  end
543
543
 
544
544
  def self.build_mcp_command(user_data_dir: nil)
545
- args = CHROME_MCP_BASE_ARGS.dup
546
- args += ["--userDataDir", user_data_dir.to_s] if user_data_dir && !user_data_dir.to_s.empty?
547
- ["chrome-devtools-mcp", *args]
545
+ # If an explicit user_data_dir is given, use it directly (e.g. from browser.yml).
546
+ if user_data_dir && !user_data_dir.to_s.empty?
547
+ return ["chrome-devtools-mcp", *CHROME_MCP_BASE_ARGS, "--userDataDir", user_data_dir.to_s]
548
+ end
549
+
550
+ # On non-macOS/Linux platforms (especially WSL), chrome-devtools-mcp's built-in
551
+ # --autoConnect only scans Linux-side paths and misses Windows-side Chrome/Edge.
552
+ # Use BrowserDetector to find any running debuggable browser across all platforms.
553
+ detected = Clacky::Utils::BrowserDetector.detect
554
+
555
+ args = base_args_without_autoconnect
556
+ case detected&.fetch(:mode)
557
+ when :ws_endpoint
558
+ # DevToolsActivePort found — use exact WS endpoint (most reliable)
559
+ ["chrome-devtools-mcp", *args, "--wsEndpoint", detected[:value]]
560
+ when :browser_url
561
+ # TCP port reachable — let chrome-devtools-mcp handle WS negotiation
562
+ ["chrome-devtools-mcp", *args, "--browserUrl", detected[:value]]
563
+ else
564
+ # Nothing detected — fall back to --autoConnect (works on plain macOS/Linux)
565
+ ["chrome-devtools-mcp", *CHROME_MCP_BASE_ARGS]
566
+ end
567
+ end
568
+
569
+ # Base args without --autoConnect, used when we supply explicit connection info.
570
+ def self.base_args_without_autoconnect
571
+ CHROME_MCP_BASE_ARGS.reject { |a| a == "--autoConnect" }
548
572
  end
549
573
 
550
574
  # Delegate to BrowserManager. Auto-retries once on "selected page has been closed".