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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca259b2f17df9695e21c4d4c4f18ee83affec0fc41d67db829d903e1c3bb008d
4
- data.tar.gz: b0bfd31378c6b452a7aef246d3d316242a91fa6366f543c45edcff8c3f44ac6c
3
+ metadata.gz: 878e99c7061a59dee8c50f4ec416986c33c7d93538595e90719042827a9b774e
4
+ data.tar.gz: 51a940cdc2e2f25b95a6ce96d84cd7c45988154b324ca2c9962bcddf4f213fa4
5
5
  SHA512:
6
- metadata.gz: cfcb5b28264aeb2acb6b121b89c395e4e9241b3a3135c2e9fb7d565365ed263c701f939448a2c01cb229359cca11402b36b081d4edff6231ce8e46e72d253498
7
- data.tar.gz: 95cca310ebd8e0a23b1f1e5aed241b6e5fbc19aa206fdb16301dfd3b90f07e33424a44686c10b2d6cf4d3344c75eab2c033d7b3e4e7ef6c9a40b7879dd07a88b
6
+ metadata.gz: 82af0b7e1e966e3e8eafc888006a05e75f637b6d6508721432bc484394f9699cb2a82ad0f0abee73cd88214bcad6e865acf8e1840e357059a470aee5e59f00c5
7
+ data.tar.gz: f996cf6c38e1070608dd49955e68c1154301152b8fc51d0e3e392055dcd51bebc21c234e0bf20fddbdb0a2a9ba48dcc527afcfd1e92e43fc2fc82a1ddbbcfa33
data/CHANGELOG.md CHANGED
@@ -4,6 +4,102 @@ 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.15] - 2026-09-17
8
+
9
+ ### Added
10
+ - Quote text selected in a message and send it as a reference badge
11
+ - Queue messages while the agent is working, with pending-message controls in the CLI and Web UI (#561)
12
+ - Steer a running turn with a queued message
13
+ - Discover custom-endpoint models from the add-model dialog (#560 - @kylezhang)
14
+ - Show vendor badges for each provider in the add-model dropdown
15
+ - Label every model with its provider in the model picker
16
+ - Open unviewable files in the system app and reveal them in the workspace tree
17
+ - Follow the browsed directory in the directory picker sidebar
18
+ - Show the extension and skill lists as segmented tabs in Extension Studio
19
+ - Add disclosure carets to the clickable status-bar fields
20
+ - Resolve redirected Windows shell folders in the WSL directory picker
21
+ - Show web search results as UI results in the chat
22
+
23
+ ### Improved
24
+ - Swap the extension and channel management order in the sidebar
25
+ - Rename the Tasks sidebar entry to Scheduled Tasks
26
+ - Improve mobile folder navigation in the directory picker (#549)
27
+ - Improve the mobile layout of settings tabs and model controls (#548)
28
+ - Add a global focus ring for keyboard navigation (#544)
29
+ - Align the input behavior toggle with the send buttons
30
+ - Deepen the directory-picker breadcrumb field
31
+ - Keep the segmented control highlight dark on the dim and bluedark themes
32
+ - Retire the dsk-deepseek-v4-flash model ids
33
+
34
+ ### Fixed
35
+ - Keep the mobile composer above the soft keyboard (#557)
36
+ - Install newly published brand skills when an agent starts
37
+ - Show the marketplace takedown state instead of an unusable unpublish button
38
+ - Show complete brand distribution for brand owners (#556)
39
+ - Focus brand owners on brand publishing in Extension Studio (#555)
40
+ - Hide the creator center from branded non-owners (#553)
41
+ - Forward brand marketplace sorting to the backend (#552)
42
+ - Complete the public session creation lifecycle (#551)
43
+ - Make the @ mention menu selectable on the new-session page (#558)
44
+ - Show a searched old session in the sidebar without a reload
45
+ - Stop session updated_at from rolling back on save
46
+ - Avoid a duplicated empty state in the sidebar search
47
+ - Align reasoning and image fields with the Responses API schema
48
+ - Use theme tokens for the badge colors
49
+ - Give the calendar popup its own class namespace
50
+ - Add the default user prompt for attachments
51
+
52
+ ## [1.5.14] - 2026-09-10
53
+
54
+ ### Added
55
+ - Add a click-driven live preview panel for workspace files and localhost dev servers
56
+ - Open local files in the preview panel via file:// URLs and bare absolute paths
57
+ - Add GPT-5.6 Sol/Terra/Luna and GPT-6 Astra models with Global CRIS pricing
58
+ - Add the deepseek-flash model
59
+ - Add an editable Chinese metadata form and field-level frontmatter update API for skills
60
+ - Add a /think reasoning-effort switcher to the TUI
61
+ - Render CSV/TSV files as read-only tables in the workspace
62
+ - Add Parallel Free as the primary built-in web-search provider
63
+ - Select text in a message and quote it into the composer (#526 - @kylezhang)
64
+ - Syntax-highlight code in the file viewer
65
+ - Send large STT audio as base64 JSON (#525 - @tomash)
66
+ - Add a Files panel open-with menu, tree toggle, split preview, and in-menu download
67
+ - Detect Windows apps for open-with in WSL
68
+ - Skip registering the browser tool when it is disabled
69
+ - Auto-snapshot the page after browser actions
70
+ - Truncate long working directories in the session info bar
71
+ - Highlight width-drag handles on hover
72
+
73
+ ### Improved
74
+ - Switch the default model to Gemini 3.8 Flash
75
+ - Use single-pane navigation on mobile (C-5779) (#539)
76
+ - Polish the chat navigator layout and hover interactions (#527)
77
+ - Improve message navigation in long conversations (#523)
78
+ - Refine the billing tooltip UX (C-5778) (#537)
79
+ - Let the git panel fill the aside height and free up diff width (#531)
80
+
81
+ ### Fixed
82
+ - Restore the aside width after closing file previews (#545)
83
+ - Replace undefined danger tokens with the error color variable (#542 - @sundevilyang)
84
+ - Add hover and disabled feedback to danger and secondary buttons (#541, #540 - @sundevilyang)
85
+ - Correct DeepSeek v4-pro and flash pricing
86
+ - Collapse extension actions into a menu on narrow screens (C-5781) (#538)
87
+ - Don't submit argumented commands on Enter
88
+ - Scroll overflow menus and pin config action buttons
89
+ - Sniff binary content before text preview (C-5690) (#535)
90
+ - Require a slash prefix for channel help commands (C-5641) (#533)
91
+ - Normalize the homepage URL scheme on read (C-5666) (#534)
92
+ - Decode non-ASCII paths in git panel output (C-5777) (#530)
93
+ - Refresh the file tree on working-dir switch and task completion
94
+ - Harden extension developer guidance and manifest validation (#524)
95
+ - Fix zip file encoding errors
96
+ - Resolve file://~/ tilde paths from chat links
97
+ - Align the open-button icon
98
+
99
+ ### More
100
+ - Move the preview tab to the end of the left-nav order
101
+ - Extract provider id constants to replace magic strings
102
+
7
103
  ## [1.5.13] - 2026-09-03
8
104
 
9
105
  ### 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