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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +58 -0
- data/CONTRIBUTING.md +9 -0
- data/Rakefile +15 -0
- data/docs/design-emit-and-background-skills.md +253 -0
- data/docs/mcp-architecture.md +48 -9
- data/docs/mcp.example.json +9 -0
- data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
- data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
- data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -2
- data/lib/clacky/agent/goal_manager.rb +305 -0
- data/lib/clacky/agent/goal_state.rb +88 -0
- data/lib/clacky/agent/hook_manager.rb +14 -3
- data/lib/clacky/agent/memory_updater.rb +17 -18
- data/lib/clacky/agent/message_compressor_helper.rb +23 -0
- data/lib/clacky/agent/session_serializer.rb +162 -25
- data/lib/clacky/agent/skill_auto_creator.rb +1 -2
- data/lib/clacky/agent/skill_evolution.rb +11 -7
- data/lib/clacky/agent/skill_manager.rb +20 -18
- data/lib/clacky/agent/skill_reflector.rb +3 -6
- data/lib/clacky/agent.rb +312 -22
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +19 -15
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +21 -7
- data/lib/clacky/default_searchers/tavily.rb +98 -0
- data/lib/clacky/extension/api_extension.rb +37 -3
- data/lib/clacky/extension/packager.rb +54 -5
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/idle_compression_timer.rb +1 -3
- data/lib/clacky/locales/en.rb +2 -0
- data/lib/clacky/locales/zh.rb +2 -0
- data/lib/clacky/mcp/cli_commands.rb +61 -0
- data/lib/clacky/mcp/client.rb +18 -2
- data/lib/clacky/mcp/connection_manager.rb +85 -0
- data/lib/clacky/mcp/http_transport.rb +25 -9
- data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
- data/lib/clacky/mcp/oauth/config.rb +51 -0
- data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
- data/lib/clacky/mcp/oauth/session.rb +88 -0
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +32 -1
- data/lib/clacky/providers.rb +15 -13
- data/lib/clacky/search_config.rb +57 -0
- data/lib/clacky/server/http_server.rb +136 -37
- data/lib/clacky/server/session_registry.rb +21 -18
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +13 -11
- data/lib/clacky/shell_hook_loader.rb +14 -8
- data/lib/clacky/tools/invoke_skill.rb +3 -2
- data/lib/clacky/tools/terminal.rb +11 -4
- data/lib/clacky/tools/web_search.rb +45 -6
- data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
- data/lib/clacky/ui2/components/input_area.rb +26 -2
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +223 -6
- data/lib/clacky/ui_interface.rb +22 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/gitignore_parser.rb +7 -1
- data/lib/clacky/utils/model_pricing.rb +21 -10
- data/lib/clacky/utils/searcher_manager.rb +87 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +243 -33
- data/lib/clacky/web/app.js +12 -1
- data/lib/clacky/web/components/chat-navigator.js +260 -0
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/brand/view.js +5 -1
- data/lib/clacky/web/features/extensions/view.js +19 -19
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/features/skills/store.js +4 -2
- data/lib/clacky/web/i18n.js +64 -14
- data/lib/clacky/web/index.html +40 -9
- data/lib/clacky/web/projects.js +62 -19
- data/lib/clacky/web/sessions.js +329 -115
- data/lib/clacky/web/settings.js +86 -0
- data/lib/clacky/web/skills.js +24 -4
- data/lib/clacky/web/ws-dispatcher.js +86 -35
- data/lib/clacky.rb +4 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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?
|