openclacky 1.5.5 → 1.5.7

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +58 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Rakefile +15 -0
  7. data/docs/design-emit-and-background-skills.md +253 -0
  8. data/docs/mcp-architecture.md +48 -9
  9. data/docs/mcp.example.json +9 -0
  10. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  11. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  12. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  13. data/lib/clacky/agent/cost_tracker.rb +17 -2
  14. data/lib/clacky/agent/goal_manager.rb +305 -0
  15. data/lib/clacky/agent/goal_state.rb +88 -0
  16. data/lib/clacky/agent/hook_manager.rb +14 -3
  17. data/lib/clacky/agent/memory_updater.rb +17 -18
  18. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  19. data/lib/clacky/agent/session_serializer.rb +162 -25
  20. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  21. data/lib/clacky/agent/skill_evolution.rb +11 -7
  22. data/lib/clacky/agent/skill_manager.rb +20 -18
  23. data/lib/clacky/agent/skill_reflector.rb +3 -6
  24. data/lib/clacky/agent.rb +312 -22
  25. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  26. data/lib/clacky/cli.rb +19 -15
  27. data/lib/clacky/client.rb +1 -1
  28. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  29. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +21 -7
  32. data/lib/clacky/default_searchers/tavily.rb +98 -0
  33. data/lib/clacky/extension/api_extension.rb +37 -3
  34. data/lib/clacky/extension/packager.rb +54 -5
  35. data/lib/clacky/fanout.rb +100 -0
  36. data/lib/clacky/idle_compression_timer.rb +1 -3
  37. data/lib/clacky/locales/en.rb +2 -0
  38. data/lib/clacky/locales/zh.rb +2 -0
  39. data/lib/clacky/mcp/cli_commands.rb +61 -0
  40. data/lib/clacky/mcp/client.rb +18 -2
  41. data/lib/clacky/mcp/connection_manager.rb +85 -0
  42. data/lib/clacky/mcp/http_transport.rb +25 -9
  43. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  44. data/lib/clacky/mcp/oauth/config.rb +51 -0
  45. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  46. data/lib/clacky/mcp/oauth/session.rb +88 -0
  47. data/lib/clacky/message_format/anthropic.rb +27 -13
  48. data/lib/clacky/message_history.rb +32 -1
  49. data/lib/clacky/providers.rb +15 -13
  50. data/lib/clacky/search_config.rb +57 -0
  51. data/lib/clacky/server/http_server.rb +136 -37
  52. data/lib/clacky/server/session_registry.rb +21 -18
  53. data/lib/clacky/server/web_ui_controller.rb +1 -1
  54. data/lib/clacky/session_manager.rb +13 -11
  55. data/lib/clacky/shell_hook_loader.rb +14 -8
  56. data/lib/clacky/tools/invoke_skill.rb +3 -2
  57. data/lib/clacky/tools/terminal.rb +11 -4
  58. data/lib/clacky/tools/web_search.rb +45 -6
  59. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  60. data/lib/clacky/ui2/components/input_area.rb +26 -2
  61. data/lib/clacky/ui2/progress_handle.rb +27 -3
  62. data/lib/clacky/ui2/ui_controller.rb +223 -6
  63. data/lib/clacky/ui_interface.rb +22 -3
  64. data/lib/clacky/utils/arguments_parser.rb +73 -4
  65. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  66. data/lib/clacky/utils/model_pricing.rb +21 -10
  67. data/lib/clacky/utils/searcher_manager.rb +87 -0
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +243 -33
  70. data/lib/clacky/web/app.js +12 -1
  71. data/lib/clacky/web/components/chat-navigator.js +260 -0
  72. data/lib/clacky/web/features/billing/view.js +2 -2
  73. data/lib/clacky/web/features/brand/view.js +5 -1
  74. data/lib/clacky/web/features/extensions/view.js +19 -19
  75. data/lib/clacky/web/features/new-session/view.js +19 -2
  76. data/lib/clacky/web/features/skills/store.js +4 -2
  77. data/lib/clacky/web/i18n.js +64 -14
  78. data/lib/clacky/web/index.html +40 -9
  79. data/lib/clacky/web/projects.js +62 -19
  80. data/lib/clacky/web/sessions.js +329 -115
  81. data/lib/clacky/web/settings.js +86 -0
  82. data/lib/clacky/web/skills.js +24 -4
  83. data/lib/clacky/web/ws-dispatcher.js +86 -35
  84. data/lib/clacky.rb +4 -0
  85. metadata +18 -1
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Clacky
6
+ # Drives the /goal Ralph-style loop for a single session.
7
+ #
8
+ # Responsibilities:
9
+ # - own the GoalState lifecycle (set / pause / resume / clear)
10
+ # - after each agent turn, ask a lightweight judge whether the goal is
11
+ # done, then decide whether to feed the loop another turn
12
+ # - enforce the turn budget and auto-pause on repeated judge failures
13
+ #
14
+ # The judge is a single stateless LLM call routed through the same Client
15
+ # the agent already holds (optionally on a cheaper model name). It never
16
+ # touches conversation history — it only sees the goal text and the agent's
17
+ # last response.
18
+ class GoalManager
19
+ # After this many consecutive judge-output parse failures the loop
20
+ # auto-pauses instead of burning the whole turn budget on a judge model
21
+ # that cannot follow the JSON reply contract.
22
+ MAX_CONSECUTIVE_JUDGE_FAILURES = 3
23
+
24
+ JUDGE_MAX_TOKENS = 512
25
+
26
+ JUDGE_SYSTEM_PROMPT = <<~PROMPT.freeze
27
+ You are a strict judge deciding whether an autonomous agent has achieved a user's stated goal.
28
+ You receive the goal text and the agent's most recent response.
29
+
30
+ Decide one verdict:
31
+ - "done": the goal is fully satisfied, OR the agent clearly explains the goal is
32
+ unachievable / blocked / needs user input (treat a genuine block as done).
33
+ - "continue": not done, and there is a concrete next step the agent can take now.
34
+ This is the default when in doubt.
35
+
36
+ Reply with ONLY a single-line JSON object, no prose, no code fences:
37
+ {"verdict": "done", "reason": "<one short sentence>"}
38
+ {"verdict": "continue", "reason": "<one short sentence>"}
39
+ PROMPT
40
+
41
+ CONTINUATION_PROMPT_TEMPLATE = <<~PROMPT.freeze
42
+ [Continuing toward your standing goal]
43
+ Goal: %<goal>s
44
+
45
+ Continue working toward this goal. Take the next concrete step.
46
+ If you believe the goal is complete, state so explicitly and stop.
47
+ If you are blocked and need input from the user, say so clearly and stop.
48
+ PROMPT
49
+
50
+ attr_reader :state
51
+
52
+ def initialize(judge_client:, judge_model:, state: nil)
53
+ @judge_client = judge_client
54
+ @judge_model = judge_model
55
+ @state = state
56
+ @consecutive_judge_failures = 0
57
+ end
58
+
59
+ def active?
60
+ @state&.active? || false
61
+ end
62
+
63
+ def has_goal?
64
+ @state && (@state.active? || @state.paused?)
65
+ end
66
+
67
+ # --- lifecycle ----------------------------------------------------------
68
+
69
+ def set(goal, max_turns: nil)
70
+ goal = goal.to_s.strip
71
+ raise ArgumentError, "goal text is empty" if goal.empty?
72
+
73
+ @state = GoalState.new(
74
+ goal: goal,
75
+ max_turns: max_turns || GoalState::DEFAULT_MAX_TURNS
76
+ )
77
+ @consecutive_judge_failures = 0
78
+ @state
79
+ end
80
+
81
+ def pause(reason: "user-paused")
82
+ return nil unless @state
83
+ @state.status = "paused"
84
+ @state.paused_reason = reason
85
+ @state
86
+ end
87
+
88
+ def resume(reset_budget: true)
89
+ return nil unless @state
90
+ @state.status = "active"
91
+ @state.paused_reason = nil
92
+ @state.turns_used = 0 if reset_budget
93
+ @consecutive_judge_failures = 0
94
+ @state
95
+ end
96
+
97
+ def clear
98
+ @state = nil
99
+ end
100
+
101
+ # Human-readable one-liner for /goal status and status bars.
102
+ def status_line
103
+ return "No active goal. Set one with /goal <text>." unless @state
104
+
105
+ s = @state
106
+ meta = "#{s.turns_used}/#{s.max_turns} turns"
107
+ case s.status
108
+ when "active"
109
+ "⊙ Goal (active, #{meta}): #{s.goal}"
110
+ when "paused"
111
+ extra = s.paused_reason ? " — #{s.paused_reason}" : ""
112
+ "⏸ Goal (paused, #{meta}#{extra}): #{s.goal}"
113
+ when "done"
114
+ "✓ Goal done (#{meta}): #{s.goal}"
115
+ else
116
+ "Goal (#{s.status}, #{meta}): #{s.goal}"
117
+ end
118
+ end
119
+
120
+ # --- the main hook, called after every agent turn ----------------------
121
+
122
+ # Judge the finished turn and decide the loop's next move.
123
+ #
124
+ # @param last_response [String] the agent's final assistant text this turn
125
+ # @return [Hash] decision:
126
+ # {
127
+ # should_continue: Boolean, # caller should run another turn
128
+ # continuation_prompt: String|nil, # prompt to feed the next turn
129
+ # status: String, # goal status after this turn
130
+ # verdict: "done"|"continue",
131
+ # reason: String,
132
+ # message: String # user-visible one-liner
133
+ # }
134
+ def evaluate_after_turn(last_response)
135
+ return inactive_decision unless @state&.active?
136
+
137
+ @state.turns_used += 1
138
+ @state.last_turn_at = Time.now.to_f
139
+
140
+ last_response = last_response.to_s
141
+ if last_response.strip.empty?
142
+ # Nothing to judge — treat as a transient blip, keep going if budget allows.
143
+ return continue_or_pause("empty response (nothing to evaluate)")
144
+ end
145
+
146
+ verdict, reason = judge(@state.goal, last_response)
147
+ @state.last_verdict = verdict
148
+ @state.last_reason = reason
149
+
150
+ if verdict.nil?
151
+ @consecutive_judge_failures += 1
152
+ if @consecutive_judge_failures >= MAX_CONSECUTIVE_JUDGE_FAILURES
153
+ return pause_decision(
154
+ "judge returned unusable output #{@consecutive_judge_failures} turns in a row"
155
+ )
156
+ end
157
+ return continue_or_pause("judge output unusable — continuing")
158
+ end
159
+
160
+ @consecutive_judge_failures = 0
161
+
162
+ if verdict == "done"
163
+ @state.status = "done"
164
+ return {
165
+ should_continue: false,
166
+ continuation_prompt: nil,
167
+ status: "done",
168
+ verdict: "done",
169
+ reason: reason,
170
+ message: "✓ Goal achieved: #{reason}"
171
+ }
172
+ end
173
+
174
+ continue_or_pause(reason)
175
+ end
176
+
177
+ # --- persistence bridge -------------------------------------------------
178
+
179
+ def to_h
180
+ @state&.to_h
181
+ end
182
+
183
+ private def continue_or_pause(reason)
184
+ if @state.budget_exhausted?
185
+ return pause_decision(
186
+ "turn budget exhausted (#{@state.turns_used}/#{@state.max_turns})"
187
+ )
188
+ end
189
+
190
+ {
191
+ should_continue: true,
192
+ continuation_prompt: continuation_prompt,
193
+ status: "active",
194
+ verdict: "continue",
195
+ reason: reason,
196
+ message: "↻ Continuing toward goal (#{@state.turns_used}/#{@state.max_turns}): #{reason}"
197
+ }
198
+ end
199
+
200
+ private def pause_decision(reason)
201
+ @state.status = "paused"
202
+ @state.paused_reason = reason
203
+ {
204
+ should_continue: false,
205
+ continuation_prompt: nil,
206
+ status: "paused",
207
+ verdict: @state.last_verdict || "continue",
208
+ reason: reason,
209
+ message: "⏸ Goal paused — #{reason}. Use /goal resume to continue, or /goal clear to stop."
210
+ }
211
+ end
212
+
213
+ private def inactive_decision
214
+ {
215
+ should_continue: false,
216
+ continuation_prompt: nil,
217
+ status: @state&.status,
218
+ verdict: "inactive",
219
+ reason: "no active goal",
220
+ message: ""
221
+ }
222
+ end
223
+
224
+ def continuation_prompt
225
+ format(CONTINUATION_PROMPT_TEMPLATE, goal: @state.goal)
226
+ end
227
+
228
+ # Run the judge LLM call. Fail-open: any error returns [nil, reason] so the
229
+ # caller counts it as a parse failure (and eventually auto-pauses) rather
230
+ # than crashing the agent loop.
231
+ #
232
+ # @return [Array(String|nil, String)] [verdict, reason]; verdict nil on failure
233
+ private def judge(goal, last_response)
234
+ messages = [
235
+ { role: "system", content: JUDGE_SYSTEM_PROMPT },
236
+ { role: "user", content: judge_user_prompt(goal, last_response) }
237
+ ]
238
+
239
+ raw = @judge_client.send_messages(messages, model: @judge_model, max_tokens: JUDGE_MAX_TOKENS)
240
+ parse_judge_response(raw)
241
+ rescue StandardError => e
242
+ Clacky::Logger.info("goal.judge_failed", error: e.message) if defined?(Clacky::Logger)
243
+ [nil, "judge error: #{e.class}"]
244
+ end
245
+
246
+ private def judge_user_prompt(goal, last_response)
247
+ <<~PROMPT
248
+ Goal:
249
+ #{truncate(goal, 2000)}
250
+
251
+ Agent's most recent response:
252
+ #{truncate(last_response, 4000)}
253
+
254
+ Is the goal satisfied — done or continue?
255
+ PROMPT
256
+ end
257
+
258
+ # Parse the judge reply into [verdict, reason]. Fail-open: unparseable
259
+ # output returns [nil, reason].
260
+ private def parse_judge_response(raw)
261
+ text = raw.to_s.strip
262
+ return [nil, "judge returned empty response"] if text.empty?
263
+
264
+ # Strip markdown code fences the model may wrap JSON in.
265
+ if text.start_with?("```")
266
+ text = text.gsub(/\A```[a-zA-Z]*\n?/, "").gsub(/```\z/, "").strip
267
+ end
268
+
269
+ json = extract_json_object(text)
270
+ return [nil, "judge reply was not JSON"] unless json
271
+
272
+ reason = json["reason"].to_s.strip
273
+ reason = "no reason provided" if reason.empty?
274
+
275
+ verdict = json["verdict"].to_s.strip.downcase
276
+ verdict = "done" if verdict.empty? && json.key?("done") && truthy?(json["done"])
277
+ verdict = "continue" unless %w[done continue].include?(verdict)
278
+
279
+ [verdict, reason]
280
+ end
281
+
282
+ private def extract_json_object(text)
283
+ JSON.parse(text)
284
+ rescue JSON::ParserError
285
+ match = text.match(/\{.*\}/m)
286
+ return nil unless match
287
+ begin
288
+ JSON.parse(match[0])
289
+ rescue JSON::ParserError
290
+ nil
291
+ end
292
+ end
293
+
294
+ private def truthy?(value)
295
+ return value if value == true || value == false
296
+ %w[true yes 1 done].include?(value.to_s.strip.downcase)
297
+ end
298
+
299
+ private def truncate(text, limit)
300
+ str = text.to_s
301
+ return str if str.length <= limit
302
+ "#{str[0, limit]}…"
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ # Serializable per-session goal state for the /goal Ralph-style loop.
5
+ #
6
+ # A "goal" is a standing objective the agent works toward across multiple
7
+ # turns without the user re-prompting each time. After every turn a judge
8
+ # decides done/continue; while continuing (and under the turn budget) the
9
+ # loop feeds itself a continuation prompt and runs another turn.
10
+ class GoalState
11
+ DEFAULT_MAX_TURNS = 20
12
+
13
+ # active — loop is running, judge fires after each turn
14
+ # paused — stopped but recoverable via /goal resume (budget exhausted,
15
+ # user pause, or repeated judge failures)
16
+ # done — judge decided the goal is satisfied
17
+ STATUSES = %w[active paused done].freeze
18
+
19
+ attr_accessor :goal, :status, :turns_used, :max_turns,
20
+ :last_verdict, :last_reason, :paused_reason,
21
+ :created_at, :last_turn_at
22
+
23
+ def initialize(goal:, max_turns: DEFAULT_MAX_TURNS, status: "active",
24
+ turns_used: 0, last_verdict: nil, last_reason: nil,
25
+ paused_reason: nil, created_at: nil, last_turn_at: nil)
26
+ @goal = goal.to_s
27
+ @status = STATUSES.include?(status.to_s) ? status.to_s : "active"
28
+ @turns_used = turns_used.to_i
29
+ @max_turns = max_turns.to_i.positive? ? max_turns.to_i : DEFAULT_MAX_TURNS
30
+ @last_verdict = last_verdict
31
+ @last_reason = last_reason
32
+ @paused_reason = paused_reason
33
+ @created_at = created_at || Time.now.to_f
34
+ @last_turn_at = last_turn_at
35
+ end
36
+
37
+ def active?
38
+ @status == "active"
39
+ end
40
+
41
+ def paused?
42
+ @status == "paused"
43
+ end
44
+
45
+ def done?
46
+ @status == "done"
47
+ end
48
+
49
+ def budget_exhausted?
50
+ @turns_used >= @max_turns
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ goal: @goal,
56
+ status: @status,
57
+ turns_used: @turns_used,
58
+ max_turns: @max_turns,
59
+ last_verdict: @last_verdict,
60
+ last_reason: @last_reason,
61
+ paused_reason: @paused_reason,
62
+ created_at: @created_at,
63
+ last_turn_at: @last_turn_at
64
+ }
65
+ end
66
+
67
+ def self.from_h(data)
68
+ return nil unless data.is_a?(Hash)
69
+
70
+ # Tolerate both symbol and string keys (session.json round-trips as strings).
71
+ get = ->(key) { data[key] || data[key.to_s] }
72
+ goal = get.call(:goal).to_s
73
+ return nil if goal.strip.empty?
74
+
75
+ new(
76
+ goal: goal,
77
+ status: get.call(:status) || "active",
78
+ turns_used: get.call(:turns_used),
79
+ max_turns: get.call(:max_turns),
80
+ last_verdict: get.call(:last_verdict),
81
+ last_reason: get.call(:last_reason),
82
+ paused_reason: get.call(:paused_reason),
83
+ created_at: get.call(:created_at),
84
+ last_turn_at: get.call(:last_turn_at)
85
+ )
86
+ end
87
+ end
88
+ end
@@ -12,8 +12,11 @@ module Clacky
12
12
  :session_rollback
