samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../kernel_loop"
4
+ require_relative "../steer"
4
5
 
5
6
  module Samagotchi
6
7
  class Bridge
@@ -32,7 +33,7 @@ module Samagotchi
32
33
  end
33
34
 
34
35
  # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
35
- # continue, ordered parts (thinking / text / tool / input / reminder / hook_notice),
36
+ # continue, ordered parts (thinking / text / tool / input / steer / reminder / hook_notice),
36
37
  # pending_question and the last event_seq folded in
37
38
  def current_turn
38
39
  @mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
@@ -67,6 +68,9 @@ module Samagotchi
67
68
  when "input"
68
69
  flush.call
69
70
  messages << { role: "user", content: part[:text].to_s }
71
+ when "steer"
72
+ flush.call
73
+ messages << Steer.message(text: part[:text], source: part[:source])
70
74
  end
71
75
  end
72
76
  flush.call
@@ -155,9 +159,17 @@ module Samagotchi
155
159
  tool[:output] = capped ? output[0, @max_output_chars] : output.dup
156
160
  tool[:output_truncated] = capped || !!event[:output_truncated]
157
161
  tool[:images] = event[:images] if event[:images]
162
+ tool[:diff] = event[:diff] if event[:diff]
158
163
  when :pending_input_merged
159
- parts << { kind: "input", iteration: event[:iteration], text: event[:content].to_s.dup, origins: @merged_origins }
160
- @merged_origins = []
164
+ # A steer-only merge (count 0) has no user part: the origins stay
165
+ # for the user lines' own merge.
166
+ unless event[:content].nil?
167
+ parts << { kind: "input", iteration: event[:iteration], text: event[:content].to_s.dup, origins: @merged_origins }
168
+ @merged_origins = []
169
+ end
170
+ Array(event[:steers]).each do |steer|
171
+ parts << { kind: "steer", iteration: event[:iteration], source: steer[:source].to_s, text: steer[:text].to_s.dup }
172
+ end
161
173
  when :reminder_injected
162
174
  parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
163
175
  when :hook_notice
@@ -10,6 +10,7 @@ require "time"
10
10
  require_relative "bridge/bounded_queue"
11
11
  require_relative "bridge/card_store"
12
12
  require_relative "bridge/event_id"
13
+ require_relative "bridge/pending_card"
13
14
  require_relative "bridge/ring_buffer"
14
15
  require_relative "bridge/sse_writer"
15
16
  require_relative "bridge/turn_accumulator"
@@ -18,6 +19,7 @@ require_relative "engine"
18
19
  require_relative "session_commands"
19
20
  require_relative "image_store"
20
21
  require_relative "log"
22
+ require_relative "version"
21
23
 
22
24
  module Samagotchi
23
25
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -92,6 +94,7 @@ module Samagotchi
92
94
  @ring = RingBuffer.new(capacity: ring_capacity)
93
95
  @accumulator = TurnAccumulator.new
94
96
  @cards = CardStore.new
97
+ @pending_card = PendingCard.new(session_dir)
95
98
  @heartbeat_interval = heartbeat_interval
96
99
  @epoch = SecureRandom.hex(4)
97
100
 
@@ -151,6 +154,9 @@ module Samagotchi
151
154
  # A plugin's ctx.messages mid-turn holds the turn so far (plan O1).
152
155
  @engine.running_turn_messages = -> { @accumulator.current_messages } if @engine.respond_to?(:running_turn_messages=)
153
156
  @cards_handle = @engine.subscribe(observer: @cards)
157
+ # One a worker that died left is not open.
158
+ @pending_card.clear
159
+ @pending_card_handle = @engine.subscribe(observer: @pending_card)
154
160
  @accept_thread = Thread.new { accept_loop }
155
161
  @accept_thread.report_on_exception = false
156
162
  write_sidecar
@@ -175,6 +181,8 @@ module Samagotchi
175
181
  @accumulator_handle&.unsubscribe
176
182
  @engine.running_turn_messages = nil if @engine.respond_to?(:running_turn_messages=)
177
183
  @cards_handle&.unsubscribe
184
+ @pending_card_handle&.unsubscribe
185
+ @pending_card&.clear
178
186
  # A turn post killed between its enqueue and its reply looks failed to
