kairos-chain 3.42.1 → 3.43.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/lib/kairos_mcp/version.rb +1 -1
  4. data/templates/knowledge/loop_engineering_patterns/loop_engineering_patterns.md +5 -2
  5. data/templates/skillsets/hestia/config/hestia.yml +24 -0
  6. data/templates/skillsets/hestia/lib/hestia/import_command_generator.rb +43 -0
  7. data/templates/skillsets/hestia/lib/hestia/place_router.rb +110 -1
  8. data/templates/skillsets/hestia/lib/hestia/public_presenter.rb +86 -0
  9. data/templates/skillsets/hestia/lib/hestia/public_rate_limiter.rb +47 -0
  10. data/templates/skillsets/hestia/lib/hestia/skill_auditor.rb +395 -0
  11. data/templates/skillsets/hestia/lib/hestia/skill_board.rb +8 -0
  12. data/templates/skillsets/hestia/lib/hestia/web/assets/marketplace.css +101 -0
  13. data/templates/skillsets/hestia/lib/hestia/web/assets/pico.min.css +4 -0
  14. data/templates/skillsets/hestia/lib/hestia/web/views/web_about.erb +49 -0
  15. data/templates/skillsets/hestia/lib/hestia/web/views/web_agent.erb +46 -0
  16. data/templates/skillsets/hestia/lib/hestia/web/views/web_anchor.erb +53 -0
  17. data/templates/skillsets/hestia/lib/hestia/web/views/web_index.erb +64 -0
  18. data/templates/skillsets/hestia/lib/hestia/web/views/web_layout.erb +33 -0
  19. data/templates/skillsets/hestia/lib/hestia/web/views/web_skill.erb +132 -0
  20. data/templates/skillsets/hestia/lib/hestia/web/views/web_verify.erb +31 -0
  21. data/templates/skillsets/hestia/lib/hestia/web_router.rb +339 -0
  22. data/templates/skillsets/hestia/lib/hestia.rb +5 -0
  23. data/templates/skillsets/hestia/skillset.json +1 -1
  24. data/templates/skillsets/synoptis/lib/synoptis/anchoring/containment.rb +206 -0
  25. data/templates/skillsets/synoptis/lib/synoptis/anchoring/deposit_board.rb +286 -0
  26. data/templates/skillsets/synoptis/lib/synoptis/anchoring/entry.rb +188 -0
  27. data/templates/skillsets/synoptis/lib/synoptis/anchoring/log.rb +492 -0
  28. data/templates/skillsets/synoptis/lib/synoptis/anchoring/public_read.rb +57 -0
  29. data/templates/skillsets/synoptis/lib/synoptis/anchoring/public_verifier.rb +92 -0
  30. data/templates/skillsets/synoptis/lib/synoptis/anchoring/read_path.rb +70 -0
  31. data/templates/skillsets/synoptis/lib/synoptis/anchoring/write_budget.rb +121 -0
  32. data/templates/skillsets/synoptis/lib/synoptis/anchoring/write_path.rb +99 -0
  33. data/templates/skillsets/synoptis/lib/synoptis.rb +11 -0
  34. metadata +24 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d6b5abadc2727f37c441c8d0442da6470438537ad47f387e6c4021769c678dd
4
- data.tar.gz: f5c625947d73fb41251829cf638b397308294daa4fed7ed94cae945ad6086b72
3
+ metadata.gz: 4ef5b418a3202426bfd13d7f62a849363f52144978f6b091c9ebb8bb43564927
4
+ data.tar.gz: 471e635837cc5e5b65cd704dd78a9b48bd7312f61f7a27aa420c5323c5c0d43b
5
5
  SHA512:
6
- metadata.gz: 4948c9a12280c2ccc7e0083e056d0ad99d457fcb11d86761f060a891d2033924395521364b5f867ca30eec2cc806f5d539ccc22282657c31e31a0695a5270f55
7
- data.tar.gz: 789de8f265c7d1dbbde4969643e26f3359887b3417ddd76fe5567348b41c9dc8c37b2f062f6e77d382abc49becd4a2703ac4de29cf8d2f63b62c9fef272a5db1
6
+ metadata.gz: e45299ac0b7b574419742d6b8243fbd9b492641c37e5f5f1e81e22ea77623e2cf986ca96da63dfdde8cc5f08bef07ff83bb42ab816296c4fe50a002d80805ae5
7
+ data.tar.gz: 8138a96211b233b85f9e6342a32f2a02745dbf230ad319c274880c659c444ad03bf1909013864a79abeab2dccba7d754edbd3350506e7ffd751b112aac2803b0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.42.2] - 2026-07-14
8
+
9
+ ### Knowledge — loop_engineering_patterns §B: Model/Effort budget allocation
10
+
11
+ Extended the `loop_engineering_patterns` L1 entry (0.3 → 0.4) with a §B
12
+ design-craft note distilled from Anthropic's *Model and Effort in Claude Code*
13
+ post:
14
+
15
+ - Model (intelligence ceiling) and effort (per-request thoroughness) are
16
+ distinct levers; raise model when logic/knowledge is the wall, raise effort
17
+ when the loop skips steps.
18
+ - Because a loop compounds turns, uniform high effort multiplies latency —
19
+ concentrate high effort on the verify/judgment/decision gates, keep mechanical
20
+ dispatch turns low.
21
+ - The lever is the *orchestrator's*; a worker in a separate process (hermes body
22
+ subprocess, `agent`-skillset step) carries its own config and is unaffected.
23
+ - KairosChain delta: the non-bypassable gates (Prop 10 consent/audit floor,
24
+ INV-5 layer guard, verdict track) are where effort must not be cut — a stop
25
+ there is an irreversible Kairos moment (Prop 5), not a throughput step.
26
+
27
+ Added the `effort-allocation` tag and a "high/low effort for a long run?"
28
+ entry-point line. No code or behavior change.
29
+
7
30
  ## [3.42.1] - 2026-07-14
8
31
 
9
32
  ### Fixed — SectionWriter auto-chunk completeness & duplicate tail
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.42.1"
2
+ VERSION = "3.43.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: loop_engineering_patterns
3
3
  description: Use before designing or classifying an agent loop in KairosChain — to pick a loop type, apply loop-design craft, and place each loop at the right layer under the Prop 10 floor. Claude Code's four loop types + design principles, extended with the governance constraint Anthropic omits.
4
- version: "0.3"
5
- tags: [loops, agent, autonomy, taxonomy, layer-placement, provenance]
4
+ version: "0.4"
5
+ tags: [loops, agent, autonomy, taxonomy, layer-placement, provenance, effort-allocation]
6
6
  ---
7
7
 
8
8
  # Loop Engineering Patterns
@@ -14,6 +14,7 @@ Open *before* designing or classifying an agent loop — not a runtime tool:
14
14
  - New agent loop / autonomous cycle → pick a §A type, apply §B craft, then place it via §C.
15
15
  - "Which layer owns this loop?" (harness vs gem/SkillSet, plus the cross-cutting L0 governance floor) → §C table + the L0 note below it.
16
16
  - Strengthening the `agent` OODA loop (self-verification, semantic stop) → §B + §C evaluator/verifier notes.