13
13
  ].freeze
14
14
 
15
- def initialize
15
+ attr_accessor :agent
16
+
17
+ def initialize(agent: nil)
16
18
  @hooks = Hash.new { |h, k| h[k] = [] }
19
+ @agent = agent
17
20
  end
18
21
 
19
22
  def add(event, &block)
@@ -21,20 +24,28 @@ module Clacky
21
24
  @hooks[event] << block
22
25
  end
23
26
 
27
+ # @return [Hash] `{action: :allow}`, `{action: :deny, reason:}`, or
28
+ # `{action: :handled, result:}` when a hook fulfilled the call itself.
29
+ # Extra trailing arg: the agent that owns this hook chain, so ext hooks can
30
+ # call `agent.emit_event(...)`. Blocks are procs — those declaring fewer
31
+ # params (`|call|`, `|call, result|`) silently ignore it.
24
32
  def trigger(event, *args)
25
33
  validate_event!(event)
26
34
  result = { action: :allow }
27
35
 
28
36
  @hooks[event].each do |hook|
29
37
  begin
30
- hook_result = hook.call(*args)
38
+ hook_result = hook.call(*args, @agent)
31
39
  next unless hook_result.is_a?(Hash)
32
40
  # First deny wins and stops the chain: a weaker later verdict must