179
187
  # the web, which then queues the prompt again from the input file.
180
188
  await_answers(REQUEST_GRACE_SECONDS)
@@ -597,6 +605,8 @@ module Samagotchi
597
605
  return BAD_DEADLINE unless deadline_valid?(deadline)
598
606
 
599
607
  command = { command_id: SecureRandom.uuid, client_id: fetched(parsed, "client_id"), line: line }
608
+ # A card's action: the UIs leave its line out (the card is the echo).
609
+ command[:card] = true if fetched(parsed, "card") == true
600
610
  queued = @engine.synchronize_events do
601
611
  next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
602
612
 
@@ -952,7 +962,9 @@ module Samagotchi
952
962
  "port" => @port,
953
963
  "bind" => @bind,
954
964
  "session_id" => @session_id,
955
- "started_at" => Time.now.iso8601(3)
965
+ "started_at" => Time.now.iso8601(3),
966
+ # The chi this worker runs (chi update reports older ones).
967
+ "version" => Samagotchi::VERSION
956
968
  }
957
969
  record["input_format"] = @input_format if @input_format
958
970
  path = File.join(session_dir, SIDECAR_FILE)
@@ -162,9 +162,13 @@ module Samagotchi
162
162
  # !rollback, !cmd, /continue) for the worker to run. 202 = queued (body
163
163
  # carries the command_id its :command_ran will name), 400 = not a
164
164
  # command, 404 = a worker older than the route, 408 = too late.
165
+ # @param card [Boolean] the line is a card's action: its command_queued
166
+ # and command_ran carry card: true, and the UIs show no echo of it
165
167
  # @return [Response]
166
- def post_command(line:, client_id: nil)
167
- post("command", { line: line, client_id: client_id, deadline: deadline }, read_body: true)
168
+ def post_command(line:, client_id: nil, card: false)
169
+ payload = { line: line, client_id: client_id, deadline: deadline }
170
+ payload[:card] = true if card
171
+ post("command", payload, read_body: true)
168
172
  end
169
173
 
