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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. 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: (updated_at || @persisted_updated_at || Time.now.iso8601).then { |v| v.is_a?(String) ? v : v.iso8601 },
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
- # Split @history into rounds, each starting at a real user message
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 = File.read(resolved).then do |s|
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 || Time.now).to_f
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 (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
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 = "assistant"
464
+ current_role = header[:role]
505
465
  current_lines = []
506
- current_nested_chunk = File.join(chunk_dir, m[1])
507
- current_task_id = nil
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 = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
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
- current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
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