17
+ - "High/low effort for a long run?" → §B budget-allocation note (concentrate effort on §C's non-bypassable gates).
17
18
  - Explaining why KairosChain's autonomous mode keeps human checkpoints → §C Prop 10 floor.
18
19
  - Not for: skill lifecycle/maturation → [[agent_skill_evolution_guide]]; general layer rules → [[layer_placement_guide]].
19
20
 
@@ -29,6 +30,8 @@ Micro (small) and macro (broad) loops compose; match loop complexity to problem
29
30
  ## B. Design craft (universal — adopt as-is)
30
31
  Output quality tracks the surrounding harness, not the model alone. Keep the codebase clean so the loop follows existing patterns. **Encode verification as a skill with quantitative checks** so the loop measures its own work. Keep docs accessible/current. **Use a second, independent agent for review** (fresh context, separate session/model). Set explicit success/stop criteria and max-turn caps to bound cost; script deterministic steps instead of re-reasoning them; monitor token spend.
31
32
 
33
+ **Allocate reasoning budget by phase, not uniformly.** Model and effort are distinct levers ([Model and Effort in Claude Code](https://claude.com/blog/claude-model-and-effort-level-in-claude-code), Anthropic, 2026): model sets the intelligence ceiling — raise it when logic or knowledge is the wall; effort sets per-request thoroughness (files read, tests run, verification passes) — raise it when the loop skips steps, not when it lacks intelligence. Because a loop compounds turns, uniform high effort multiplies latency across every cycle: concentrate high effort on the verify / judgment / decision gates and keep mechanical dispatch turns low. Note this is the *orchestrator's* lever — it governs how carefully the loop monitors, verifies, and decides; it does not raise the quality of a worker running in a separate process (e.g. a hermes body subprocess or an `agent`-skillset step), which carries its own model/effort config. In KairosChain the non-bypassable gates — the Prop 10 consent/audit floor, the INV-5 layer guard, the verdict track — are exactly where effort must not be cut, since a stop there is an irreversible Kairos moment (§C, Prop 5), not a throughput step.
34
+
32
35
  ## C. KairosChain reading (layer placement + the floor Anthropic omits)
33
36
  The four types are harness-framed (`/goal`, `/loop`, `/schedule`, `/usage`, auto mode are Claude Code mechanisms). KairosChain already implements most of the substance across layers — so the work is mostly *placement*, not adoption; one item is still design-stage (flagged in the table):
34
37
 
@@ -13,6 +13,30 @@ meeting_place:
13
13
  # registry_path: storage/agent_registry.json
14
14
  # parent_place_url: null # URL of parent place for federation
15
15
 
16
+ # Public web surface (unauthenticated, read-only): skill catalog, JSON API,
17
+ # and — when anchoring is enabled — the ANC-7 verification view.
18
+ # Mounted by PlaceRouter under /place/web/* and /place/api/v1/*.
19
+ web_ui:
20
+ enabled: true
21
+ # place_host / place_url anchor the public links and the ANC-7 verification
22
+ # address (place://<place_host>/anchor/<slug>). Set to the served hostname.
23
+ # place_host: meeting.genomicschain.io
24
+ # place_url: https://meeting.genomicschain.io
25
+
26
+ # Anchor attestation capability (ANC-1..9 / BRD-1..4). Opt-in: when disabled
27
+ # the catalog still serves but /place/web/verify and /place/web/anchor/* 404.
28
+ # Enable on the place that hosts external-reference (content-by-reference)
29
+ # anchors — e.g. the constitutive-note / GenomicsChain provenance deployment.
30
+ anchoring:
31
+ enabled: false
32
+ # ANC-9 free-write budget (mechanism, design §11) — tune to observed load.
33
+ # The Sybil disclosure is shown regardless of the numbers.
34
+ per_identity: 60 # writes per identity per window
35
+ aggregate: 600 # writes across all identities per window
36
+ window_seconds: 3600 # refill window
37
+ # log_path: storage/hestia_anchor_log.json
38
+ # attestation_store_path: storage/hestia_anchor_attestations.json
39
+
16
40
  # Deposit policy (operator-configurable)
17
41
  deposit_policy:
18
42
  max_skill_size_bytes: 100000
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Hestia
6
+ # Generates shell-safe import commands for various LLM tools.
7
+ # All interpolated values are escaped via Shellwords.shellescape.
8
+ class ImportCommandGenerator
9
+ def initialize(place_url:)
10
+ @place_url = place_url
11
+ end
12
+
13
+ def commands_for(skill, deposit_id:)
14
+ safe_url = Shellwords.shellescape(@place_url)
15
+ safe_skill_id = Shellwords.shellescape(skill[:skill_id] || deposit_id.split('__', 2).last)
16
+
17
+ {
18
+ claude_code: claude_code_command(safe_skill_id, safe_url),
19
+ codex: codex_command(safe_skill_id, safe_url),
20
+ kairos_cli: kairos_cli_command(safe_skill_id, safe_url),
21
+ curl_preview: curl_preview_command(Shellwords.shellescape(deposit_id), safe_url)
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ def claude_code_command(skill_id, place_url)
28
+ "claude -p \"Use meeting_acquire_skill to acquire #{skill_id} from place #{place_url}\""
29
+ end
30
+
31
+ def codex_command(skill_id, place_url)
32
+ "echo \"Use meeting_acquire_skill to acquire #{skill_id} from place #{place_url}\" | codex exec -"
33
+ end
34
+
35
+ def kairos_cli_command(skill_id, place_url)
36
+ "kairos-chain acquire --place #{place_url} --skill #{skill_id}"
37
+ end
38
+
39
+ def curl_preview_command(deposit_id, place_url)
40
+ "curl -s #{place_url}/place/api/v1/skill/#{deposit_id}"
41
+ end
42
+ end
43
+ end
@@ -55,7 +55,8 @@ module Hestia
55
55
  end
56
56
  end
57
57
 
58
- attr_reader :registry, :skill_board, :heartbeat_manager, :session_store, :started_at, :extensions
58
+ attr_reader :registry, :skill_board, :heartbeat_manager, :session_store, :started_at, :extensions,
59
+ :web_router, :anchor_log, :anchor_board
59
60
 
60
61
  def initialize(config: nil)
61
62
  @config = config || ::Hestia.load_config
@@ -63,6 +64,16 @@ module Hestia
63
64
  @skill_board = nil
64
65
  @heartbeat_manager = nil
65
66
  @session_store = nil
67
+ # The public (unauthenticated) web UI + JSON API + ANC-7 verification view.
68
+ # Constructed in #start when the operator enables it (default on). The
69
+ # Meeting Place SkillSet owns its own public surface here rather than in the
70
+ # core HTTP server, so mounting it never crosses the L0/SkillSet boundary.
71
+ @web_router = nil
72
+ # Anchor store instances (nil until #start with anchoring enabled). Exposed
73
+ # so an authenticated write entry point can deposit into the same log/board
74
+ # the public ANC-7 view reads from.
75
+ @anchor_log = nil
76
+ @anchor_board = nil
66
77
  @started = false
67
78
  @started_at = nil
68
79
  @self_id = nil
@@ -121,6 +132,16 @@ module Hestia
121
132
  observer_id: @self_id
122
133
  )
123
134
 
135
+ # Anchor store (ANC-1 log + BRD board + ANC-9 budget). Built independently
136
+ # of the web UI so it also backs the authenticated write path (the
137
+ # inaugural / external anchoring deposit), and exposed via attr_reader so an
138
+ # operator write entry point deposits into the same instances the public
139
+ # view reads from. [nil, nil] when anchoring is disabled.
140
+ @anchor_log, @anchor_board = build_anchor_store(place_config, default_storage)
141
+
142
+ # Mount the public web surface (catalog/api + ANC-7 verification view).
143
+ @web_router = build_web_router(place_config)
144
+
124
145
  # Self-register: the Place IS also a participant (主客未分)
125
146
  @registry.self_register(identity)
126
147
 
@@ -135,6 +156,84 @@ module Hestia
135
156
  }
136
157
  end
137
158
 
159
+ # Build the public WebRouter (catalog/api + ANC-7 verification view).
160
+ #
161
+ # The web classes require rack/utils (present in the server bundle) and are
162
+ # required lazily here so a place that never serves the web UI does not pull
163
+ # in rack at load time. A missing dependency degrades to "web UI not mounted"
164
+ # (the authenticated API keeps working) rather than a boot failure.
165
+ #
166
+ # @return [WebRouter, nil] nil when disabled or the dependency is unavailable.
167
+ def build_web_router(place_config)
168
+ web_config = place_config['web_ui'] || {}
169
+ return nil unless web_config.fetch('enabled', true)
170
+
171
+ require_relative 'public_rate_limiter'
172
+ require_relative 'public_presenter'
173
+ require_relative 'import_command_generator'
174
+ require_relative 'web_router'
175
+
176
+ place_host = web_config['place_host']
177
+ place_url = web_config['place_url'] || (place_host && "https://#{place_host}")
178
+
179
+ WebRouter.new(
180
+ skill_board: @skill_board,
181
+ agent_registry: @registry,
182
+ config: {
183
+ 'name' => place_config['name'] || 'KairosChain Meeting Place',
184
+ 'place_host' => place_host,
185
+ 'place_url' => place_url
186
+ }.compact,
187
+ anchor_read: build_anchor_read
188
+ )
189
+ rescue LoadError => e
190
+ warn "[PlaceRouter] Public web UI not mounted (missing dependency): #{e.message}"
191
+ nil
192
+ end
193
+
194
+ # Build the Synoptis-owned public read handle the WebRouter presents through
195
+ # (AHM-1). nil when anchoring is disabled (@anchor_log unset), in which case
196
+ # the anchor routes 404. The write-side handles (@anchor_log/@anchor_board)
197
+ # stay owned here for the authenticated deposit path (design §4.3).
198
+ def build_anchor_read
199
+ return nil unless @anchor_log
200
+
201
+ Synoptis::Anchoring::PublicRead.new(log: @anchor_log, board: @anchor_board)
202
+ end
203
+
204
+ # Build the anchor store (ANC-1 log + BRD deposit board + ANC-9 write budget)
205
+ # when the operator enables anchoring. Returns [nil, nil] when disabled, in
206
+ # which case the WebRouter serves the catalog but the /place/web/verify and
207
+ # /place/web/anchor/* routes 404 (the capability is simply not present).
208
+ #
209
+ # The operator identity (@self_id) is the ANC-5/ANC-8 control boundary: it is
210
+ # the only identity permitted to withdraw any entry and the one against which
211
+ # same-party vs foreign is derived.
212
+ def build_anchor_store(place_config, default_storage)
213
+ anchor_config = place_config['anchoring'] || {}
214
+ return [nil, nil] unless anchor_config['enabled']
215
+
216
+ require 'synoptis'
217
+
218
+ log = Synoptis::Anchoring::Log.new(
219
+ storage_path: anchor_config['log_path'] || "#{default_storage}hestia_anchor_log.json",
220
+ operator_id: @self_id
221
+ )
222
+ budget = Synoptis::Anchoring::WriteBudget.new(
223
+ per_identity: anchor_config['per_identity'] || Synoptis::Anchoring::WriteBudget::DEFAULT_PER_IDENTITY,
224
+ aggregate: anchor_config['aggregate'] || Synoptis::Anchoring::WriteBudget::DEFAULT_AGGREGATE,
225
+ window_seconds: anchor_config['window_seconds'] || Synoptis::Anchoring::WriteBudget::DEFAULT_WINDOW_SECONDS,
226
+ operator_id: @self_id
227
+ )
228
+ board = Synoptis::Anchoring::DepositBoard.new(
229
+ log: log,
230
+ attestation_store_path: anchor_config['attestation_store_path'] ||
231
+ "#{default_storage}hestia_anchor_attestations.json",
232
+ budget: budget
233
+ )
234
+ [log, board]
235
+ end
236
+
138
237
  # Rack-compatible call method.
139
238
  def call(env)
140
239
  unless @started
@@ -147,6 +246,16 @@ module Hestia
147
246
  request_method = env['REQUEST_METHOD']
148
247
  path = env['PATH_INFO']
149
248
 
249
+ # Public, unauthenticated, read-only web surface (ANC-7 verification view,
250
+ # skill catalog, JSON API). Delegated before the auth flow — same position
251
+ # as the other unauthenticated endpoints below. The WebRouter enforces
252
+ # GET-only, IP rate limiting, and HTML escaping internally.
253
+ if @web_router &&
254
+ (path == '/place/web' || path.start_with?('/place/web/') ||
255
+ path.start_with?('/place/api/v1/'))
256
+ return @web_router.call(env)
257
+ end
258
+
150
259
  # Unauthenticated endpoints
151
260
  if request_method == 'GET' && path == '/place/v1/info'
152
261
  return handle_info
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hestia
4
+ # Shared presenter for public data — used by both JSON API and HTML views.
5
+ # Truncates IDs and strips content to prevent information leakage.
6
+ class PublicPresenter
7
+ ID_TRUNCATE_LENGTH = 12
8
+
9
+ def catalog_entry(entry, auditor = nil)
10
+ deposit_id = make_deposit_id(entry)
11
+ {
12
+ deposit_id: deposit_id,
13
+ skill_id: entry[:skill_id],
14
+ name: entry[:name],
15
+ description: truncate(entry[:description], 500),
16
+ tags: entry[:tags] || [],
17
+ format: entry[:format],
18
+ type: entry[:type],
19
+ size_bytes: entry[:size_bytes],
20
+ content_hash: entry[:content_hash]&.slice(0, 16),
21
+ deposited_at: entry[:deposited_at],
22
+ depositor_id: truncate_id(entry[:agent_id] || entry[:depositor_id]),
23
+ summary: truncate(entry[:summary], 500),
24
+ version: entry[:version],
25
+ license: entry[:license],
26
+ attestation_count: entry[:attestations]&.size || 0,
27
+ audit_status: auditor ? auditor.audit_status(deposit_id)[:status] : nil
28
+ }
29
+ end
30
+
31
+ def skill_detail(skill, auditor = nil)
32
+ base = catalog_entry(skill, auditor)
33
+ base.merge(
34
+ sections: skill[:sections],
35
+ first_lines: skill[:first_lines],
36
+ input_output: skill[:input_output],
37
+ attestations: present_attestations(skill[:attestations]),
38
+ trust_notice: skill[:trust_notice],
39
+ audit_detail: auditor ? auditor.audit_status(make_deposit_id(skill)) : nil
40
+ )
41
+ end
42
+
43
+ def agent_profile(agent)
44
+ id = agent.respond_to?(:id) ? agent.id : agent[:id]
45
+ {
46
+ agent_id: truncate_id(id),
47
+ name: agent.respond_to?(:name) ? agent.name : agent[:name],
48
+ description: agent.respond_to?(:description) ? agent.description : agent[:description],
49
+ registered_at: agent.respond_to?(:registered_at) ? agent.registered_at : agent[:registered_at]
50
+ }
51
+ end
52
+
53
+ def make_deposit_id(entry)
54
+ agent = entry[:agent_id] || entry[:depositor_id]
55
+ skill = entry[:skill_id]
56
+ "#{agent}__#{skill}" if agent && skill
57
+ end
58
+
59
+ private
60
+
61
+ def present_attestations(attestations)
62
+ return [] unless attestations
63
+
64
+ attestations.map do |a|
65
+ {
66
+ claim: a[:claim],
67
+ attester_id: truncate_id(a[:attester_id]),
68
+ attester_name: a[:attester_name],
69
+ actor_role: a[:actor_role],
70
+ has_signature: a[:has_signature],
71
+ deposited_at: a[:deposited_at]
72
+ }
73
+ end
74
+ end
75
+
76
+ def truncate_id(id)
77
+ return nil unless id
78
+ id.length > ID_TRUNCATE_LENGTH ? "#{id[0, ID_TRUNCATE_LENGTH]}..." : id
79
+ end
80
+
81
+ def truncate(text, max)
82
+ return nil unless text
83
+ text.length > max ? "#{text[0, max]}..." : text
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hestia
4
+ # IP-based rate limiter for public web/API endpoints.
5
+ # Bounded memory: TTL eviction + max tracked IPs.
6
+ class PublicRateLimiter
7
+ MAX_REQUESTS_PER_MINUTE = 30
8
+ MAX_TRACKED_IPS = 10_000
9
+ CLEANUP_INTERVAL = 1000
10
+ STALE_SECONDS = 300
11
+
12
+ def initialize(max_rpm: MAX_REQUESTS_PER_MINUTE)
13
+ @max_rpm = max_rpm
14
+ @requests = {}
15
+ @mutex = Mutex.new
16
+ @total_requests = 0
17
+ end
18
+
19
+ def allow?(ip)
20
+ @mutex.synchronize do
21
+ @total_requests += 1
22
+ cleanup! if (@total_requests % CLEANUP_INTERVAL).zero?
23
+
24
+ now = Time.now.to_f
25
+ @requests[ip] = (@requests[ip] || []).select { |t| now - t < 60 }
26
+
27
+ return false if @requests[ip].size >= @max_rpm
28
+
29
+ @requests[ip] << now
30
+ true
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def cleanup!
37
+ now = Time.now.to_f
38
+ @requests.delete_if { |_ip, timestamps| timestamps.all? { |t| now - t >= STALE_SECONDS } }
39
+
40
+ return unless @requests.size > MAX_TRACKED_IPS
41
+
42
+ sorted = @requests.sort_by { |_ip, ts| ts.last || 0 }
43
+ excess = @requests.size - MAX_TRACKED_IPS
44
+ sorted.first(excess).each { |ip, _| @requests.delete(ip) }
45
+ end
46
+ end
47
+ end