openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -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 +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca259b2f17df9695e21c4d4c4f18ee83affec0fc41d67db829d903e1c3bb008d
4
- data.tar.gz: b0bfd31378c6b452a7aef246d3d316242a91fa6366f543c45edcff8c3f44ac6c
3
+ metadata.gz: 87ba4a4c52b1e52b0af0aad921bac4b8addd3d95955f44442894753ffd73ad14
4
+ data.tar.gz: 0a861cf43fcecc64275cb243029f7cc621ac32dfab949e0768aadfaf7e0f9490
5
5
  SHA512:
6
- metadata.gz: cfcb5b28264aeb2acb6b121b89c395e4e9241b3a3135c2e9fb7d565365ed263c701f939448a2c01cb229359cca11402b36b081d4edff6231ce8e46e72d253498
7
- data.tar.gz: 95cca310ebd8e0a23b1f1e5aed241b6e5fbc19aa206fdb16301dfd3b90f07e33424a44686c10b2d6cf4d3344c75eab2c033d7b3e4e7ef6c9a40b7879dd07a88b
6
+ metadata.gz: 1b476b0c9ef80a39ac6830c35cac601abb5086d639d2806fc473af3a9074af25449f6f8befeea54bc07e38571e484f4012150d3a53adef674534f8b89ca8771e
7
+ data.tar.gz: 5ee52bb903109343a3a7adb9dc3681be836e22dd43f1b12021def3b32383c205b3750283b3af357f0e01b580ad1f5a8e2507c9f6133789b4813378e5a56120b3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ 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.14] - 2026-09-10
8
+
9
+ ### Added
10
+ - Add a click-driven live preview panel for workspace files and localhost dev servers
11
+ - Open local files in the preview panel via file:// URLs and bare absolute paths
12
+ - Add GPT-5.6 Sol/Terra/Luna and GPT-6 Astra models with Global CRIS pricing
13
+ - Add the deepseek-flash model
14
+ - Add an editable Chinese metadata form and field-level frontmatter update API for skills
15
+ - Add a /think reasoning-effort switcher to the TUI
16
+ - Render CSV/TSV files as read-only tables in the workspace
17
+ - Add Parallel Free as the primary built-in web-search provider
18
+ - Select text in a message and quote it into the composer (#526 - @kylezhang)
19
+ - Syntax-highlight code in the file viewer
20
+ - Send large STT audio as base64 JSON (#525 - @tomash)
21
+ - Add a Files panel open-with menu, tree toggle, split preview, and in-menu download
22
+ - Detect Windows apps for open-with in WSL
23
+ - Skip registering the browser tool when it is disabled
24
+ - Auto-snapshot the page after browser actions
25
+ - Truncate long working directories in the session info bar
26
+ - Highlight width-drag handles on hover
27
+
28
+ ### Improved
29
+ - Switch the default model to Gemini 3.8 Flash
30
+ - Use single-pane navigation on mobile (C-5779) (#539)
31
+ - Polish the chat navigator layout and hover interactions (#527)
32
+ - Improve message navigation in long conversations (#523)
33
+ - Refine the billing tooltip UX (C-5778) (#537)
34
+ - Let the git panel fill the aside height and free up diff width (#531)
35
+
36
+ ### Fixed
37
+ - Restore the aside width after closing file previews (#545)
38
+ - Replace undefined danger tokens with the error color variable (#542 - @sundevilyang)
39
+ - Add hover and disabled feedback to danger and secondary buttons (#541, #540 - @sundevilyang)
40
+ - Correct DeepSeek v4-pro and flash pricing
41
+ - Collapse extension actions into a menu on narrow screens (C-5781) (#538)
42
+ - Don't submit argumented commands on Enter
43
+ - Scroll overflow menus and pin config action buttons
44
+ - Sniff binary content before text preview (C-5690) (#535)
45
+ - Require a slash prefix for channel help commands (C-5641) (#533)
46
+ - Normalize the homepage URL scheme on read (C-5666) (#534)
47
+ - Decode non-ASCII paths in git panel output (C-5777) (#530)
48
+ - Refresh the file tree on working-dir switch and task completion
49
+ - Harden extension developer guidance and manifest validation (#524)
50
+ - Fix zip file encoding errors
51
+ - Resolve file://~/ tilde paths from chat links
52
+ - Align the open-button icon
53
+
54
+ ### More
55
+ - Move the preview tab to the end of the left-nav order
56
+ - Extract provider id constants to replace magic strings
57
+
7
58
  ## [1.5.13] - 2026-09-03
8
59
 
9
60
  ### Added
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # Byte ranges only: no message bodies, tool outputs or persistent sidecars.
6
+ module ChunkIndex
7
+ private def chunk_section_header(line, directory)
8
+ if (match = line.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
9
+ { role: "assistant", nested_chunk: File.join(directory, match[1]) }
10
+ elsif (match = line.match(/\A## (User|Assistant)(?: \[Task ([1-9]\d*)\])?\z/))
11
+ { role: match[1].downcase, task_id: match[2]&.to_i }
12
+ elsif line.start_with?("### Tool Result:")
13
+ { role: "tool" }
14
+ end
15
+ end
16
+
17
+ private def chunk_file_version(path)
18
+ stat = File.stat(path)
19
+ [stat.size, stat.mtime.to_f, stat.ctime.to_f].join(":")
20
+ rescue Errno::ENOENT
21
+ "missing"
22
+ end
23
+
24
+ private def chunk_navigation_index(path)
25
+ version = chunk_file_version(path)
26
+ @chunk_index_mutex ||= Mutex.new
27
+ @chunk_index_mutex.synchronize do
28
+ @chunk_indexes ||= {}
29
+ cached = @chunk_indexes[path]
30
+ return cached if cached && cached[:version] == version
31
+
32
+ index = version == "missing" ? { rounds: [], nested: [], section_count: 0 } : scan_chunk_index(path)
33
+ raise ArgumentError, "History changed while indexing" unless chunk_file_version(path) == version
34
+
35
+ @chunk_indexes[path] = index.merge(version: version)
36
+ end
37
+ end
38
+
39
+ private def scan_chunk_index(path)
40
+ rounds = []
41
+ nested = []
42
+ section_count = 0
43
+ user_start = nil
44
+ archived_at = nil
45
+ front_matter = false
46
+ directory = File.dirname(path)
47
+ position = 0
48
+ File.open(path, "rb") do |file|
49
+ while (line = file.gets)
50
+ start = position
51
+ position += line.bytesize
52
+ text = line.force_encoding(Encoding::UTF_8).scrub.chomp
53
+ if start.zero? && text == "---"
54
+ front_matter = true
55
+ elsif front_matter
56
+ front_matter = false if text == "---"
57
+ archived_at = (Time.parse(text.split(":", 2).last.strip) rescue nil) if text.start_with?("archived_at:")
58
+ end
59
+ header = chunk_section_header(text, directory)
60
+ if header
61
+ section_count += 1
62
+ nested << header[:nested_chunk] if header[:nested_chunk]
63
+ user_start = header[:role] == "user" ? start : nil
64
+ elsif user_start && !text.strip.empty?
65
+ # Match replay's empty/metadata-only user handling without
66
+ # retaining the rest of the user message or any assistant text.
67
+ visible, events = extract_ext_events_from_text(text)
68
+ visible, files = extract_display_files_from_text(visible)
69
+ next if visible.empty? && events.empty? && files.empty?
70
+
71
+ rounds.last[:length] = user_start - rounds.last[:start] if rounds.any?
72
+ rounds << { start: user_start }
73
+ user_start = nil
74
+ end
75
+ end
76
+ rounds.last[:length] = position - rounds.last[:start] if rounds.any?
77
+ end
78
+ { rounds: rounds, nested: nested, section_count: section_count,
79
+ base_time: (archived_at || File.mtime(path)).to_f }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # Read-only navigation: locations at startup, bounded previews on demand.
6
+ module HistoryNavigation
7
+ NAVIGATION_PREVIEW_LENGTH = 400
8
+ NAVIGATION_PREVIEW_BATCH_LIMIT = 30
9
+
10
+ def history_navigation
11
+ sources = navigation_sources
12
+ manifest = sources.map do |source|
13
+ entry = { key: source[:key], version: source[:version], count: navigation_source_count(source),
14
+ volatile: !!source[:rounds] || source[:continuation].to_a.any? }
15
+ entry[:identities] = source[:rounds].map { |round| round[:user_msg].values_at(:created_at, :task_id) } if source[:rounds]
16
+ entry
17
+ end
18
+ { sources: manifest, total: manifest.sum { |source| source[:count] } }
19
+ end
20
+
21
+ def history_navigation_preview(id:)
22
+ history_navigation_previews(ids: [id]).fetch(0)
23
+ end
24
+
25
+ def history_navigation_previews(ids:)
26
+ ids = Array(ids)
27
+ if ids.length > NAVIGATION_PREVIEW_BATCH_LIMIT
28
+ raise ArgumentError, "Too many history locations"
29
+ end
30
+
31
+ sources = navigation_sources
32
+ ids.map do |id|
33
+ source_index, offset = navigation_position(sources, id)
34
+ unless offset && offset < navigation_source_count(sources[source_index])
35
+ raise ArgumentError, "History location is no longer available"
36
+ end
37
+ navigation_entry(navigation_source_rounds(sources, source_index, offset: offset, limit: 1).fetch(0))
38
+ end
39
+ end
40
+
41
+ def replay_history_window(ui, limit: 30, around: nil, before_id: nil, after_id: nil)
42
+ sources = navigation_sources
43
+ anchor = around || before_id || after_id
44
+ source_index, offset = anchor ? navigation_position(sources, anchor) : [sources.length - 1, nil]
45
+ count = navigation_source_count(sources[source_index])
46
+ offset ||= count
47
+ raise ArgumentError, "History location is no longer available" if anchor && offset >= count
48
+
49
+ # Around starts at the requested round; before/after are exclusive.
50
+ forward = !!(around || after_id)
51
+ offset += 1 if after_id
52
+ page = []
53
+ index = source_index
54
+ while index.between?(0, sources.length - 1) && page.length < limit
55
+ count = navigation_source_count(sources[index])
56
+ if forward
57
+ first = index == source_index ? offset : 0
58
+ page.concat(navigation_source_rounds(sources, index, offset: first, limit: limit - page.length))
59
+ index += 1
60
+ else
61
+ last = index == source_index ? offset : count
62
+ first = [last - (limit - page.length), 0].max
63
+ page = navigation_source_rounds(sources, index, offset: first, limit: last - first) + page
64
+ index -= 1
65
+ end
66
+ end
67
+
68
+ if page.empty? && !anchor
69
+ @history.to_a.each do |message|
70
+ next if message[:role].to_s == "system" || message[:system_injected]
71
+ _replay_single_message(message, ui)
72
+ end
73
+ else
74
+ replay_rounds(ui, page)
75
+ end
76
+ first = page.first&.dig(:navigation_id)
77
+ last = page.last&.dig(:navigation_id)
78
+ first_pos = first && navigation_position(sources, first)
79
+ last_pos = last && navigation_position(sources, last)
80
+ has_before = first_pos && (first_pos[1].positive? || first_pos[0].positive?)
81
+ has_after = last_pos && (last_pos[1] < navigation_source_count(sources[last_pos[0]]) - 1 ||
82
+ ((last_pos[0] + 1)...sources.length).any? { |i| navigation_source_count(sources[i]).positive? })
83
+ {
84
+ has_more: !!has_before, has_after: !!has_after,
85
+ before_cursor: first, after_cursor: last,
86
+ round_ids: page.map { |round| round[:navigation_id] }
87
+ }
88
+ end
89
+
90
+ private def real_history_user?(msg)
91
+ return false unless msg[:role].to_s == "user" && !msg[:system_injected]
92
+
93
+ content = msg[:content]
94
+ if content.is_a?(String)
95
+ !content.start_with?("[SYSTEM]")
96
+ elsif content.is_a?(Array)
97
+ content.any? { |block| block.is_a?(Hash) && %w[text image image_url].include?((block[:type] || block["type"]).to_s) }
98
+ else
99
+ false
100
+ end
101
+ end
102
+
103
+ private def navigation_sources
104
+ messages = @history.to_a
105
+ rounds = []
106
+ leading = []
107
+ chunks = []
108
+ messages.each do |msg|
109
+ if real_history_user?(msg)
110
+ rounds << { user_msg: msg, events: [], editable: true }
111
+ elsif msg[:compressed_summary]
112
+ paths = if msg[:chunk_path].to_s.empty?
113
+ session_manager.chunks_for_current(@session_id, @created_at).map { |chunk| chunk[:path] }
114
+ else
115
+ sibling_chunks_of(msg[:chunk_path])
116
+ end
117
+ chunks.concat(paths)
118
+ elsif rounds.empty?
119
+ leading << msg unless msg[:role].to_s == "system" || msg[:system_injected]
120
+ else
121
+ rounds.last[:events] << msg
122
+ end
123
+ end
124
+ sources = []
125
+ visited = Set.new
126
+ top_level = chunks.map { |path| File.basename(path) }.to_set
127
+ append_source = lambda do |path|
128
+ path = File.join(Clacky::SessionManager::SESSIONS_DIR, File.basename(path)) unless File.exist?(path)
129
+ path = File.expand_path(path)
130
+ next unless visited.add?(path)
131
+
132
+ index = chunk_navigation_index(path)
133
+ # Legacy nested references precede this source's own rounds. Sibling
134
+ # chunks already enumerated above must not be counted twice.
135
+ index[:nested].reverse_each do |nested|
136
+ append_source.call(nested) unless top_level.include?(File.basename(nested))
137
+ end
138
+ sources << { key: File.basename(path), path: path, version: index[:version], index: index }
139
+ end
140
+ chunks.uniq.each { |path| append_source.call(path) }
141
+ @chunk_index_mutex&.synchronize { @chunk_indexes&.delete_if { |path, _| !visited.include?(path) } }
142
+ last_archive = sources.reverse_each.find { |source| navigation_source_count(source).positive? }
143
+ last_archive[:continuation] = leading if last_archive
144
+ version = rounds.map { |round| round[:user_msg].values_at(:created_at, :task_id) }.hash.to_s
145
+ sources << { key: "live", rounds: rounds, version: version }
146
+ sources
147
+ end
148
+
149
+ private def navigation_source_count(source)
150
+ source[:rounds] ? source[:rounds].length : source[:index][:rounds].length
151
+ end
152
+
153
+ private def navigation_id(source, offset)
154
+ identity = source[:rounds] && offset && source[:rounds][offset]&.dig(:user_msg)&.values_at(:created_at, :task_id)
155
+ JSON.generate([source[:key], offset, source[:version], identity])
156
+ end
157
+
158
+ private def navigation_position(sources, id)
159
+ key, offset, version, identity = JSON.parse(id)
160
+ index = sources.index { |source| source[:key] == key }
161
+ if index && key == "live" && version != sources[index][:version] && identity.is_a?(Array) && identity.any?
162
+ matches = sources[index][:rounds].each_index.select do |i|
163
+ sources[index][:rounds][i][:user_msg].values_at(:created_at, :task_id) == identity
164
+ end
165
+ if matches.one?
166
+ offset = matches.first
167
+ version = sources[index][:version]
168
+ end
169
+ end
170
+ unless index && sources[index][:version] == version && (offset.nil? || (offset.is_a?(Integer) && offset >= 0))
171
+ raise ArgumentError, "History location is no longer available"
172
+ end
173
+ [index, offset]
174
+ rescue JSON::ParserError, TypeError
175
+ raise ArgumentError, "Invalid history location"
176
+ end
177
+
178
+ private def navigation_source_rounds(sources, index, offset: 0, limit: nil)
179
+ source = sources.fetch(index)
180
+ count = navigation_source_count(source)
181
+ length = [limit || count, count - offset].min
182
+ return [] unless length.positive?
183
+
184
+ siblings = sources.filter_map { |entry| entry[:key] if entry[:path] && entry != source }.to_set
185
+ rounds = if source[:rounds]
186
+ source[:rounds].slice(offset, length)
187
+ else
188
+ ranges = source[:index][:rounds]
189
+ first = ranges[offset]
190
+ last = ranges[offset + length - 1]
191
+ range = { start: first[:start], length: last[:start] + last[:length] - first[:start] }
192
+ result = parse_chunk_md_to_rounds(source[:path], excluded_paths: siblings, byte_range: range,
193
+ index: source[:index], round_offset: offset)
194
+ unless chunk_file_version(source[:path]) == source[:version] && result.length == length
195
+ raise ArgumentError, "History changed while reading"
196
+ end
197
+ result
198
+ end
199
+ if rounds.any? && offset + length == count && source[:continuation]
200
+ rounds.last[:events].concat(source[:continuation])
201
+ end
202
+ rounds.each { |round| round[:events].reject! { |event| event[:compressed_summary] } }
203
+ rounds.each_with_index do |round, position|
204
+ round[:navigation_id] = navigation_id(source, offset + position)
205
+ end
206
+ rounds
207
+ end
208
+
209
+ private def navigation_entry(round)
210
+ msg = round[:user_msg]
211
+ user = navigation_preview(msg[:display_text] || extract_text_from_content(msg[:content]))
212
+ if user.empty?
213
+ names = Array(msg[:display_files]).map { |file| file[:name] || file["name"] }
214
+ Array(msg[:content]).each do |block|
215
+ next unless block.is_a?(Hash) && %w[image image_url].include?((block[:type] || block["type"]).to_s)
216
+ names << (block[:image_name] || block["image_name"] || "image")
217
+ end
218
+ user = navigation_preview(names.compact.join(", "))
219
+ end
220
+ # A final reply is the last main-agent response, with no pending tool
221
+ # work. Earlier narration, reasoning and nested subagent transcripts
222
+ # must never be substituted for a missing final response.
223
+ last = round[:events].reverse.find do |event|
224
+ !event[:system_injected] && !event[:compressed_summary] &&
225
+ %w[assistant tool].include?(event[:role].to_s)
226
+ end
227
+ final = last && last[:role].to_s == "assistant" && !last[:interim] && Array(last[:tool_calls]).empty?
228
+ answer = final ? navigation_preview(extract_text_from_content(last[:content])) : ""
229
+ { id: round[:navigation_id], created_at: msg[:created_at], archived: round[:editable] == false,
230
+ user: user, assistant: answer }
231
+ end
232
+
233
+ private def navigation_preview(text)
234
+ text.to_s.gsub(/<think>.*?(?:<\/think>|\z)/m, "")
235
+ .strip[0, NAVIGATION_PREVIEW_LENGTH]
236
+ end
237
+ end
238
+ end
239
+ end
@@ -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)
@@ -289,77 +293,8 @@ module Clacky
289
293
  # created_at < before. Pass nil to get the most recent rounds.
290
294
  # @return [Hash] { has_more: Boolean } — whether older rounds exist beyond this page
291
295
  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
296
+ sources = navigation_sources
297
+ rounds = sources.each_index.flat_map { |index| navigation_source_rounds(sources, index) }
363
298
 
364
299
  # Apply before-cursor filter: only rounds whose user message created_at < before
365
300
  if before
@@ -379,6 +314,11 @@ module Clacky
379
314
  # Take the most recent `limit` rounds
380
315
  page = rounds.last(limit)
381
316
 
317
+ replay_rounds(ui, page)
318
+ { has_more: has_more }
319
+ end
320
+
321
+ private def replay_rounds(ui, page)
382
322
  page.each do |round|
383
323
  msg = round[:user_msg]
384
324
  raw_text = msg[:display_text] || extract_text_from_content(msg[:content])
@@ -410,7 +350,6 @@ module Clacky
410
350
  end
411
351
  end
412
352
 
413
- { has_more: has_more }
414
353
  end
415
354
 
416
355
  # Return all chunk MD file paths that belong to the same session as
@@ -446,7 +385,8 @@ module Clacky
446
385
  #
447
386
  # @param chunk_path [String] Path to the chunk md file
448
387
  # @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)
388
+ private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new, excluded_paths: Set.new,
389
+ byte_range: nil, index: nil, round_offset: 0)
450
390
  return [] unless chunk_path
451
391
 
452
392
  # 1. Try the stored absolute path first (same machine, normal case).
@@ -468,7 +408,14 @@ module Clacky
468
408
 
469
409
  # Scrub invalid UTF-8 bytes defensively — chunk files written before
470
410
  # the 0.9.37 fix may contain poisoned bytes from file_reader results.
471
- raw = File.read(resolved).then do |s|
411
+ raw = (if byte_range
412
+ File.open(resolved, "rb") do |file|
413
+ file.seek(byte_range[:start])
414
+ file.read(byte_range[:length]).to_s.force_encoding(Encoding::UTF_8)
415
+ end
416
+ else
417
+ File.read(resolved)
418
+ end).then do |s|
472
419
  s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
473
420
  s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
474
421
  end
@@ -486,7 +433,7 @@ module Clacky
486
433
  end
487
434
  end
488
435
  end
489
- base_time = (archived_at || Time.now).to_f
436
+ base_time = index ? index[:base_time] : (archived_at || File.mtime(resolved)).to_f
490
437
  chunk_dir = File.dirname(chunk_path.to_s)
491
438
 
492
439
  # Split into sections by ## headings
@@ -498,25 +445,12 @@ module Clacky
498
445
 
499
446
  raw.each_line do |line|
500
447
  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
448
+ if (header = chunk_section_header(stripped, chunk_dir))
503
449
  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"
450
+ current_role = header[:role]
505
451
  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
452
+ current_nested_chunk = header[:nested_chunk]
453
+ current_task_id = header[:task_id]
520
454
  else
521
455
  current_lines << line
522
456
  end
@@ -538,11 +472,15 @@ module Clacky
538
472
 
539
473
  # Nested chunk: expand it recursively, prepend before current rounds
540
474
  if sec[:nested_chunk]
541
- nested = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
475
+ nested = if excluded_paths.include?(File.basename(sec[:nested_chunk]))
476
+ []
477
+ else
478
+ parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited, excluded_paths: excluded_paths)
479
+ end
542
480
  rounds = nested + rounds unless nested.empty?
543
481
  # Also render its summary text as an assistant event in current round if any
544
482
  if current_round && !text.empty?
545
- current_round[:events] << { role: "assistant", content: text }
483
+ current_round[:events] << { role: "assistant", content: text, compressed_summary: true }
546
484
  end
547
485
  next
548
486
  end
@@ -552,7 +490,7 @@ module Clacky
552
490
  if sec[:role] == "user"
553
491
  round_index += 1
554
492
  # Synthetic timestamp: spread rounds backwards from archived_at
555
- synthetic_ts = base_time - (sections.size - round_index) * 1.0
493
+ synthetic_ts = base_time - ((index ? index[:section_count] : sections.size) - round_offset - round_index) * 1.0
556
494
  user_msg = {
557
495
  role: "user",
558
496
  content: text,
@@ -594,7 +532,9 @@ module Clacky
594
532
 
595
533
  # Flush any plain text
596
534
  plain_text = remaining_lines.join.strip
597
- current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
535
+ unless plain_text.empty?
536
+ current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty? }
537
+ end
598
538
 
599
539
  # Emit one synthetic tool_calls message per detected tool
600
540
  pending_tool_entries.each do |entry|
@@ -774,7 +714,8 @@ module Clacky
774
714
  else
775
715
  raw_text
776
716
  end
777
- ui.show_assistant_message(text, files: [], created_at: msg[:created_at])
717
+ ui.show_assistant_message(text, files: [], created_at: msg[:created_at],
718
+ interim: !!msg[:interim] || Array(msg[:tool_calls]).any?)
778
719
  end
779
720
 
780
721
  # Tool calls embedded in assistant message