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.
@@ -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
- # Read-only. Requires the client's **private** key — the public key is write-only here and is
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, response = get_json_with_headers(list_url(query), ERROR_NOUN)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -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
- # Read-only. Requires the client's **private** key. There is no per-id endpoint.
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, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
- raise Error, "#{ERROR_NOUN} response was not a JSON array" unless workloads.is_a?(Array)
39
+ fetch_list(query, WorkloadSearchResult, :workloads, page: page, per: per)
40
+ end
39
41
 
40
- WorkloadSearchResult.new(
41
- workloads: workloads,
42
- pagination: Pagination.from_headers(response, items: workloads, page: page, per: per)
43
- ).freeze
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
- previous = Thread.current[:coolhand_capture_override]
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
- previous = Thread.current[:coolhand_capture_override]
87
- Thread.current[:coolhand_capture_override] = true
88
- yield
89
- ensure
90
- Thread.current[:coolhand_capture_override] = previous
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.8.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-14 00:00:00.000000000 Z
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