openclacky 1.5.4 → 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 +63 -0
- data/lib/clacky/agent/goal_manager.rb +305 -0
- data/lib/clacky/agent/goal_state.rb +88 -0
- data/lib/clacky/agent/message_compressor_helper.rb +4 -1
- data/lib/clacky/agent/session_serializer.rb +27 -3
- data/lib/clacky/agent.rb +202 -3
- data/lib/clacky/agent_config.rb +4 -2
- data/lib/clacky/agent_profile.rb +25 -3
- 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 +4 -4
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +130 -59
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +12 -4
- data/lib/clacky/default_extensions/git/ext.yml +3 -0
- data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
- data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
- data/lib/clacky/default_searchers/tavily.rb +98 -0
- data/lib/clacky/default_skills/new/SKILL.md +17 -12
- data/lib/clacky/extension/api_extension.rb +29 -1
- data/lib/clacky/extension/loader.rb +1 -0
- data/lib/clacky/extension/packager.rb +114 -20
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/idle_compression_timer.rb +1 -3
- data/lib/clacky/message_format/anthropic.rb +1 -1
- data/lib/clacky/message_format/bedrock.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +8 -0
- data/lib/clacky/providers.rb +65 -8
- data/lib/clacky/search_config.rb +57 -0
- data/lib/clacky/server/http_server.rb +249 -31
- data/lib/clacky/server/session_registry.rb +10 -3
- data/lib/clacky/server/web_ui_controller.rb +12 -2
- data/lib/clacky/session_manager.rb +1 -0
- data/lib/clacky/tools/terminal.rb +30 -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 +530 -197
- data/lib/clacky/web/app.js +28 -3
- 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/store.js +214 -9
- data/lib/clacky/web/features/extensions/view.js +158 -13
- data/lib/clacky/web/features/new-session/store.js +25 -3
- data/lib/clacky/web/features/new-session/view.js +337 -140
- data/lib/clacky/web/features/skills/store.js +4 -2
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/i18n.js +72 -18
- data/lib/clacky/web/index.html +71 -64
- data/lib/clacky/web/projects.js +36 -19
- data/lib/clacky/web/sessions.js +173 -8
- data/lib/clacky/web/settings.js +85 -0
- data/lib/clacky/web/skills.js +24 -4
- data/lib/clacky/web/utils.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +63 -3
- 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,69 @@ 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
|
+
|
|
39
|
+
## [1.5.5] - 2026-08-04
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- Ollama (Cloud) provider preset — connect to self-hosted Ollama instances with one click
|
|
43
|
+
- Extension WebSocket bridge — extensions can now subscribe to session events via `Clacky.ext` bus
|
|
44
|
+
- Hidden field support for extension agent profiles
|
|
45
|
+
- Max reasoning effort level beyond `xhigh` (supports `max` tier)
|
|
46
|
+
- Smart terminal output truncation — keeps head and tail of long output instead of cutting at the end
|
|
47
|
+
- Time machine panel now attached to all views
|
|
48
|
+
- Persist sort and installed filter selection across sessions in extensions panel
|
|
49
|
+
- New extension button added to extensions management page header
|
|
50
|
+
- Auto cleanup of trash sessions after 7 days
|
|
51
|
+
|
|
52
|
+
### Improved
|
|
53
|
+
- Extensions panel redesigned with system/market tabs and toggle switch
|
|
54
|
+
- New-session page: replaced advanced modal with inline context bar chips; agents refresh on open with localized badges, ordered by most-recently-used
|
|
55
|
+
- Overflow tooltip shown on truncated skill descriptions
|
|
56
|
+
- Compression: idle and threshold switches are now separate controls
|
|
57
|
+
- Extension install is now async with progress polling
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- Ollama base URL corrected from `/api` to root domain
|
|
61
|
+
- Extension WebSocket bridge included `hidden` field in verifier AGENT_KEYS
|
|
62
|
+
- User message files render immediately in Web UI; local disk images proxied correctly
|
|
63
|
+
- Sidebar visibility persisted across page reloads
|
|
64
|
+
- Brand logo injected server-side to eliminate flash on load
|
|
65
|
+
- Extension uninstall 404 resolved by normalizing slug field
|
|
66
|
+
- Extension homepage link color variable corrected
|
|
67
|
+
- `request_user_feedback` skill uses structured JSON example to avoid literal `\n` in output
|
|
68
|
+
- `create_session` in extensions now broadcasts session update correctly
|
|
69
|
+
|
|
7
70
|
## [1.5.4] - 2026-08-01
|
|
8
71
|
|
|
9
72
|
### 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
|
|
@@ -23,6 +23,7 @@ module Clacky
|
|
|
23
23
|
Clacky::Logger.info(
|
|
24
24
|
"Idle compression skipped",
|
|
25
25
|
enable_compression: @config.enable_compression,
|
|
26
|
+
enable_idle_compression: @config.enable_idle_compression,
|
|
26
27
|
previous_total_tokens: @previous_total_tokens,
|
|
27
28
|
history_size: @history.size,
|
|
28
29
|
idle_threshold: IDLE_COMPRESSION_THRESHOLD,
|
|
@@ -129,8 +130,10 @@ module Clacky
|
|
|
129
130
|
# (force: true) is still allowed as a safety net for long-running subagents.
|
|
130
131
|
return nil if @is_subagent && !force
|
|
131
132
|
|
|
132
|
-
# Check if compression is enabled
|
|
133
|
+
# Check if compression is enabled (master switch)
|
|
133
134
|
return nil unless @config.enable_compression
|
|
135
|
+
# Idle compression has its own sub-switch under the master
|
|
136
|
+
return nil if force && !@config.enable_idle_compression
|
|
134
137
|
|
|
135
138
|
# Use the larger of: API-reported tokens from last response, or current
|
|
136
139
|
# estimated history size. The estimate guards the case where a single
|
|
@@ -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
|
|
@@ -224,6 +246,7 @@ module Clacky
|
|
|
224
246
|
# (falling back to the current default if the model no longer exists).
|
|
225
247
|
permission_mode: @config.permission_mode.to_s,
|
|
226
248
|
enable_compression: @config.enable_compression,
|
|
249
|
+
enable_idle_compression: @config.enable_idle_compression,
|
|
227
250
|
enable_prompt_caching: @config.enable_prompt_caching,
|
|
228
251
|
max_tokens: @config.max_tokens,
|
|
229
252
|
verbose: @config.verbose,
|
|
@@ -240,6 +263,7 @@ module Clacky
|
|
|
240
263
|
},
|
|
241
264
|
channel_info: @channel_info,
|
|
242
265
|
project_id: @project_id,
|
|
266
|
+
goal: @goal_manager&.to_h,
|
|
243
267
|
stats: stats_data,
|
|
244
268
|
messages: @history.to_a
|
|
245
269
|
}
|