coolhand 0.8.0 → 0.10.0
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 +28 -0
- data/README.md +46 -2
- data/Rakefile +7 -0
- data/docs/declared_workloads.md +156 -0
- data/docs/help-center.md +120 -0
- data/docs/openai.md +4 -0
- data/lib/coolhand/api_service.rb +5 -1
- data/lib/coolhand/base_interceptor.rb +3 -1
- data/lib/coolhand/configuration.rb +14 -1
- data/lib/coolhand/declarable.rb +178 -0
- data/lib/coolhand/declared_context.rb +106 -0
- data/lib/coolhand/log_service.rb +1 -7
- data/lib/coolhand/logger_service.rb +11 -19
- data/lib/coolhand/net_http_interceptor.rb +3 -2
- data/lib/coolhand/open_ai/webhook_validator.rb +27 -15
- data/lib/coolhand/public_content_service.rb +69 -0
- data/lib/coolhand/rails_auto_declare.rb +55 -0
- data/lib/coolhand/railtie.rb +11 -0
- data/lib/coolhand/read_query.rb +14 -0
- data/lib/coolhand/read_requests.rb +11 -2
- data/lib/coolhand/response_warnings.rb +39 -0
- data/lib/coolhand/template_service.rb +26 -10
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/workload_service.rb +12 -8
- data/lib/coolhand/write_requests.rb +14 -0
- data/lib/coolhand.rb +41 -12
- metadata +10 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Prints the `warnings` a Coolhand log response carries, for {ApiService}.
|
|
5
|
+
#
|
|
6
|
+
# Warnings come from the response body and are written to the host app's stdout, so they are
|
|
7
|
+
# untrusted: each is coerced to a string, stripped of control characters and length-capped, and
|
|
8
|
+
# the number printed per response is bounded.
|
|
9
|
+
module ResponseWarnings
|
|
10
|
+
MAX_WARNING_LENGTH = 500
|
|
11
|
+
MAX_WARNINGS = 5
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
# The server ingests a log normally but ignores a declared workload/template it cannot honour
|
|
16
|
+
# (e.g. over the public-key cap), and says so here.
|
|
17
|
+
def log_response_warnings(result)
|
|
18
|
+
warnings = result.is_a?(Hash) ? Array(result[:warnings]) : []
|
|
19
|
+
warnings.first(MAX_WARNINGS).each { |warning| log "⚠️ Coolhand: #{sanitize_warning(warning)}" }
|
|
20
|
+
|
|
21
|
+
omitted = warnings.size - MAX_WARNINGS
|
|
22
|
+
log "⚠️ Coolhand: ... and #{omitted} more warning(s) omitted" if omitted.positive?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Control characters and line/paragraph separators are replaced with a space, not dropped: a
|
|
28
|
+
# newline would forge a log line and ESC starts a terminal escape sequence. Unicode format
|
|
29
|
+
# characters (bidi overrides, zero-width) are invisible and can reorder or hide text in a
|
|
30
|
+
# terminal, so they are removed outright.
|
|
31
|
+
def sanitize_warning(warning)
|
|
32
|
+
text = warning.to_s.dup.force_encoding("UTF-8").scrub
|
|
33
|
+
text = text.gsub(/\p{Cf}+/, "").gsub(/[[:cntrl:]\p{Zl}\p{Zp}]+/, " ").strip
|
|
34
|
+
return text if text.length <= MAX_WARNING_LENGTH
|
|
35
|
+
|
|
36
|
+
"#{text[0, MAX_WARNING_LENGTH]}... [truncated]"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
require_relative "api_service"
|
|
4
4
|
require_relative "pagination"
|
|
5
5
|
require_relative "read_query"
|
|
6
|
+
require_relative "write_requests"
|
|
6
7
|
|
|
7
8
|
module Coolhand
|
|
8
9
|
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
10
|
TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
|
|
10
11
|
|
|
11
|
-
#
|
|
12
|
-
# rejected like an invalid one.
|
|
12
|
+
# Reads templates and upserts them by key. Requires the client's **private** key — the public key is
|
|
13
|
+
# write-only here and is rejected like an invalid one.
|
|
13
14
|
#
|
|
14
15
|
# Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
|
|
15
|
-
# See docs/template-search.md.
|
|
16
|
+
# See docs/template-search.md and docs/declared_workloads.md.
|
|
16
17
|
class TemplateService < ApiService
|
|
17
18
|
include ReadQuery
|
|
19
|
+
include WriteRequests
|
|
18
20
|
|
|
19
21
|
ERROR_NOUN = "Template"
|
|
20
22
|
BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
|
|
@@ -39,13 +41,7 @@ module Coolhand
|
|
|
39
41
|
per: per
|
|
40
42
|
}.compact
|
|
41
43
|
|
|
42
|
-
templates,
|
|
43
|
-
raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
|
|
44
|
-
|
|
45
|
-
TemplateSearchResult.new(
|
|
46
|
-
templates: templates,
|
|
47
|
-
pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
|
|
48
|
-
).freeze
|
|
44
|
+
fetch_list(query, TemplateSearchResult, :templates, page: page, per: per)
|
|
49
45
|
end
|
|
50
46
|
|
|
51
47
|
# Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
|
|
@@ -56,5 +52,25 @@ module Coolhand
|
|
|
56
52
|
**window_params(since, binding.local_variable_get(:until)) }.compact
|
|
57
53
|
get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
|
|
58
54
|
end
|
|
55
|
+
|
|
56
|
+
# Creates the template with this `key`, or updates the existing one. The key is immutable; a new
|
|
57
|
+
# `name` renames the display name. `workload` (`{ id: }` or `{ key: }`, the latter find-or-created)
|
|
58
|
+
# is needed only when the template is new; an existing one keeps its workload. Returns the template
|
|
59
|
+
# Hash. Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
|
|
60
|
+
def upsert(key:, workload: nil, name: nil, reporter: nil)
|
|
61
|
+
body = { name: name, reporter: reporter, **workload_params(workload) }.compact
|
|
62
|
+
request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def workload_params(workload)
|
|
68
|
+
return {} if workload.nil?
|
|
69
|
+
unless workload.is_a?(Hash)
|
|
70
|
+
raise ArgumentError, "upsert: workload must be a Hash with :id or :key, got #{workload.class}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
{ workload_id: workload[:id] || workload["id"], workload_key: workload[:key] || workload["key"] }
|
|
74
|
+
end
|
|
59
75
|
end
|
|
60
76
|
end
|
data/lib/coolhand/version.rb
CHANGED
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
require_relative "api_service"
|
|
4
4
|
require_relative "pagination"
|
|
5
5
|
require_relative "read_query"
|
|
6
|
+
require_relative "write_requests"
|
|
6
7
|
|
|
7
8
|
module Coolhand
|
|
8
9
|
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
10
|
WorkloadSearchResult = Struct.new(:workloads, :pagination, keyword_init: true)
|
|
10
11
|
|
|
11
|
-
#
|
|
12
|
-
# See docs/workload-search.md.
|
|
12
|
+
# Lists workloads and upserts them by key. Requires the client's **private** key. There is no per-id
|
|
13
|
+
# endpoint. See docs/workload-search.md and docs/declared_workloads.md.
|
|
13
14
|
class WorkloadService < ApiService
|
|
14
15
|
include ReadQuery
|
|
16
|
+
include WriteRequests
|
|
15
17
|
|
|
16
18
|
ERROR_NOUN = "Workload"
|
|
17
19
|
|
|
@@ -34,13 +36,15 @@ module Coolhand
|
|
|
34
36
|
per: per
|
|
35
37
|
}.compact
|
|
36
38
|
|
|
37
|
-
workloads,
|
|
38
|
-
|
|
39
|
+
fetch_list(query, WorkloadSearchResult, :workloads, page: page, per: per)
|
|
40
|
+
end
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Creates the workload with this `key`, or updates the existing one. The key is immutable; a new
|
|
43
|
+
# `name` renames the display name. Returns the workload Hash (the server answers 201 on create,
|
|
44
|
+
# 200 on update). Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
|
|
45
|
+
def upsert(key:, name: nil, description: nil, reporter: nil)
|
|
46
|
+
body = { name: name, description: description, reporter: reporter }.compact
|
|
47
|
+
request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
|
|
44
48
|
end
|
|
45
49
|
end
|
|
46
50
|
end
|
|
@@ -13,6 +13,8 @@ module Coolhand
|
|
|
13
13
|
# `api_key`, `apply_headers` and `format_error_body` from there and the shared HTTP helpers from
|
|
14
14
|
# {ReadRequests}.
|
|
15
15
|
module WriteRequests
|
|
16
|
+
DOT_SEGMENTS = [".", ".."].freeze
|
|
17
|
+
|
|
16
18
|
protected
|
|
17
19
|
|
|
18
20
|
# Returns the parsed body, or nil for an empty one (204).
|
|
@@ -26,6 +28,18 @@ module Coolhand
|
|
|
26
28
|
response.body.to_s.strip.empty? ? nil : parse_json_body(response.body, noun)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# `<endpoint>/by_key/<key>` for the upsert routes, which are globs: a key like "Parser#call/squish:L2"
|
|
32
|
+
# keeps its slashes and each segment is escaped, so `#` or `?` in a key cannot retarget the request.
|
|
33
|
+
def by_key_url(method_name, key)
|
|
34
|
+
trimmed = key.is_a?(String) ? key.strip : ""
|
|
35
|
+
segments = trimmed.split("/", -1)
|
|
36
|
+
if trimmed.empty? || segments.any? { |segment| segment.empty? || DOT_SEGMENTS.include?(segment) }
|
|
37
|
+
raise Error, "#{method_name}: key must be a non-empty string without empty or dot path segments"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
URI.parse("#{api_endpoint}/by_key/#{segments.map { |segment| escape_path_segment(segment) }.join('/')}")
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
private
|
|
30
44
|
|
|
31
45
|
def perform_write(verb, url, noun, body)
|
data/lib/coolhand.rb
CHANGED
|
@@ -10,6 +10,9 @@ require_relative "coolhand/errors"
|
|
|
10
10
|
require_relative "coolhand/version"
|
|
11
11
|
require_relative "coolhand/configuration"
|
|
12
12
|
require_relative "coolhand/collector"
|
|
13
|
+
require_relative "coolhand/declared_context"
|
|
14
|
+
require_relative "coolhand/declarable"
|
|
15
|
+
require_relative "coolhand/rails_auto_declare"
|
|
13
16
|
require_relative "coolhand/log_queue"
|
|
14
17
|
require_relative "coolhand/base_interceptor"
|
|
15
18
|
require_relative "coolhand/net_http_interceptor"
|
|
@@ -19,8 +22,11 @@ require_relative "coolhand/feedback_service"
|
|
|
19
22
|
require_relative "coolhand/template_service"
|
|
20
23
|
require_relative "coolhand/optimization_feedback_link_service"
|
|
21
24
|
require_relative "coolhand/workload_service"
|
|
25
|
+
require_relative "coolhand/public_content_service"
|
|
22
26
|
require_relative "coolhand/log_service"
|
|
23
27
|
require_relative "coolhand/webhook_interceptor"
|
|
28
|
+
# Only when Rails is already loaded; the gem has no Rails dependency.
|
|
29
|
+
require_relative "coolhand/railtie" if defined?(Rails::Railtie)
|
|
24
30
|
|
|
25
31
|
# The main module for the Coolhand gem.
|
|
26
32
|
# It provides the configuration interface and initializes the patching.
|
|
@@ -35,6 +41,7 @@ module Coolhand
|
|
|
35
41
|
def reset_configuration!
|
|
36
42
|
@configuration = Configuration.new
|
|
37
43
|
Thread.current[:coolhand_capture_override] = nil
|
|
44
|
+
DeclaredContext.clear!
|
|
38
45
|
end
|
|
39
46
|
|
|
40
47
|
# Provides a block to configure the gem.
|
|
@@ -74,20 +81,25 @@ module Coolhand
|
|
|
74
81
|
end
|
|
75
82
|
end
|
|
76
83
|
|
|
77
|
-
def without_capture
|
|
78
|
-
|
|
79
|
-
Thread.current[:coolhand_capture_override] = false
|
|
80
|
-
yield
|
|
81
|
-
ensure
|
|
82
|
-
Thread.current[:coolhand_capture_override] = previous
|
|
84
|
+
def without_capture(&block)
|
|
85
|
+
override_capture(false, &block)
|
|
83
86
|
end
|
|
84
87
|
|
|
85
|
-
def with_capture
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
def with_capture(&block)
|
|
89
|
+
override_capture(true, &block)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Declares the workload/template that LLM calls captured inside the block belong to. Nested
|
|
93
|
+
# blocks override per field (`reporter`, `workload`, `template`), innermost wins. `workload` and
|
|
94
|
+
# `template` are Hashes with `id:` (an existing record) or `key:` (find-or-created), plus optional
|
|
95
|
+
# `name:` (and `description:` on a workload). See docs/declared_workloads.md.
|
|
96
|
+
#
|
|
97
|
+
# Example:
|
|
98
|
+
# Coolhand.with_context(reporter: "squishling", workload: { key: "Parser#call" }) { client.chat(...) }
|
|
99
|
+
def with_context(reporter: nil, workload: nil, template: nil, &block)
|
|
100
|
+
raise ArgumentError, "Coolhand.with_context requires a block" unless block
|
|
101
|
+
|
|
102
|
+
DeclaredContext.with(reporter: reporter, workload: workload, template: template, &block)
|
|
91
103
|
end
|
|
92
104
|
|
|
93
105
|
# Blocks until log requests queued by async logging have been sent, or the timeout (in
|
|
@@ -136,6 +148,12 @@ module Coolhand
|
|
|
136
148
|
LogService.new
|
|
137
149
|
end
|
|
138
150
|
|
|
151
|
+
# Creates a new PublicContentService, for reading the Coolhand help center. Needs no API key; a
|
|
152
|
+
# configured one is sent and lifts the anonymous rate limit.
|
|
153
|
+
def public_content_service
|
|
154
|
+
PublicContentService.new
|
|
155
|
+
end
|
|
156
|
+
|
|
139
157
|
# Creates a new OptimizationFeedbackLinkService instance, for linking feedback to an
|
|
140
158
|
# optimization as evidence. Needs the private API key.
|
|
141
159
|
def optimization_feedback_link_service
|
|
@@ -149,5 +167,16 @@ module Coolhand
|
|
|
149
167
|
|
|
150
168
|
true
|
|
151
169
|
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# Forces capture on or off for the block on this thread, restoring the previous override after.
|
|
174
|
+
def override_capture(enabled)
|
|
175
|
+
previous = Thread.current[:coolhand_capture_override]
|
|
176
|
+
Thread.current[:coolhand_capture_override] = enabled
|
|
177
|
+
yield
|
|
178
|
+
ensure
|
|
179
|
+
Thread.current[:coolhand_capture_override] = previous
|
|
180
|
+
end
|
|
152
181
|
end
|
|
153
182
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coolhand
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Carroll
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-10-
|
|
12
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: base64
|
|
@@ -47,9 +47,11 @@ files:
|
|
|
47
47
|
- SECURITY.md
|
|
48
48
|
- docs/anthropic.md
|
|
49
49
|
- docs/configuration.md
|
|
50
|
+
- docs/declared_workloads.md
|
|
50
51
|
- docs/elevenlabs.md
|
|
51
52
|
- docs/feedback-links.md
|
|
52
53
|
- docs/feedback.md
|
|
54
|
+
- docs/help-center.md
|
|
53
55
|
- docs/log-search.md
|
|
54
56
|
- docs/openai.md
|
|
55
57
|
- docs/template-search.md
|
|
@@ -60,6 +62,8 @@ files:
|
|
|
60
62
|
- lib/coolhand/base_interceptor.rb
|
|
61
63
|
- lib/coolhand/collector.rb
|
|
62
64
|
- lib/coolhand/configuration.rb
|
|
65
|
+
- lib/coolhand/declarable.rb
|
|
66
|
+
- lib/coolhand/declared_context.rb
|
|
63
67
|
- lib/coolhand/default_exclude_api_patterns.yml
|
|
64
68
|
- lib/coolhand/default_intercept_addresses.yml
|
|
65
69
|
- lib/coolhand/default_intercept_path_patterns.yml
|
|
@@ -74,8 +78,12 @@ files:
|
|
|
74
78
|
- lib/coolhand/open_ai/webhook_validator.rb
|
|
75
79
|
- lib/coolhand/optimization_feedback_link_service.rb
|
|
76
80
|
- lib/coolhand/pagination.rb
|
|
81
|
+
- lib/coolhand/public_content_service.rb
|
|
82
|
+
- lib/coolhand/rails_auto_declare.rb
|
|
83
|
+
- lib/coolhand/railtie.rb
|
|
77
84
|
- lib/coolhand/read_query.rb
|
|
78
85
|
- lib/coolhand/read_requests.rb
|
|
86
|
+
- lib/coolhand/response_warnings.rb
|
|
79
87
|
- lib/coolhand/template_service.rb
|
|
80
88
|
- lib/coolhand/version.rb
|
|
81
89
|
- lib/coolhand/vertex/batch_result_processor.rb
|