33
41
  # never clobber a stronger earlier one, and the first deny's reason
34
42
  # is the one that reaches the agent. Rewrite hooks mutate `call` in
35
43
  # place (chained rewrite), so for non-deny results there's nothing to
36
44
  # merge — we just keep going.
37
- if hook_result[:action] == :deny
45
+ #
46
+ # :handled short-circuits the same way — the hook has already produced
47
+ # the tool's result, so later hooks have nothing left to act on.
48
+ if hook_result[:action] == :deny || hook_result[:action] == :handled
38
49
  result = hook_result
39
50
  break
40
51
  end
@@ -68,20 +68,23 @@ module Clacky
68
68
  def run_memory_update_subagent
69
69
  return unless should_update_memory?
70
70
 
71
- with_memory_update_phase do
72
- run_memory_update_subagent_inner
73
- end
71
+ with_memory_update_phase { run_memory_update_subagent_inner }
74
72
  end
75
73
 
76
74
  private def with_memory_update_phase
77
75
  return yield unless @ui.respond_to?(:with_phase)
78
76
 
79
- @ui.with_phase(kind: "memory_update", label: "Updating long-term memory") { yield }
77
+ pid = @ui.phase_start(kind: "memory_update", label: "Updating long-term memory")
78
+ summary = nil
79
+ begin
80
+ summary = yield
81
+ ensure
82
+ @ui.phase_end(pid, summary: summary)
83
+ end
84
+ summary
80
85
  end