170
174
  # POST /session/:id/exit: ask the worker to exit now. 200 = it will
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: check-in
3
+ version: 0.1.1
4
+ scope: system
5
+ description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:b2ca10c949734ef3d31df9ff775cf136d7f86f185e875a4c16259e8f871bfb82
10
+ requires_chi: ">= 0.3.0"
@@ -0,0 +1,244 @@
1
+ # The check-in bundle (docs/plugins.md, The check-in bundle): a turn that
2
+ # has made many tool calls without answering gets checked on. After `after`
3
+ # tool calls (then every `every` more) it asks the user with a card (Nudge /
4
+ # Keep going / Stop), nudges the model by itself, or only says so.
5
+ #
6
+ # A nudge is ctx.steer: the message joins the running turn at its next
7
+ # boundary as its own user message, so the model reads it before its next
8
+ # step. It never starts a turn, and one that arrives after the model's
9
+ # final answer is dropped: the card's Nudge then says so at the turn's end
10
+ # (after_turn's messages have no steer with its text).
11
+ #
12
+ # The count is per turn: before_turn resets it (and closes a card left open
13
+ # by a turn that failed or was interrupted, where after_turn doesn't fire);
14
+ # steering merged mid-turn does not. The /checkin commands are anytime ones
15
+ # (a card's actions run while the turn goes on), so the state is behind a
16
+ # Mutex.
17
+ #
18
+ # Settings (config.yml, bundles: check-in:):
19
+ # after: 50 tool calls in one turn before the first check-in
20
+ # every: 50 then again every this many more
21
+ # mode: ask ask | nudge | notify
22
+ # message: "..." what a nudge says; {calls} is the count
23
+ # ignore_tools: [task_wait, task_get, delegate_result]
24
+ require "securerandom"
25
+
26
+ class Plugin
27
+ MODES = %w[ask nudge notify].freeze
28
+ DEFAULT_MESSAGE = "You've made {calls} tool calls in this turn without answering. Say briefly what you've found " \
29
+ "so far and what's left, then answer now or continue."
30
+ DEFAULT_IGNORE = %w[task_wait task_get delegate_result].freeze
31
+ LAST_TOOLS = 5
32
+ USAGE = "usage: /checkin [on|off|<calls>|mode ask|nudge|notify|nudge|later|stop]"
33
+
34
+ def initialize(settings = {})
35
+ settings = {} unless settings.is_a?(Hash)
36
+ @after = positive(settings["after"]) || 50
37
+ @every = positive(settings["every"]) || @after
38
+ @mode = MODES.include?(settings["mode"].to_s) ? settings["mode"].to_s : "ask"
39
+ message = settings["message"].to_s.strip
40
+ @message = message.empty? ? DEFAULT_MESSAGE : message
41
+ @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
42
+ @enabled = true
43
+ @mutex = Mutex.new
44
+ reset
45
+ end
46
+
47
+ def register(chi)
48
+ chi.on(:before_turn) { |_event, ctx| before_turn(ctx) }
49
+ chi.on(:after_tool_call) { |event, ctx| after_tool_call(event, ctx) }
50
+ chi.on(:after_turn) { |event, ctx| after_turn(event, ctx) }
51
+ chi.command "/checkin", "check on a long turn: status, on|off, <calls>, mode ask|nudge|notify, nudge, later, stop",
52
+ anytime: true do |args, ctx|
53
+ command(args.to_s.strip.downcase, ctx)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # --- the turn --------------------------------------------------------------
60
+
61
+ def reset
62
+ @count = 0
63
+ @next_at = @after
64
+ @started = monotonic
65
+ @tools = []
66
+ @card_id = nil # this turn's card, once shown
67
+ @card_open = false
68
+ @nudge = nil # the card's Nudge this turn: { text:, count: }
69
+ end
70
+
71
+ def before_turn(ctx)
72
+ close_card(ctx, "The turn ended after #{calls} tool calls.")
73
+ @mutex.synchronize { reset }
74
+ end
75
+
76
+ def after_tool_call(event, ctx)
77
+ tool = event[:tool].to_s
78
+ return if @ignore.include?(tool)
79
+
80
+ due = @mutex.synchronize do
81
+ @count += 1
82
+ @tools = (@tools + [tool]).last(LAST_TOOLS)
83
+ next nil unless @enabled && @count >= @next_at
84
+
85
+ @next_at = @count + @every
86
+ [@count, @mode]
87
+ end
88
+ check_in(event, ctx, *due) if due
89
+ end
90
+
91
+ def check_in(event, ctx, count, mode)
92
+ case mode
93
+ when "nudge"
94
+ ctx.notify("nudged the model after #{count} tool calls") if event[:steer]&.call(message(count))
95
+ when "notify"
96
+ ctx.notify("#{count} tool calls in this turn, no answer yet")
97
+ else
98
+ show_card(ctx, count)
99
+ end
100
+ end
101
+
102
+ def after_turn(event, ctx)
103
+ return if close_card(ctx, "The turn ended after #{calls} tool calls.")
104
+
105
+ nudge_not_sent(event, ctx)
106
+ end
107
+
108
+ # The card said "Nudged": if the turn ended before the nudge joined it
109
+ # (the model answered first, or the turn was stopped), the card says so.
110
+ def nudge_not_sent(event, ctx)
111
+ id, nudge = @mutex.synchronize { [@card_id, @nudge] }
112
+ return unless id && nudge
113
+ return if steered?(event[:messages], nudge[:text])
114
+
115
+ first = event[:status].to_s == "canceled" ? "The turn ended first" : "The answer came first"
116
+ ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
117
+ end
118
+
119
+ # Whether this turn's messages (after its last prompt) have the steer.
120
+ def steered?(messages, text)
121
+ Array(messages).reverse_each do |message|
122
+ next unless message.is_a?(Hash)
123
+
124
+ role = (message[:role] || message["role"]).to_s
125
+ kind = (message[:kind] || message["kind"]).to_s
126
+ return true if kind == "steer" && (message[:content] || message["content"]).to_s.strip == text.strip
127
+ return false if role == "user" && kind != "steer"
128
+ end
129
+ false
130
+ end
131
+
132
+ # One card per turn: a later check-in updates it in place.
133
+ def show_card(ctx, count)
134
+ id, body = @mutex.synchronize do
135
+ @card_id ||= "check-in-#{SecureRandom.hex(4)}"
136
+ @card_open = true
137
+ [@card_id, "#{elapsed} in this turn. Last tools: #{@tools.join(", ")}."]
138
+ end
139
+ ctx.card(id: id, title: "#{count} tool calls, no answer yet", body: body,
140
+ actions: [{ label: "Nudge", command: "/checkin nudge" },
141
+ { label: "Keep going", command: "/checkin later" },
142
+ { label: "Stop", command: "/checkin stop" }])
143
+ end
144
+
145
+ # The open card, replaced by one without actions (stale buttons go).
146
+ # @return [Boolean] whether there was one
147
+ def close_card(ctx, body)
148
+ id = @mutex.synchronize do
149
+ next nil unless @card_open
150
+
151
+ @card_open = false
152
+ @card_id
153
+ end
154
+ return false unless id
155
+
156
+ ctx.card(id: id, title: "check-in", body: body)
157
+ true
158
+ end
159
+
160
+ # --- /checkin --------------------------------------------------------------
161
+
162
+ def command(args, ctx)
163
+ case args
164
+ when "" then status
165
+ when "on", "off"
166
+ @mutex.synchronize { @enabled = args == "on" }
167
+ "check-in is #{args} for this session"
168
+ when /\A\d+\z/
169
+ threshold(Integer(args))
170
+ when /\Amode\s+(\S+)\z/
171
+ mode = Regexp.last_match(1)
172
+ return "check-in: unknown mode #{mode} (ask, nudge or notify)" unless MODES.include?(mode)
173
+
174
+ @mutex.synchronize { @mode = mode }
175
+ "check-in mode is #{mode} for this session"
176
+ when "nudge" then nudge(ctx)
177
+ when "later" then later(ctx)
178
+ when "stop" then stop(ctx)
179
+ else USAGE
180
+ end
181
+ end
182
+
183
+ def status
184
+ @mutex.synchronize do
185
+ state = @enabled ? "on" : "off"
186
+ "check-in is #{state}: mode #{@mode}, after #{@after} tool calls, then every #{@every}; " \
187
+ "this turn: #{@count} tool calls"
188
+ end
189
+ end
190
+
191
+ # For this session: check in at +n+ calls and every +n+ after; a turn
192
+ # already past it is checked on +n+ calls from now.
193
+ def threshold(n)
194
+ return "check-in: the threshold must be at least 1" unless n.positive?
195
+
196
+ @mutex.synchronize do
197
+ @after = @every = n
198
+ @next_at = n > @count ? n : @count + n
199
+ end
200
+ "check-in after #{n} tool calls, then every #{n}, for this session"
201
+ end
202
+
203
+ def nudge(ctx)
204
+ count = calls
205
+ text = message(count)
206
+ return "check-in: no turn running" unless ctx.steer(text)
207
+
208
+ @mutex.synchronize { @nudge = { text: text, count: count } }
209
+ close_card(ctx, "Nudged the model at #{count} tool calls.")
210
+ nil
211
+ end
212
+
213
+ def later(ctx)
214
+ at = @mutex.synchronize { @next_at }
215
+ return "check-in: no check-in open" unless close_card(ctx, "Kept going; the next check-in is at #{at} tool calls.")
216
+
217
+ nil
218
+ end
219
+
220
+ def stop(ctx)
221
+ return "check-in: no turn running" unless ctx.stop_turn("stopped from check-in")
222
+
223
+ close_card(ctx, "Stopped the turn at #{calls} tool calls.")
224
+ nil
225
+ end
226
+
227
+ # --- helpers ---------------------------------------------------------------
228
+
229
+ def calls = @mutex.synchronize { @count }
230
+
231
+ def message(count) = @message.gsub("{calls}", count.to_s)
232
+
233
+ def elapsed
234
+ seconds = (monotonic - @started).round
235
+ seconds < 60 ? "#{seconds}s" : "#{seconds / 60}m #{seconds % 60}s"
236
+ end
237
+
238
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
239
+
240
+ def positive(value)
241
+ number = Integer(value.to_s, exception: false)
242
+ number&.positive? ? number : nil
243
+ end
244
+ end