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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/lib/clacky/agent/goal_manager.rb +305 -0
  4. data/lib/clacky/agent/goal_state.rb +88 -0
  5. data/lib/clacky/agent/session_serializer.rb +26 -3
  6. data/lib/clacky/agent.rb +201 -2
  7. data/lib/clacky/cli.rb +15 -15
  8. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +29 -1
  9. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  10. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +88 -26
  11. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +8 -2
  12. data/lib/clacky/default_searchers/tavily.rb +98 -0
  13. data/lib/clacky/extension/api_extension.rb +27 -1
  14. data/lib/clacky/extension/packager.rb +54 -5
  15. data/lib/clacky/idle_compression_timer.rb +1 -3
  16. data/lib/clacky/providers.rb +4 -8
  17. data/lib/clacky/search_config.rb +57 -0
  18. data/lib/clacky/server/http_server.rb +95 -16
  19. data/lib/clacky/server/session_registry.rb +10 -3
  20. data/lib/clacky/tools/web_search.rb +45 -6
  21. data/lib/clacky/ui2/components/command_suggestions.rb +17 -0
  22. data/lib/clacky/ui2/components/input_area.rb +26 -2
  23. data/lib/clacky/ui2/ui_controller.rb +17 -0
  24. data/lib/clacky/ui_interface.rb +9 -0
  25. data/lib/clacky/utils/gitignore_parser.rb +7 -1
  26. data/lib/clacky/utils/model_pricing.rb +0 -10
  27. data/lib/clacky/utils/searcher_manager.rb +87 -0
  28. data/lib/clacky/version.rb +1 -1
  29. data/lib/clacky/web/app.css +233 -30
  30. data/lib/clacky/web/app.js +12 -1
  31. data/lib/clacky/web/components/chat-navigator.js +260 -0
  32. data/lib/clacky/web/features/billing/view.js +2 -2
  33. data/lib/clacky/web/features/brand/view.js +5 -1
  34. data/lib/clacky/web/features/extensions/view.js +19 -19
  35. data/lib/clacky/web/features/skills/store.js +4 -2
  36. data/lib/clacky/web/i18n.js +44 -8
  37. data/lib/clacky/web/index.html +40 -5
  38. data/lib/clacky/web/projects.js +36 -19
  39. data/lib/clacky/web/sessions.js +171 -7
  40. data/lib/clacky/web/settings.js +85 -0
  41. data/lib/clacky/web/skills.js +24 -4
  42. data/lib/clacky/web/ws-dispatcher.js +25 -1
  43. data/lib/clacky.rb +2 -0
  44. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5803d9775474800f6f61d6771837ebe90597080e0f068a9dff49d5d44fdc90d1
4
- data.tar.gz: f111f2b083635876cd2e4477e5a64da14245684c5c18a5f5f23860541ac9def0
3
+ metadata.gz: 534fd25ab5ff8361425b6aac147c7279c3a31a873467b7da03c106a5984c7f5d
4
+ data.tar.gz: 6623ec9d40ef5869af08b3476cb913c9cdf894dcd13c4bdd7cf49dc375085730
5
5
  SHA512:
6
- metadata.gz: a0cc2a8397b5165b941a5aec4f1fc72770d47fdee4b24b72d862b12154d6a9b8114563b2e977d64e43a39c86d278fb5bbb97b045357c0a00c2f818f6466dbbf8
7
- data.tar.gz: '07913be97ae9966164649f88a8e8abc60346c80038aec65886c51d9552e75eaf1050e3a9cc6fc6e28d1137d00d9fabcf735a8ac195bc3c5554890a6a8c0a073d'
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(preserve_updated_at: true))
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, preserve_updated_at: false)
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 || (preserve_updated_at && @persisted_updated_at) || Time.now.iso8601).then { |v| v.is_a?(String) ? v : v.iso8601 },
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
  }