81
86
 
82
87
  private def run_memory_update_subagent_inner
83
- handle = @ui&.start_progress(message: "Updating long-term memory…", style: :primary)
84
-
85
88
  # Fork subagent inheriting main agent's model, tools, and history.
86
89
  # Maximizes prompt-cache reuse: same model, same tool set, same
87
90
  # cloned history — only the +system_prompt_suffix+ (the memory
@@ -114,8 +117,6 @@ module Clacky
114
117
  }
115
118
  Clacky::Logger.error("memory_update_error", error: e)
116
119
  return
117
- ensure
118
- handle&.finish
119
120
  end
120
121
 
121
122
  return unless result
@@ -124,16 +125,14 @@ module Clacky
124
125
  # sessionbar shows the real total. The parent's task-complete cost
125
126
  # (result[:total_cost_usd] in Agent#run) stays unaffected — it
126
127
  # still reflects ONLY the user's task, not the memory update.
127
- subagent_cost = result[:total_cost_usd] || 0.0
128
- @total_cost += subagent_cost
129
- @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
130
-
131
- # Only surface a completion info line if the subagent actually
132
- # wrote something to memory. The common "No memory updates needed."
133
- # path stays silent to avoid visual noise.
134
- if subagent_wrote_memory?(subagent)
135
- @ui&.show_info("Memory updated: #{result[:iterations]} iterations, $#{subagent_cost.round(4)}")
136
- end
128
+ subagent_cost = absorb_subagent_cost(result)
129
+
130
+ # A summary is only worth showing if the subagent actually wrote
131
+ # something. The common "No memory updates needed." path returns nil
132
+ # so the phase leaves no trace at all.
133
+ return unless subagent_wrote_memory?(subagent)
134
+
135
+ "#{result[:iterations]} steps · $#{subagent_cost.round(4)}"
137
136
  end
