openclacky 1.5.13 → 1.5.15
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 +96 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +53 -98
- data/lib/clacky/agent.rb +477 -297
- data/lib/clacky/agent_config.rb +5 -3
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +7 -0
- data/lib/clacky/brand_config.rb +27 -19
- data/lib/clacky/cli.rb +92 -12
- data/lib/clacky/cli_guidance.rb +48 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +18 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/json_ui_controller.rb +4 -2
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/message_format/open_ai.rb +22 -5
- data/lib/clacky/message_format/open_ai_responses.rb +7 -3
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +44 -13
- data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +25 -7
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/dir_picker.rb +67 -4
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +391 -75
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/session_registry.rb +4 -0
- data/lib/clacky/server/web_ui_controller.rb +10 -5
- data/lib/clacky/session_manager.rb +52 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +91 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +37 -4
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/screen_buffer.rb +1 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -12
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +118 -68
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +998 -168
- data/lib/clacky/web/app.js +19 -0
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/composer.js +3 -1
- data/lib/clacky/web/components/datepicker.js +19 -19
- data/lib/clacky/web/components/mentions.js +11 -14
- data/lib/clacky/web/components/model-picker.js +66 -23
- data/lib/clacky/web/components/quote-select.js +341 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/extensions/store.js +4 -1
- data/lib/clacky/web/features/model-tester/store.js +37 -4
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +474 -43
- data/lib/clacky/web/i18n.js +158 -23
- data/lib/clacky/web/index.html +57 -29
- data/lib/clacky/web/sessions.js +517 -127
- data/lib/clacky/web/settings.js +155 -6
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +161 -4
- data/lib/clacky.rb +2 -0
- metadata +13 -1
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "strscan"
|
|
4
|
+
require_relative "history_navigation"
|
|
5
|
+
require_relative "chunk_index"
|
|
4
6
|
|
|
5
7
|
module Clacky
|
|
6
8
|
class Agent
|
|
7
9
|
# Session serialization for saving and restoring agent state
|
|
8
10
|
# Handles session data serialization and deserialization
|
|
9
11
|
module SessionSerializer
|
|
12
|
+
include HistoryNavigation
|
|
13
|
+
include ChunkIndex
|
|
10
14
|
# Restore from a saved session
|
|
11
15
|
# @param session_data [Hash] Saved session data
|
|
12
16
|
def restore_session(session_data)
|
|
@@ -14,6 +18,10 @@ module Clacky
|
|
|
14
18
|
@name = session_data[:name] || ""
|
|
15
19
|
@pinned = session_data[:pinned] || false
|
|
16
20
|
@history = MessageHistory.new(session_data[:messages] || [])
|
|
21
|
+
@input_queue = (session_data[:pending_inputs] || []).map do |entry|
|
|
22
|
+
entry.reject { |key, _| key == :steer_target }.merge(delivery: "queue")
|
|
23
|
+
end
|
|
24
|
+
@accepting_steering = false
|
|
17
25
|
@todos = session_data[:todos] || [] # Restore todos from session
|
|
18
26
|
@iterations = session_data.dig(:stats, :total_iterations) || 0
|
|
19
27
|
@total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0
|
|
@@ -223,15 +231,25 @@ module Clacky
|
|
|
223
231
|
stats_data[:last_error] = error_message if status == :error && error_message
|
|
224
232
|
stats_data[:last_error_raw] = raw_message if status == :error && raw_message
|
|
225
233
|
|
|
234
|
+
# Saves that carry no user-visible activity (idle compression, channel
|
|
235
|
+
# bookkeeping) pass no timestamp and reuse the last one this agent
|
|
236
|
+
# wrote. Keeping it in memory rather than falling back to the value
|
|
237
|
+
# loaded at restore time is what stops those saves from dragging an
|
|
238
|
+
# actively used session back to the moment it was restored.
|
|
239
|
+
stamp = updated_at || @persisted_updated_at || Time.now
|
|
240
|
+
stamp = stamp.iso8601 if stamp.respond_to?(:iso8601)
|
|
241
|
+
@persisted_updated_at = stamp
|
|
242
|
+
|
|
226
243
|
{
|
|
227
244
|
session_id: @session_id,
|
|
228
245
|
name: @name,
|
|
229
246
|
pinned: @pinned,
|
|
230
247
|
created_at: @created_at,
|
|
231
|
-
updated_at:
|
|
248
|
+
updated_at: stamp,
|
|
232
249
|
working_dir: @working_dir,
|
|
233
250
|
source: @source.to_s, # "manual" | "cron" | "channel" | "setup"
|
|
234
251
|
agent_profile: @agent_profile&.name || "", # "general" | "coding" | custom
|
|
252
|
+
pending_inputs: pending_inputs,
|
|
235
253
|
todos: @todos, # Include todos in session data
|
|
236
254
|
time_machine: { # Include Time Machine state
|
|
237
255
|
task_parents: @task_parents || {},
|
|
@@ -289,77 +307,8 @@ module Clacky
|
|
|
289
307
|
# created_at < before. Pass nil to get the most recent rounds.
|
|
290
308
|
# @return [Hash] { has_more: Boolean } — whether older rounds exist beyond this page
|
|
291
309
|
def replay_history(ui, limit: 20, before: nil)
|
|
292
|
-
|
|
293
|
-
rounds =
|
|
294
|
-
current_round = nil
|
|
295
|
-
|
|
296
|
-
@history.to_a.each do |msg|
|
|
297
|
-
role = msg[:role].to_s
|
|
298
|
-
|
|
299
|
-
# A real user message can have either a String content or an Array content
|
|
300
|
-
# (Array = multipart: text + image blocks). Exclude system-injected messages
|
|
301
|
-
# and synthetic [SYSTEM] text messages.
|
|
302
|
-
is_real_user_msg = role == "user" && !msg[:system_injected] &&
|
|
303
|
-
if msg[:content].is_a?(String)
|
|
304
|
-
!msg[:content].start_with?("[SYSTEM]")
|
|
305
|
-
elsif msg[:content].is_a?(Array)
|
|
306
|
-
# Must contain at least one text or image block (not a tool_result array).
|
|
307
|
-
# "image_url" covers image-only messages (user sent a picture with no
|
|
308
|
-
# accompanying text); without it such messages start no round and get
|
|
309
|
-
# dropped on replay, making the image vanish on session reopen.
|
|
310
|
-
msg[:content].any? { |b| b.is_a?(Hash) && %w[text image image_url].include?(b[:type].to_s) }
|
|
311
|
-
else
|
|
312
|
-
false
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
if is_real_user_msg
|
|
316
|
-
# Start a new round at each real user message.
|
|
317
|
-
# editable: true — this message still lives in the active in-memory
|
|
318
|
-
# @history, so truncate_from_created_at can locate and truncate it.
|
|
319
|
-
current_round = { user_msg: msg, events: [], editable: true }
|
|
320
|
-
rounds << current_round
|
|
321
|
-
elsif current_round
|
|
322
|
-
current_round[:events] << msg
|
|
323
|
-
elsif msg[:compressed_summary]
|
|
324
|
-
# Compressed summary sitting before any user rounds — expand ALL chunk
|
|
325
|
-
# MD files that belong to the same session (siblings of chunk_path),
|
|
326
|
-
# in chunk-index ascending order.
|
|
327
|
-
#
|
|
328
|
-
# Under the current "single summary + previous_chunks index" scheme,
|
|
329
|
-
# session.json only keeps the newest compressed_summary message (which
|
|
330
|
-
# points at the newest chunk). Older chunks (chunk-1..chunk-N-1) are
|
|
331
|
-
# referenced only as basenames inside the summary text. Expanding just
|
|
332
|
-
# msg[:chunk_path] would therefore lose all prior chunks on replay.
|
|
333
|
-
#
|
|
334
|
-
# chunk_path may be blank when a hot restart's SIGKILL killed the
|
|
335
|
-
# worker after the chunk file was written but before its path was
|
|
336
|
-
# persisted into session.json. Fall back to discovering the chunks
|
|
337
|
-
# on disk from session_id + created_at so the history is not lost.
|
|
338
|
-
chunk_paths = if msg[:chunk_path].to_s.empty?
|
|
339
|
-
session_manager.chunks_for_current(@session_id, @created_at).map { |c| c[:path] }
|
|
340
|
-
else
|
|
341
|
-
sibling_chunks_of(msg[:chunk_path])
|
|
342
|
-
end
|
|
343
|
-
chunk_rounds = chunk_paths.flat_map { |p| parse_chunk_md_to_rounds(p) }
|
|
344
|
-
rounds.concat(chunk_rounds)
|
|
345
|
-
# After expanding, treat the last chunk round as the current round so that
|
|
346
|
-
# any orphaned assistant/tool messages that follow in session.json (belonging
|
|
347
|
-
# to the same task whose user message was compressed into the chunk) get
|
|
348
|
-
# appended here instead of being silently discarded.
|
|
349
|
-
current_round = rounds.last unless chunk_rounds.empty?
|
|
350
|
-
elsif rounds.last
|
|
351
|
-
# Orphaned non-user message with no current_round yet (e.g. recent_messages
|
|
352
|
-
# after compression started mid-task with no leading user message).
|
|
353
|
-
# Attach to the last known round rather than drop silently.
|
|
354
|
-
rounds.last[:events] << msg
|
|
355
|
-
end
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
# Expand any compressed_summary assistant messages sitting inside a round's events.
|
|
359
|
-
# These occur when compression happened mid-round (rare) — expand them in-place.
|
|
360
|
-
rounds.each do |round|
|
|
361
|
-
round[:events].select! { |ev| !ev[:compressed_summary] }
|
|
362
|
-
end
|
|
310
|
+
sources = navigation_sources
|
|
311
|
+
rounds = sources.each_index.flat_map { |index| navigation_source_rounds(sources, index) }
|
|
363
312
|
|
|
364
313
|
# Apply before-cursor filter: only rounds whose user message created_at < before
|
|
365
314
|
if before
|
|
@@ -379,6 +328,11 @@ module Clacky
|
|
|
379
328
|
# Take the most recent `limit` rounds
|
|
380
329
|
page = rounds.last(limit)
|
|
381
330
|
|
|
331
|
+
replay_rounds(ui, page)
|
|
332
|
+
{ has_more: has_more }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private def replay_rounds(ui, page)
|
|
382
336
|
page.each do |round|
|
|
383
337
|
msg = round[:user_msg]
|
|
384
338
|
raw_text = msg[:display_text] || extract_text_from_content(msg[:content])
|
|
@@ -410,7 +364,6 @@ module Clacky
|
|
|
410
364
|
end
|
|
411
365
|
end
|
|
412
366
|
|
|
413
|
-
{ has_more: has_more }
|
|
414
367
|
end
|
|
415
368
|
|
|
416
369
|
# Return all chunk MD file paths that belong to the same session as
|
|
@@ -446,7 +399,8 @@ module Clacky
|
|
|
446
399
|
#
|
|
447
400
|
# @param chunk_path [String] Path to the chunk md file
|
|
448
401
|
# @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
|
|
449
|
-
private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new
|
|
402
|
+
private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new, excluded_paths: Set.new,
|
|
403
|
+
byte_range: nil, index: nil, round_offset: 0)
|
|
450
404
|
return [] unless chunk_path
|
|
451
405
|
|
|
452
406
|
# 1. Try the stored absolute path first (same machine, normal case).
|
|
@@ -468,7 +422,14 @@ module Clacky
|
|
|
468
422
|
|
|
469
423
|
# Scrub invalid UTF-8 bytes defensively — chunk files written before
|
|
470
424
|
# the 0.9.37 fix may contain poisoned bytes from file_reader results.
|
|
471
|
-
raw =
|
|
425
|
+
raw = (if byte_range
|
|
426
|
+
File.open(resolved, "rb") do |file|
|
|
427
|
+
file.seek(byte_range[:start])
|
|
428
|
+
file.read(byte_range[:length]).to_s.force_encoding(Encoding::UTF_8)
|
|
429
|
+
end
|
|
430
|
+
else
|
|
431
|
+
File.read(resolved)
|
|
432
|
+
end).then do |s|
|
|
472
433
|
s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
|
|
473
434
|
s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
474
435
|
end
|
|
@@ -486,7 +447,7 @@ module Clacky
|
|
|
486
447
|
end
|
|
487
448
|
end
|
|
488
449
|
end
|
|
489
|
-
base_time = (archived_at ||
|
|
450
|
+
base_time = index ? index[:base_time] : (archived_at || File.mtime(resolved)).to_f
|
|
490
451
|
chunk_dir = File.dirname(chunk_path.to_s)
|
|
491
452
|
|
|
492
453
|
# Split into sections by ## headings
|
|
@@ -498,25 +459,12 @@ module Clacky
|
|
|
498
459
|
|
|
499
460
|
raw.each_line do |line|
|
|
500
461
|
stripped = line.chomp
|
|
501
|
-
if (
|
|
502
|
-
# Nested chunk reference — record it, treat as assistant section
|
|
462
|
+
if (header = chunk_section_header(stripped, chunk_dir))
|
|
503
463
|
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
504
|
-
current_role =
|
|
464
|
+
current_role = header[:role]
|
|
505
465
|
current_lines = []
|
|
506
|
-
current_nested_chunk =
|
|
507
|
-
current_task_id =
|
|
508
|
-
elsif (m = stripped.match(/\A## (User|Assistant)(?: \[Task ([1-9]\d*)\])?\z/))
|
|
509
|
-
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
510
|
-
current_role = m[1].downcase
|
|
511
|
-
current_lines = []
|
|
512
|
-
current_nested_chunk = nil
|
|
513
|
-
current_task_id = m[2]&.to_i
|
|
514
|
-
elsif stripped.match?(/\A### Tool Result:/)
|
|
515
|
-
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
516
|
-
current_role = "tool"
|
|
517
|
-
current_lines = []
|
|
518
|
-
current_nested_chunk = nil
|
|
519
|
-
current_task_id = nil
|
|
466
|
+
current_nested_chunk = header[:nested_chunk]
|
|
467
|
+
current_task_id = header[:task_id]
|
|
520
468
|
else
|
|
521
469
|
current_lines << line
|
|
522
470
|
end
|
|
@@ -538,11 +486,15 @@ module Clacky
|
|
|
538
486
|
|
|
539
487
|
# Nested chunk: expand it recursively, prepend before current rounds
|
|
540
488
|
if sec[:nested_chunk]
|
|
541
|
-
nested =
|
|
489
|
+
nested = if excluded_paths.include?(File.basename(sec[:nested_chunk]))
|
|
490
|
+
[]
|
|
491
|
+
else
|
|
492
|
+
parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited, excluded_paths: excluded_paths)
|
|
493
|
+
end
|
|
542
494
|
rounds = nested + rounds unless nested.empty?
|
|
543
495
|
# Also render its summary text as an assistant event in current round if any
|
|
544
496
|
if current_round && !text.empty?
|
|
545
|
-
current_round[:events] << { role: "assistant", content: text }
|
|
497
|
+
current_round[:events] << { role: "assistant", content: text, compressed_summary: true }
|
|
546
498
|
end
|
|
547
499
|
next
|
|
548
500
|
end
|
|
@@ -552,7 +504,7 @@ module Clacky
|
|
|
552
504
|
if sec[:role] == "user"
|
|
553
505
|
round_index += 1
|
|
554
506
|
# Synthetic timestamp: spread rounds backwards from archived_at
|
|
555
|
-
synthetic_ts = base_time - (sections.size - round_index) * 1.0
|
|
507
|
+
synthetic_ts = base_time - ((index ? index[:section_count] : sections.size) - round_offset - round_index) * 1.0
|
|
556
508
|
user_msg = {
|
|
557
509
|
role: "user",
|
|
558
510
|
content: text,
|
|
@@ -594,7 +546,9 @@ module Clacky
|
|
|
594
546
|
|
|
595
547
|
# Flush any plain text
|
|
596
548
|
plain_text = remaining_lines.join.strip
|
|
597
|
-
|
|
549
|
+
unless plain_text.empty?
|
|
550
|
+
current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty? }
|
|
551
|
+
end
|
|
598
552
|
|
|
599
553
|
# Emit one synthetic tool_calls message per detected tool
|
|
600
554
|
pending_tool_entries.each do |entry|
|
|
@@ -774,7 +728,8 @@ module Clacky
|
|
|
774
728
|
else
|
|
775
729
|
raw_text
|
|
776
730
|
end
|
|
777
|
-
ui.show_assistant_message(text, files: [], created_at: msg[:created_at]
|
|
731
|
+
ui.show_assistant_message(text, files: [], created_at: msg[:created_at],
|
|
732
|
+
interim: !!msg[:interim] || Array(msg[:tool_calls]).any?)
|
|
778
733
|
end
|
|
779
734
|
|
|
780
735
|
# Tool calls embedded in assistant message
|