openclacky 1.5.5 → 1.5.6
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/CHANGELOG.md +32 -0
- data/lib/clacky/agent/goal_manager.rb +305 -0
- data/lib/clacky/agent/goal_state.rb +88 -0
- data/lib/clacky/agent/session_serializer.rb +26 -3
- data/lib/clacky/agent.rb +201 -2
- data/lib/clacky/cli.rb +15 -15
- 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 +8 -2
- data/lib/clacky/default_searchers/tavily.rb +98 -0
- data/lib/clacky/extension/api_extension.rb +27 -1
- data/lib/clacky/extension/packager.rb +54 -5
- data/lib/clacky/idle_compression_timer.rb +1 -3
- data/lib/clacky/providers.rb +4 -8
- data/lib/clacky/search_config.rb +57 -0
- data/lib/clacky/server/http_server.rb +95 -16
- data/lib/clacky/server/session_registry.rb +10 -3
- 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/ui_controller.rb +17 -0
- data/lib/clacky/ui_interface.rb +9 -0
- data/lib/clacky/utils/gitignore_parser.rb +7 -1
- data/lib/clacky/utils/model_pricing.rb +0 -10
- data/lib/clacky/utils/searcher_manager.rb +87 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +233 -30
- 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/skills/store.js +4 -2
- data/lib/clacky/web/i18n.js +44 -8
- data/lib/clacky/web/index.html +40 -5
- data/lib/clacky/web/projects.js +36 -19
- data/lib/clacky/web/sessions.js +171 -7
- data/lib/clacky/web/settings.js +85 -0
- data/lib/clacky/web/skills.js +24 -4
- data/lib/clacky/web/ws-dispatcher.js +25 -1
- data/lib/clacky.rb +2 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 534fd25ab5ff8361425b6aac147c7279c3a31a873467b7da03c106a5984c7f5d
|
|
4
|
+
data.tar.gz: 6623ec9d40ef5869af08b3476cb913c9cdf894dcd13c4bdd7cf49dc375085730
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 321f66b4bc0b80992c6a6bfe30da47d0ff561e6c75d912cba8e4d2a76084dc6ce54531cccadea2321d6c7a15a1ad9e2bf2362b4777a259a195a5056b7acdec4b
|
|
7
|
+
data.tar.gz: ad65e89da8c81b0b5ef33daf1ca254db77e4c409c0cf801ffc7761af4fa2b9949a122b5f18d9e0bdd96910d90a1cb21984ff4defb22cb9f14094af34f28e68a7
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.5.6] - 2026-08-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Pluggable web_search providers - drop a script into ~/.clacky/searchers/ to use Tavily, Brave, Serper, or any search API instead of DuckDuckGo/Bing scrapers; built-in engines stay as fallback
|
|
11
|
+
- /goal command - Ralph-style standing goal loop with judge-driven auto-continuation
|
|
12
|
+
- Right-edge chat navigator with hover preview and click-to-jump
|
|
13
|
+
- Project edit modal replaces inline rename
|
|
14
|
+
- Installed tab and hidden Market tab for brand users in extensions panel
|
|
15
|
+
- ext-studio publish-to-brand flow with success toast
|
|
16
|
+
- Todo panel with SVG icons and repositioned layout (#437 - @shipinliang)
|
|
17
|
+
- Skill autocomplete with MRU sorting and constant-managed localStorage keys
|
|
18
|
+
- Completion token billing-cell output style
|
|
19
|
+
- Expose project_manager and project_id to extensions (#444)
|
|
20
|
+
|
|
21
|
+
### Improved
|
|
22
|
+
- Creator Center UI copy and button naming unified
|
|
23
|
+
- Sessions list page size reduced to 10 for faster loading
|
|
24
|
+
- Load-more sessions button styling refined
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- Pagination cursor pollution and premature cron entry display in sessions list
|
|
28
|
+
- updated_at default flipped to preserve instead of refresh
|
|
29
|
+
- Extension packs now exclude gitignored and VCS files
|
|
30
|
+
- Brand logo flash before load
|
|
31
|
+
- SKILL_AC_SHOW_SYSTEM_KEY ReferenceError in skills store
|
|
32
|
+
- Ruby 3.0+ endless method syntax replaced for Ruby 2.6 compatibility
|
|
33
|
+
- session_update bridge shape normalized and ext-workspace teardown captured (#436 - @sundevilyang)
|
|
34
|
+
|
|
35
|
+
### More
|
|
36
|
+
- Retire doubao-seed-2.0-pro and doubao-seed-2.0-code models
|
|
37
|
+
- Enforce 50MB size limit on extension pack output
|
|
38
|
+
|
|
7
39
|
## [1.5.5] - 2026-08-04
|
|
8
40
|
|
|
9
41
|
### Added
|
|
@@ -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
|
|
@@ -33,6 +33,12 @@ module Clacky
|
|
|
33
33
|
# Restore project association (nil = no project)
|
|
34
34
|
@project_id = session_data[:project_id]
|
|
35
35
|
|
|
36
|
+
# Restore standing goal state (nil = no goal). The judge client/model
|
|
37
|
+
# are rebound lazily via #goal_manager when first accessed after
|
|
38
|
+
# restore — but the persisted state must be reattached here so a
|
|
39
|
+
# restored active goal keeps its progress and continues.
|
|
40
|
+
restore_goal_state(session_data[:goal])
|
|
41
|
+
|
|
36
42
|
# Restore cache statistics if available
|
|
37
43
|
@cache_stats = session_data.dig(:stats, :cache_stats) || {
|
|
38
44
|
cache_creation_input_tokens: 0,
|
|
@@ -120,6 +126,22 @@ module Clacky
|
|
|
120
126
|
refresh_system_prompt
|
|
121
127
|
end
|
|
122
128
|
|
|
129
|
+
# Reattach persisted goal state after a restore. Builds a fresh
|
|
130
|
+
# GoalManager (bound to this agent's judge client/model) and injects the
|
|
131
|
+
# deserialized GoalState so a restored active goal keeps its progress.
|
|
132
|
+
private def restore_goal_state(goal_data)
|
|
133
|
+
return if goal_data.nil? || goal_data.empty?
|
|
134
|
+
|
|
135
|
+
state = GoalState.from_h(goal_data)
|
|
136
|
+
@goal_manager = GoalManager.new(
|
|
137
|
+
judge_client: @client,
|
|
138
|
+
judge_model: judge_model_name,
|
|
139
|
+
state: state
|
|
140
|
+
)
|
|
141
|
+
rescue => e
|
|
142
|
+
Clacky::Logger.warn("restore_goal_state failed: #{e.message}")
|
|
143
|
+
end
|
|
144
|
+
|
|
123
145
|
# Fill missing entries in @task_meta from @history (for sessions saved
|
|
124
146
|
# before task_meta existed, or for tasks whose meta was lost). The first
|
|
125
147
|
# real user message of each task supplies the title; created_at becomes
|
|
@@ -168,7 +190,7 @@ module Clacky
|
|
|
168
190
|
latest_path = chunks.last[:path]
|
|
169
191
|
broken.each { |m| m[:chunk_path] = latest_path }
|
|
170
192
|
|
|
171
|
-
session_manager.save(to_session_data
|
|
193
|
+
session_manager.save(to_session_data)
|
|
172
194
|
rescue => e
|
|
173
195
|
Clacky::Logger.warn("heal_missing_chunk_paths! failed: #{e.message}")
|
|
174
196
|
end
|
|
@@ -183,7 +205,7 @@ module Clacky
|
|
|
183
205
|
# @param status [Symbol] Status of the last task: :success, :error, or :interrupted
|
|
184
206
|
# @param error_message [String] Error message if status is :error
|
|
185
207
|
# @return [Hash] Session data ready for serialization
|
|
186
|
-
def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil
|
|
208
|
+
def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil)
|
|
187
209
|
stats_data = {
|
|
188
210
|
total_tasks: @total_tasks,
|
|
189
211
|
total_iterations: @iterations,
|
|
@@ -206,7 +228,7 @@ module Clacky
|
|
|
206
228
|
pinned: @pinned,
|
|
207
229
|
hidden: @hidden,
|
|
208
230
|
created_at: @created_at,
|
|
209
|
-
updated_at: (updated_at ||
|
|
231
|
+
updated_at: (updated_at || @persisted_updated_at || Time.now.iso8601).then { |v| v.is_a?(String) ? v : v.iso8601 },
|
|
210
232
|
working_dir: @working_dir,
|
|
211
233
|
source: @source.to_s, # "manual" | "cron" | "channel" | "setup"
|
|
212
234
|
agent_profile: @agent_profile&.name || "", # "general" | "coding" | custom
|
|
@@ -241,6 +263,7 @@ module Clacky
|
|
|
241
263
|
},
|
|
242
264
|
channel_info: @channel_info,
|
|
243
265
|
project_id: @project_id,
|
|
266
|
+
goal: @goal_manager&.to_h,
|
|
244
267
|
stats: stats_data,
|
|
245
268
|
messages: @history.to_a
|
|
246
269
|
}
|