138
137
 
139
138
  private def memory_update_enabled?
@@ -628,6 +628,7 @@ module Clacky
628
628
  lines << ""
629
629
  lines << format_message_content(content)
630
630
  lines << ""
631
+ append_ext_events_line(lines, msg)
631
632
  when "assistant"
632
633
  # If this message is itself a compressed summary, annotate the header
633
634
  # so the reader knows the original conversation is in the referenced chunk
@@ -660,6 +661,7 @@ module Clacky
660
661
  end
661
662
  lines << format_message_content(content) if content
662
663
  lines << ""
664
+ append_ext_events_line(lines, msg)
663
665
  when "tool"
664
666
  tool_name = msg[:name] || "tool"
665
667
  lines << "### Tool Result: #{tool_name}"
@@ -668,11 +670,32 @@ module Clacky
668
670
  lines << truncate_content(content.to_s, max_length: 500)
669
671
  lines << "```"
670
672
  lines << ""
673
+ append_ext_events_line(lines, msg)
671
674
  end
672
675
  end
673
676
  lines
674
677
  end
675
678
 
679
+ # Serialize persisted extension events into the chunk MD so they survive
680
+ # compression and can be replayed in place. Mirrors the "_Tool calls:"
681
+ # convention; parse_chunk_md_to_rounds reads it back.
682
+ def append_ext_events_line(lines, msg)
683
+ events = Array(msg[:ext_events])
684
+ return if events.empty?
685
+
686
+ parts = events.filter_map do |ev|
687
+ type = ev[:type] || ev["type"]
688
+ next nil if type.nil? || type.to_s.empty?
689
+
690
+ data = ev[:data] || ev["data"] || {}
691
+ "#{type} | #{data.to_json}"
692
+ end
693
+ return if parts.empty?
694
+
695
+ lines << "_Ext events: #{parts.join("; ")}_"
696
+ lines << ""
697
+ end
698
+
676
699
  # Format message content (handles string or array of content blocks)
677
700
  def format_message_content(content)
678
701
  return "" if content.nil?