openclacky 1.2.6 → 1.2.8
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 +34 -0
- data/README.md +34 -0
- data/README_CN.md +34 -0
- data/lib/clacky/agent/cost_tracker.rb +7 -1
- data/lib/clacky/agent/message_compressor.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +6 -2
- data/lib/clacky/agent/session_serializer.rb +23 -4
- data/lib/clacky/agent.rb +46 -2
- data/lib/clacky/agent_config.rb +54 -6
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/brand_config.rb +0 -6
- data/lib/clacky/cli.rb +107 -1
- data/lib/clacky/client.rb +56 -6
- data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +5 -2
- data/lib/clacky/patch_loader.rb +282 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +11 -2
- data/lib/clacky/server/channel/adapters/base.rb +4 -0
- data/lib/clacky/server/channel/channel_manager.rb +149 -13
- data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
- data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
- data/lib/clacky/server/channel.rb +5 -0
- data/lib/clacky/server/http_server.rb +135 -14
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/server/session_registry.rb +30 -4
- data/lib/clacky/server/web_ui_controller.rb +6 -3
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/tools/terminal.rb +22 -26
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +392 -14
- data/lib/clacky/web/app.js +0 -1
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +50 -6
- data/lib/clacky/web/index.html +33 -0
- data/lib/clacky/web/sessions.js +203 -14
- data/lib/clacky/web/settings.js +59 -17
- data/lib/clacky/web/workspace.js +204 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -3
- data/lib/clacky.rb +15 -0
- metadata +7 -2
data/lib/clacky/client.rb
CHANGED
|
@@ -258,7 +258,16 @@ module Clacky
|
|
|
258
258
|
response.env.body = sse_buf if response.body.to_s.empty?
|
|
259
259
|
raise_error(response)
|
|
260
260
|
end
|
|
261
|
-
|
|
261
|
+
|
|
262
|
+
result = aggregator.to_h
|
|
263
|
+
# A complete Converse stream always emits stopReason in its messageStop
|
|
264
|
+
# frame. Its absence means the upstream cut the stream mid-response,
|
|
265
|
+
# leaving a half-written message; retry rather than accept the truncation.
|
|
266
|
+
if result["stopReason"].nil?
|
|
267
|
+
raise Clacky::UpstreamTruncatedError,
|
|
268
|
+
"[LLM] Streaming response ended without stopReason (upstream cut the stream). Retrying..."
|
|
269
|
+
end
|
|
270
|
+
MessageFormat::Bedrock.parse_response(result)
|
|
262
271
|
end
|
|
263
272
|
|
|
264
273
|
def parse_simple_bedrock_response(response)
|
|
@@ -301,8 +310,22 @@ module Clacky
|
|
|
301
310
|
end
|
|
302
311
|
end
|
|
303
312
|
|
|
304
|
-
|
|
305
|
-
|
|
313
|
+
unless response.status == 200
|
|
314
|
+
recovered_body = response.body.to_s
|
|
315
|
+
recovered_body = sse_buf.to_s if recovered_body.empty?
|
|
316
|
+
recovered = Struct.new(:status, :body).new(response.status, recovered_body)
|
|
317
|
+
raise_error(recovered)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
result = aggregator.to_h
|
|
321
|
+
# A complete Messages stream always emits stop_reason in its message_delta
|
|
322
|
+
# frame. Its absence means the upstream cut the stream mid-response,
|
|
323
|
+
# leaving a half-written message; retry rather than accept the truncation.
|
|
324
|
+
if result["stop_reason"].nil?
|
|
325
|
+
raise Clacky::UpstreamTruncatedError,
|
|
326
|
+
"[LLM] Streaming response ended without stop_reason (upstream cut the stream). Retrying..."
|
|
327
|
+
end
|
|
328
|
+
MessageFormat::Anthropic.parse_response(result)
|
|
306
329
|
end
|
|
307
330
|
|
|
308
331
|
def parse_simple_anthropic_response(response)
|
|
@@ -355,7 +378,18 @@ module Clacky
|
|
|
355
378
|
response.env.body = sse_buf if response.body.to_s.empty?
|
|
356
379
|
raise_error(response)
|
|
357
380
|
end
|
|
358
|
-
|
|
381
|
+
|
|
382
|
+
result = aggregator.to_h
|
|
383
|
+
# A complete chat-completion stream always terminates with a frame
|
|
384
|
+
# carrying finish_reason. Its absence means the upstream cut the stream
|
|
385
|
+
# mid-response (e.g. proxy idle-timeout, connection reset that Faraday
|
|
386
|
+
# didn't surface as an exception), leaving a half-written message. Treat
|
|
387
|
+
# as retryable so we don't hand a silently truncated answer to the agent.
|
|
388
|
+
if result.dig("choices", 0, "finish_reason").nil?
|
|
389
|
+
raise Clacky::UpstreamTruncatedError,
|
|
390
|
+
"[LLM] Streaming response ended without finish_reason (upstream cut the stream). Retrying..."
|
|
391
|
+
end
|
|
392
|
+
MessageFormat::OpenAI.parse_response(result)
|
|
359
393
|
end
|
|
360
394
|
|
|
361
395
|
def parse_simple_openai_response(response)
|
|
@@ -536,13 +570,23 @@ module Clacky
|
|
|
536
570
|
def raise_error(response)
|
|
537
571
|
error_body = JSON.parse(response.body) rescue nil
|
|
538
572
|
error_message = extract_error_message(error_body, response.body)
|
|
573
|
+
error_code = extract_error_code(error_body)
|
|
539
574
|
|
|
540
575
|
Clacky::Logger.warn("client.raise_error",
|
|
541
576
|
status: response.status,
|
|
542
577
|
body: response.body.to_s[0, 2000],
|
|
543
|
-
error_message: error_message.to_s[0, 500]
|
|
578
|
+
error_message: error_message.to_s[0, 500],
|
|
579
|
+
error_code: error_code
|
|
544
580
|
)
|
|
545
581
|
|
|
582
|
+
if error_code == "insufficient_credit" || response.status == 402
|
|
583
|
+
raise InsufficientCreditError.new(
|
|
584
|
+
"[LLM] Insufficient credit: #{error_message}",
|
|
585
|
+
error_code: "insufficient_credit",
|
|
586
|
+
provider_id: @provider_id
|
|
587
|
+
)
|
|
588
|
+
end
|
|
589
|
+
|
|
546
590
|
case response.status
|
|
547
591
|
when 400
|
|
548
592
|
# Well-behaved APIs (Anthropic, OpenAI) never put quota/availability issues in 400.
|
|
@@ -557,7 +601,6 @@ module Clacky
|
|
|
557
601
|
# broken message is not replayed on the next user turn.
|
|
558
602
|
raise BadRequestError, "[LLM] Client request error: #{error_message}"
|
|
559
603
|
when 401 then raise AgentError, "[LLM] Invalid API key"
|
|
560
|
-
when 402 then raise AgentError, "[LLM] Billing or payment issue (possibly out of credits): #{error_message}"
|
|
561
604
|
when 403 then raise AgentError, "[LLM] Access denied: #{error_message}"
|
|
562
605
|
when 404 then raise AgentError, "[LLM] API endpoint not found: #{error_message}"
|
|
563
606
|
when 429 then raise RetryableError, "[LLM] Rate limit exceeded, please wait a moment"
|
|
@@ -574,6 +617,13 @@ module Clacky
|
|
|
574
617
|
end
|
|
575
618
|
end
|
|
576
619
|
|
|
620
|
+
private def extract_error_code(error_body)
|
|
621
|
+
return nil unless error_body.is_a?(Hash)
|
|
622
|
+
err = error_body["error"]
|
|
623
|
+
return err["code"] if err.is_a?(Hash) && err["code"].is_a?(String)
|
|
624
|
+
nil
|
|
625
|
+
end
|
|
626
|
+
|
|
577
627
|
def extract_error_message(error_body, raw_body)
|
|
578
628
|
if raw_body.is_a?(String) && raw_body.strip.start_with?("<!DOCTYPE", "<html")
|
|
579
629
|
return "Invalid API endpoint or server error (received HTML instead of JSON)"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: deploy
|
|
3
3
|
description: Deploy Rails applications to Railway. Handles first-time setup and re-deploys idempotently using Railway CLI. Trigger on: "deploy", "deploy to railway", "railway deploy", "发布", "部署", "上线".
|
|
4
|
-
|
|
4
|
+
agent: coding
|
|
5
|
+
disable-model-invocation: false
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Deploy Rails App to Railway
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: extend-openclacky
|
|
3
|
+
description: Customize, fix, override or extend openclacky itself — e.g. change a built-in tool's behavior, intercept/audit/block tool calls with shell scripts, or plug in a new IM channel (Slack, in-house IM, etc.). Trigger on phrases like "patch clacky", "patch openclacky", "change WebSearch behavior", "block dangerous commands", "audit tool use", "add Slack channel", "改 openclacky 内置", "改 clacky 内置", "monkey patch openclacky", "拦截工具调用". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extending Openclacky
|
|
7
|
+
|
|
8
|
+
Openclacky ships three official extension mechanisms that survive `gem update` and never require editing the gem source.
|
|
9
|
+
**Never tell the user to `bundle show openclacky` and edit the gem — always use one of these.**
|
|
10
|
+
|
|
11
|
+
## Pick the right mechanism
|
|
12
|
+
|
|
13
|
+
| User wants to… | Use | Scaffold | Verify |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| Change behavior of an **existing method** in openclacky (e.g. `WebSearch#execute` timeout, fix a bug in a built-in tool) | **Patch** | `clacky patch_new <id> "Const#method" -d "<desc>"` | `clacky patch_verify` |
|
|
16
|
+
| **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) — no Ruby needed | **Shell Hook** | `clacky hook_new <id> -e <event>` | `clacky hook_verify` |
|
|
17
|
+
| Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook…) | **Channel Adapter** | `clacky channel_new <platform_id>` | `clacky channel_verify` |
|
|
18
|
+
|
|
19
|
+
## Authoritative documentation
|
|
20
|
+
|
|
21
|
+
Each mechanism has a full reference doc — read the relevant one with `web_fetch` before writing code:
|
|
22
|
+
|
|
23
|
+
- Patches → https://www.openclacky.com/docs/extend-patches
|
|
24
|
+
- Shell Hooks → https://www.openclacky.com/docs/extend-shell-hooks
|
|
25
|
+
- Channel Adapters → https://www.openclacky.com/docs/extend-channel-adapter
|
|
26
|
+
|
|
27
|
+
## Execution playbook
|
|
28
|
+
|
|
29
|
+
1. **Identify** which mechanism fits (use the table above; ask if genuinely ambiguous).
|
|
30
|
+
2. **Read the doc** for that mechanism with `web_fetch`. Don't guess fields, hook events, or required methods — the doc is the contract.
|
|
31
|
+
3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta.
|
|
32
|
+
4. **Edit** the generated file to implement the user's intent. Keep generated meta fields (`target`, `event`, `platform_id`, the `Clacky::ChannelRegistry.register(...)` line, etc.) intact unless the doc says otherwise.
|
|
33
|
+
5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim.
|
|
34
|
+
|
|
35
|
+
## When NOT to use this skill
|
|
36
|
+
|
|
37
|
+
- The user is building features in their own application that just *use* openclacky — that's normal coding, no patch/hook/channel needed.
|
|
38
|
+
- The user wants a brand-new tool/skill for *their* project — use `.clacky/skills/` or `.clacky/tools/`, not these gem-level mechanisms.
|
|
39
|
+
- The change can be made via `clacky config set ...` — prefer config over patches.
|
|
@@ -5,13 +5,6 @@ description: |
|
|
|
5
5
|
reconfigure. Edits ~/.clacky/mcp.json so the user never writes JSON by hand.
|
|
6
6
|
Trigger on: add mcp, install mcp, setup mcp, configure mcp, mcp list, mcp remove,
|
|
7
7
|
mcp probe, mcp reconfigure.
|
|
8
|
-
argument-hint: "add | list | probe <name> | remove <name> | reconfigure <name>"
|
|
9
|
-
allowed-tools:
|
|
10
|
-
- Bash
|
|
11
|
-
- Read
|
|
12
|
-
- Write
|
|
13
|
-
- Edit
|
|
14
|
-
- AskFollowupQuestion
|
|
15
8
|
---
|
|
16
9
|
|
|
17
10
|
# MCP Manager Skill
|
|
@@ -216,8 +216,8 @@ Silently run `ruby "SKILL_DIR/scripts/install_builtin_skills.rb"`,
|
|
|
216
216
|
then parse the last stdout line as JSON and read `installed` as N.
|
|
217
217
|
|
|
218
218
|
- If N > 0, show one line:
|
|
219
|
-
- zh: `✅ 已为你内置 N
|
|
220
|
-
- en: `✅ Installed N builtin skills
|
|
219
|
+
- zh: `✅ 已为你内置 N 个技能。`
|
|
220
|
+
- en: `✅ Installed N builtin skills.`
|
|
221
221
|
|
|
222
222
|
### A.10. Import external skills (optional)
|
|
223
223
|
|
|
@@ -101,8 +101,11 @@ module Clacky
|
|
|
101
101
|
emit("warning", message: message)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
def show_error(message)
|
|
105
|
-
|
|
104
|
+
def show_error(message, code: nil, top_up_url: nil)
|
|
105
|
+
payload = { message: message }
|
|
106
|
+
payload[:code] = code if code
|
|
107
|
+
payload[:top_up_url] = top_up_url if top_up_url
|
|
108
|
+
emit("error", **payload)
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
def show_success(message)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require "prism"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
# Prism is a stdlib on Ruby 3.3+. On older Rubies we fall back to
|
|
11
|
+
# RubyVM::AbstractSyntaxTree (available since 2.6).
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Clacky
|
|
15
|
+
# Runtime patch layer. Loads user/AI-authored patches from ~/.clacky/patches/
|
|
16
|
+
# that override existing methods via Module#prepend, WITHOUT touching the
|
|
17
|
+
# installed gem source (so `gem update` never loses them).
|
|
18
|
+
#
|
|
19
|
+
# Each patch lives in its own directory:
|
|
20
|
+
# ~/.clacky/patches/<id>/
|
|
21
|
+
# meta.yml declares target + a fingerprint of the original method source
|
|
22
|
+
# patch.rb a prepend module that overrides the target method
|
|
23
|
+
#
|
|
24
|
+
# Safety — fingerprint drift:
|
|
25
|
+
# meta.yml records a SHA256 of the targeted method's source at authoring time.
|
|
26
|
+
# Before applying, the loader recomputes the fingerprint of the method as it
|
|
27
|
+
# exists in the CURRENTLY installed gem. If they differ, the upstream code has
|
|
28
|
+
# changed and the patch may no longer be valid, so by default the patch is
|
|
29
|
+
# DISABLED (moved to _disabled/) rather than applied — a stale patch must never
|
|
30
|
+
# silently corrupt behavior.
|
|
31
|
+
#
|
|
32
|
+
# meta.yml:
|
|
33
|
+
# id: fix-web-search-timeout
|
|
34
|
+
# description: bump default timeout to 30s
|
|
35
|
+
# target: "Clacky::Tools::WebSearch#execute" # '#' = instance, '.' = class method
|
|
36
|
+
# fingerprint: "a3f8c…"
|
|
37
|
+
# gem_version: "0.7.0"
|
|
38
|
+
# on_mismatch: disable # disable | warn (default disable)
|
|
39
|
+
module PatchLoader
|
|
40
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/patches")
|
|
41
|
+
DISABLED_DIR = "_disabled"
|
|
42
|
+
|
|
43
|
+
Result = Struct.new(:applied, :disabled, :skipped, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def load_all(dir: DEFAULT_DIR)
|
|
47
|
+
result = Result.new(applied: [], disabled: [], skipped: [])
|
|
48
|
+
if Dir.exist?(dir)
|
|
49
|
+
Dir.glob(File.join(dir, "*", "meta.yml")).sort.each do |meta_path|
|
|
50
|
+
patch_dir = File.dirname(meta_path)
|
|
51
|
+
next if File.basename(File.dirname(patch_dir)) == DISABLED_DIR
|
|
52
|
+
|
|
53
|
+
apply_one(patch_dir, meta_path, result)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
@last_result = result
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def last_result
|
|
61
|
+
@last_result || load_all
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Generate a ready-to-edit patch (meta.yml + patch.rb) for a target method.
|
|
65
|
+
# Computes the current fingerprint automatically so the author never does it
|
|
66
|
+
# by hand. The patch.rb skeleton prepends a module that overrides the method
|
|
67
|
+
# and calls super by default.
|
|
68
|
+
# @param target [String] "Const::Path#method" or "Const::Path.method"
|
|
69
|
+
# @return [String] path to the new patch directory
|
|
70
|
+
def scaffold(id, target, description: "", dir: DEFAULT_DIR)
|
|
71
|
+
slug = id.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
72
|
+
raise ArgumentError, "invalid patch id: #{id.inspect}" if slug.empty?
|
|
73
|
+
|
|
74
|
+
fp = fingerprint(target) # also validates the target resolves
|
|
75
|
+
|
|
76
|
+
patch_dir = File.join(dir, slug)
|
|
77
|
+
raise ArgumentError, "patch already exists: #{patch_dir}" if Dir.exist?(patch_dir)
|
|
78
|
+
|
|
79
|
+
FileUtils.mkdir_p(patch_dir)
|
|
80
|
+
File.write(File.join(patch_dir, "meta.yml"), <<~YAML)
|
|
81
|
+
id: #{slug}
|
|
82
|
+
description: #{description.to_s.empty? ? "(describe what this fixes)" : description}
|
|
83
|
+
target: "#{target}"
|
|
84
|
+
fingerprint: "#{fp}"
|
|
85
|
+
gem_version: "#{Clacky::VERSION}"
|
|
86
|
+
on_mismatch: disable
|
|
87
|
+
YAML
|
|
88
|
+
File.write(File.join(patch_dir, "patch.rb"), patch_skeleton(slug, target))
|
|
89
|
+
patch_dir
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def patch_skeleton(slug, target)
|
|
93
|
+
const_name, sep, method_name = target.partition(/[#.]/)
|
|
94
|
+
mod_const = "Patch_#{slug.gsub(/[^a-zA-Z0-9_]/, "_")}"
|
|
95
|
+
prepend_target = sep == "#" ? const_name : "#{const_name}.singleton_class"
|
|
96
|
+
|
|
97
|
+
<<~RUBY
|
|
98
|
+
# frozen_string_literal: true
|
|
99
|
+
|
|
100
|
+
# Patch for #{target}
|
|
101
|
+
# Only edit the method body below. Call `super` to keep the original behavior.
|
|
102
|
+
module #{mod_const}
|
|
103
|
+
def #{method_name}(*args, **kwargs, &blk)
|
|
104
|
+
# TODO: your fix here. Examples:
|
|
105
|
+
# result = super
|
|
106
|
+
# result
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
#{prepend_target}.prepend(#{mod_const})
|
|
112
|
+
RUBY
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Recompute the fingerprint of a target's method as currently installed.
|
|
116
|
+
# @param target [String] "Const::Path#instance_method" or "Const::Path.class_method"
|
|
117
|
+
# @return [String] SHA256 hex of the method's source
|
|
118
|
+
# @raise [RuntimeError] if the target can't be resolved
|
|
119
|
+
def fingerprint(target)
|
|
120
|
+
meth = original_method(resolve_method(target))
|
|
121
|
+
file, lineno = meth.source_location
|
|
122
|
+
raise "no source location for #{target} (defined in C or eval?)" unless file && lineno
|
|
123
|
+
|
|
124
|
+
first, last = method_line_range(file, lineno, meth.name, meth)
|
|
125
|
+
raise "cannot locate source for #{target} in #{file}:#{lineno}" unless first && last
|
|
126
|
+
|
|
127
|
+
lines = File.readlines(file)[(first - 1)...last]
|
|
128
|
+
Digest::SHA256.hexdigest(lines.join)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def method_line_range(file, lineno, name, meth)
|
|
132
|
+
if defined?(Prism)
|
|
133
|
+
range = prism_line_range(file, lineno, name)
|
|
134
|
+
return range if range
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
ast_line_range(meth)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def prism_line_range(file, lineno, name)
|
|
141
|
+
result = Prism.parse_file(file)
|
|
142
|
+
return nil unless result.success?
|
|
143
|
+
|
|
144
|
+
node = find_def_at(result.value, lineno, name.to_sym)
|
|
145
|
+
return nil unless node
|
|
146
|
+
|
|
147
|
+
loc = node.location
|
|
148
|
+
[loc.start_line, loc.end_line]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def find_def_at(node, lineno, name)
|
|
152
|
+
return nil unless node
|
|
153
|
+
|
|
154
|
+
if node.is_a?(Prism::DefNode) && node.name == name && node.location.start_line == lineno
|
|
155
|
+
return node
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
node.compact_child_nodes.each do |child|
|
|
159
|
+
found = find_def_at(child, lineno, name)
|
|
160
|
+
return found if found
|
|
161
|
+
end
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ast_line_range(meth)
|
|
166
|
+
return nil unless defined?(RubyVM::AbstractSyntaxTree)
|
|
167
|
+
|
|
168
|
+
node = RubyVM::AbstractSyntaxTree.of(meth)
|
|
169
|
+
return nil unless node
|
|
170
|
+
|
|
171
|
+
[node.first_lineno, node.last_lineno]
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Walk past any methods introduced by our own patches (files under the
|
|
177
|
+
# patches dir) so the fingerprint always reflects the original upstream
|
|
178
|
+
# definition, even after a prepend has already been applied.
|
|
179
|
+
def original_method(meth)
|
|
180
|
+
current = meth
|
|
181
|
+
while current
|
|
182
|
+
file, = current.source_location
|
|
183
|
+
break if file.nil? || !file.start_with?(DEFAULT_DIR)
|
|
184
|
+
|
|
185
|
+
nxt = current.super_method
|
|
186
|
+
break if nxt.nil?
|
|
187
|
+
|
|
188
|
+
current = nxt
|
|
189
|
+
end
|
|
190
|
+
current
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def resolve_method(target)
|
|
194
|
+
if target.include?("#")
|
|
195
|
+
const_name, method_name = target.split("#", 2)
|
|
196
|
+
const = resolve_const(const_name)
|
|
197
|
+
const.instance_method(method_name.to_sym)
|
|
198
|
+
elsif target.include?(".")
|
|
199
|
+
const_name, method_name = target.split(".", 2)
|
|
200
|
+
const = resolve_const(const_name)
|
|
201
|
+
const.method(method_name.to_sym)
|
|
202
|
+
else
|
|
203
|
+
raise "invalid target (need '#' or '.'): #{target}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def apply_one(patch_dir, meta_path, result)
|
|
208
|
+
id = File.basename(patch_dir)
|
|
209
|
+
meta = YAMLCompat.load_file(meta_path) || {}
|
|
210
|
+
target = meta["target"].to_s
|
|
211
|
+
recorded = meta["fingerprint"].to_s
|
|
212
|
+
|
|
213
|
+
if target.empty? || recorded.empty?
|
|
214
|
+
result.skipped << [id, "meta.yml missing target or fingerprint"]
|
|
215
|
+
log(:warn, id, result.skipped.last[1])
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
current = begin
|
|
220
|
+
fingerprint(target)
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
result.skipped << [id, "cannot fingerprint #{target}: #{e.message}"]
|
|
223
|
+
log(:warn, id, result.skipped.last[1])
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if current != recorded
|
|
228
|
+
handle_mismatch(patch_dir, id, meta, result)
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
patch_rb = File.join(patch_dir, "patch.rb")
|
|
233
|
+
unless File.exist?(patch_rb)
|
|
234
|
+
result.skipped << [id, "patch.rb not found"]
|
|
235
|
+
log(:warn, id, result.skipped.last[1])
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
require patch_rb
|
|
240
|
+
result.applied << id
|
|
241
|
+
log(:info, id, "applied → #{target}")
|
|
242
|
+
rescue StandardError, ScriptError => e
|
|
243
|
+
result.skipped << [id, e.message]
|
|
244
|
+
log(:warn, id, e.message)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def handle_mismatch(patch_dir, id, meta, result)
|
|
248
|
+
reason = "fingerprint mismatch — upstream code for #{meta["target"]} changed"
|
|
249
|
+
if meta["on_mismatch"].to_s == "warn"
|
|
250
|
+
result.skipped << [id, "#{reason} (kept, not applied)"]
|
|
251
|
+
log(:warn, id, result.skipped.last[1])
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
disable!(patch_dir, id)
|
|
256
|
+
result.disabled << [id, reason]
|
|
257
|
+
log(:warn, id, "#{reason} — disabled")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def disable!(patch_dir, id)
|
|
261
|
+
base = File.dirname(patch_dir)
|
|
262
|
+
dest_root = File.join(base, DISABLED_DIR)
|
|
263
|
+
FileUtils.mkdir_p(dest_root)
|
|
264
|
+
dest = File.join(dest_root, id)
|
|
265
|
+
FileUtils.rm_rf(dest)
|
|
266
|
+
FileUtils.mv(patch_dir, dest)
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
log(:error, id, "failed to disable: #{e.message}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def resolve_const(name)
|
|
272
|
+
name.split("::").reject(&:empty?).inject(Object) do |mod, part|
|
|
273
|
+
mod.const_get(part)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def log(level, id, msg)
|
|
278
|
+
Clacky::Logger.public_send(level, "[PatchLoader] #{id}: #{msg}")
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Clacky
|
|
|
31
31
|
"api" => "bedrock",
|
|
32
32
|
"default_model" => "abs-claude-sonnet-4-6",
|
|
33
33
|
"models" => [
|
|
34
|
+
"abs-claude-opus-4-8",
|
|
34
35
|
"abs-claude-opus-4-7",
|
|
35
36
|
"abs-claude-opus-4-6",
|
|
36
37
|
"abs-claude-sonnet-4-6",
|
|
@@ -61,6 +62,7 @@ module Clacky
|
|
|
61
62
|
# sibling wired up (yet) on this provider; subagents using the
|
|
62
63
|
# Gemini default will just reuse it for lite work until we add one.
|
|
63
64
|
"lite_models" => {
|
|
65
|
+
"abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
|
|
64
66
|
"abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
|
|
65
67
|
"abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
|
|
66
68
|
"abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
|
|
@@ -88,6 +90,7 @@ module Clacky
|
|
|
88
90
|
# ID manually; this list only seeds the picker.
|
|
89
91
|
"models" => [
|
|
90
92
|
"anthropic/claude-sonnet-4-6",
|
|
93
|
+
"anthropic/claude-opus-4-8",
|
|
91
94
|
"anthropic/claude-opus-4-7",
|
|
92
95
|
"anthropic/claude-opus-4-6",
|
|
93
96
|
"anthropic/claude-haiku-4-5",
|
|
@@ -101,6 +104,7 @@ module Clacky
|
|
|
101
104
|
# cheap/fast sidekick automatically.
|
|
102
105
|
"lite_models" => {
|
|
103
106
|
"anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
|
|
107
|
+
"anthropic/claude-opus-4-8" => "anthropic/claude-haiku-4-5",
|
|
104
108
|
"anthropic/claude-opus-4-7" => "anthropic/claude-haiku-4-5",
|
|
105
109
|
"anthropic/claude-opus-4-6" => "anthropic/claude-haiku-4-5",
|
|
106
110
|
"openai/gpt-5.5" => "openai/gpt-5.4-mini",
|
|
@@ -232,8 +236,8 @@ module Clacky
|
|
|
232
236
|
"name" => "Anthropic (Claude)",
|
|
233
237
|
"base_url" => "https://api.anthropic.com",
|
|
234
238
|
"api" => "anthropic-messages",
|
|
235
|
-
"default_model" => "claude-sonnet-4
|
|
236
|
-
"models" => ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4
|
|
239
|
+
"default_model" => "claude-sonnet-4-6",
|
|
240
|
+
"models" => ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
|
237
241
|
"website_url" => "https://console.anthropic.com/settings/keys"
|
|
238
242
|
}.freeze,
|
|
239
243
|
|
|
@@ -545,6 +549,11 @@ module Clacky
|
|
|
545
549
|
return "openclacky"
|
|
546
550
|
end
|
|
547
551
|
|
|
552
|
+
if base_url.is_a?(String) &&
|
|
553
|
+
base_url.match?(%r{\Ahttps?://(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|/|\z)}i)
|
|
554
|
+
return "openclacky"
|
|
555
|
+
end
|
|
556
|
+
|
|
548
557
|
nil
|
|
549
558
|
end
|
|
550
559
|
|