openclacky 1.5.12 → 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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/chunk_index.rb +83 -0
  5. data/lib/clacky/agent/history_navigation.rb +239 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  7. data/lib/clacky/agent/session_serializer.rb +73 -99
  8. data/lib/clacky/agent/time_machine.rb +9 -1
  9. data/lib/clacky/agent.rb +292 -283
  10. data/lib/clacky/agent_config.rb +41 -1
  11. data/lib/clacky/agent_profile.rb +5 -5
  12. data/lib/clacky/billing/billing_store.rb +2 -2
  13. data/lib/clacky/billing/platform_billing.rb +90 -1
  14. data/lib/clacky/brand_config.rb +17 -5
  15. data/lib/clacky/cli.rb +31 -2
  16. data/lib/clacky/client.rb +109 -2
  17. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +131 -26
  20. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  21. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  22. data/lib/clacky/default_extensions/git/panels/git/view.js +503 -77
  23. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  24. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  25. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  26. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  27. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +177 -208
  28. data/lib/clacky/extension/api_extension.rb +1 -1
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/verifier.rb +15 -2
  31. data/lib/clacky/media/openai_compat.rb +70 -30
  32. data/lib/clacky/message_format/bedrock.rb +6 -1
  33. data/lib/clacky/message_format/open_ai.rb +5 -1
  34. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  35. data/lib/clacky/message_history.rb +6 -2
  36. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  37. data/lib/clacky/prompts/base.md +1 -1
  38. data/lib/clacky/providers.rb +56 -9
  39. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  40. data/lib/clacky/search_config.rb +3 -3
  41. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  42. data/lib/clacky/server/dir_picker.rb +154 -0
  43. data/lib/clacky/server/git_panel.rb +71 -11
  44. data/lib/clacky/server/http_server.rb +308 -117
  45. data/lib/clacky/server/preview.rb +351 -0
  46. data/lib/clacky/server/web_ui_controller.rb +1 -1
  47. data/lib/clacky/skill.rb +48 -0
  48. data/lib/clacky/tools/browser.rb +176 -19
  49. data/lib/clacky/tools/terminal.rb +27 -3
  50. data/lib/clacky/tools/web_search.rb +79 -8
  51. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  52. data/lib/clacky/ui2/components/input_area.rb +22 -2
  53. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  54. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  55. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  56. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  57. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  58. data/lib/clacky/ui2/ui_controller.rb +44 -7
  59. data/lib/clacky/utils/file_processor.rb +19 -7
  60. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  61. data/lib/clacky/utils/model_pricing.rb +184 -70
  62. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  63. data/lib/clacky/version.rb +1 -1
  64. data/lib/clacky/web/app.css +1004 -234
  65. data/lib/clacky/web/components/chat-navigator.js +489 -202
  66. data/lib/clacky/web/components/code-editor.js +191 -5
  67. data/lib/clacky/web/components/composer.js +45 -7
  68. data/lib/clacky/web/components/custom-select.js +136 -0
  69. data/lib/clacky/web/components/mentions.js +58 -2
  70. data/lib/clacky/web/components/model-picker.js +528 -0
  71. data/lib/clacky/web/components/onboard.js +29 -55
  72. data/lib/clacky/web/components/quote-select.js +235 -0
  73. data/lib/clacky/web/core/aside.js +157 -8
  74. data/lib/clacky/web/core/ext.js +54 -0
  75. data/lib/clacky/web/features/billing/view.js +95 -27
  76. data/lib/clacky/web/features/extensions/view.js +22 -10
  77. data/lib/clacky/web/features/new-session/store.js +15 -1
  78. data/lib/clacky/web/features/new-session/view.js +31 -22
  79. data/lib/clacky/web/features/skills/store.js +18 -1
  80. data/lib/clacky/web/features/skills/view.js +128 -7
  81. data/lib/clacky/web/features/trash/view.js +31 -19
  82. data/lib/clacky/web/features/workspace/store.js +81 -7
  83. data/lib/clacky/web/features/workspace/view.js +446 -43
  84. data/lib/clacky/web/i18n.js +161 -14
  85. data/lib/clacky/web/index.html +105 -41
  86. data/lib/clacky/web/sessions.js +742 -629
  87. data/lib/clacky/web/settings.js +163 -190
  88. data/lib/clacky/web/utils.js +34 -0
  89. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  90. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  91. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  92. data/lib/clacky/web/ws-dispatcher.js +20 -0
  93. data/lib/clacky.rb +5 -0
  94. metadata +18 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 858730306a6e6647d79a5ebb35160c52fba7ed26c62ec97043373e7f052332e2
4
- data.tar.gz: b412bc405daae81f1b6d71dd4dd2dffc197a00ac97540ecfd4916f7daf58843d
3
+ metadata.gz: 87ba4a4c52b1e52b0af0aad921bac4b8addd3d95955f44442894753ffd73ad14
4
+ data.tar.gz: 0a861cf43fcecc64275cb243029f7cc621ac32dfab949e0768aadfaf7e0f9490
5
5
  SHA512:
6
- metadata.gz: 685d67556e68efd81a8bc446edd473a57939f0404b754e4432c7d29e073d2c47adc0c939fc5da3ea32144f3c6026aa17d73ff4766420399515089a3a8a4bc98d
7
- data.tar.gz: dc85168d4b103185d20087635cfc5972c4e3e23b8dee95cb2ba28017d276dda425d8d92d23159dad463bd6cdcc29b8e02404d50dd4a79e5e6345193e8b4167c8
6
+ metadata.gz: 1b476b0c9ef80a39ac6830c35cac601abb5086d639d2806fc473af3a9074af25449f6f8befeea54bc07e38571e484f4012150d3a53adef674534f8b89ca8771e
7
+ data.tar.gz: 5ee52bb903109343a3a7adb9dc3681be836e22dd43f1b12021def3b32383c205b3750283b3af357f0e01b580ad1f5a8e2507c9f6133789b4813378e5a56120b3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,105 @@ 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
+
58
+ ## [1.5.13] - 2026-09-03
59
+
60
+ ### Added
61
+ - Add Gemini 3.8 Flash model and set it as the trial default
62
+ - Add or-stt-gemini-3-8-flash speech-to-text model; default STT and video understanding to 3.8
63
+ - Add OpenAI Responses API as a fourth API format (#490 - @kylezhang)
64
+ - Add access-key API and config-reload API
65
+ - Add Claude Fable 5.1, GLM-5.3-Flash and qwen3.8-max models with pricing
66
+ - Default the Web UI to the OS/browser language on first visit
67
+ - Make the workspace file tree resizable
68
+ - Expose mounted drives in the directory picker
69
+ - Personalize the new-session page title and empty-chat greeting with the AI's name
70
+ - Localise built-in setup session titles
71
+
72
+ ### Improved
73
+ - Replace native selects with shared CustomSelect dropdowns across settings
74
+ - Overhaul the git & time-machine panel
75
+ - Improve the directory picker (#521)
76
+
77
+ ### Fixed
78
+ - Merge OpenClacky billing across all configured API keys
79
+ - Preserve custom provider and API format when editing a model
80
+ - Reclaim PTY file descriptors when a session shell dies
81
+ - Wrap ANSI-coloured CJK text without IndexError
82
+ - Prevent history edits while a session is running (#520)
83
+ - Preserve attachment badges after compression (#516)
84
+ - Flag hidden agents instead of dropping them
85
+ - Install upgrades next to the running gem's base directory
86
+ - Skip extension auto-sync for administrators (#515)
87
+ - Support the mention menu with Windows IME (#513)
88
+ - Validate extension version input (#514)
89
+ - Keep mention chips anchored and on one line while typing
90
+ - Fall back to uncolored markdown for CJK tables
91
+ - Stabilise billing toolbar layout (#517)
92
+ - Light the group unread dot for backend-created sessions (#518)
93
+ - Translate maximum reasoning effort (#522)
94
+ - Harden WSL Windows profile detection in the directory picker
95
+ - Wrap download errors so the CDN fallback fires
96
+ - Surface the git tab badge eagerly
97
+ - Show the meeting panel only in general sessions
98
+ - Whitelist the config top-level key in the extension verifier
99
+
100
+ ### More
101
+ - Extract shared ModelPicker model dropdown component
102
+ - Refine dropdown chevrons and submenu selection
103
+ - Adjust sidebar panel order and labels
104
+ - Update trial model copy to Gemini 3.7 Flash
105
+
7
106
  ## [1.5.12] - 2026-08-27
8
107
 
9
108
  ### Added
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ module Clacky
7
+ # Access key for server-mode authentication.
8
+ #
9
+ # Two sources, in priority order:
10
+ # 1. ~/.clacky/access_key — a file holding the raw key, writable from
11
+ # outside the process (e.g. a sandbox host pushing a key into a
12
+ # pre-started container). This is what makes rotation possible:
13
+ # ENV is a boot-time snapshot the running process can never change.
14
+ # 2. CLACKY_ACCESS_KEY env var — the original bootstrap mechanism.
15
+ module AccessKey
16
+ ENV_VAR = "CLACKY_ACCESS_KEY"
17
+
18
+ def self.resolve
19
+ from_file || from_env
20
+ end
21
+
22
+ def self.from_file
23
+ return nil unless File.exist?(key_file)
24
+
25
+ key = File.read(key_file).strip
26
+ key.empty? ? nil : key
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def self.from_env
32
+ key = ENV.fetch(ENV_VAR, "").strip
33
+ key.empty? ? nil : key
34
+ end
35
+
36
+ def self.write(key)
37
+ key = key.to_s.strip
38
+ raise ArgumentError, "access key must not be blank" if key.empty?
39
+
40
+ FileUtils.mkdir_p(File.dirname(key_file))
41
+ File.write(key_file, "#{key}\n")
42
+ FileUtils.chmod(0o600, key_file)
43
+ key
44
+ end
45
+
46
+ def self.generate
47
+ SecureRandom.hex(32)
48
+ end
49
+
50
+ def self.configured?
51
+ !resolve.nil?
52
+ end
53
+
54
+ # Resolved lazily so specs that stub Dir.home in a before-hook still work.
55
+ def self.key_file
56
+ File.join(Dir.home, ".clacky", "access_key")
57
+ end
58
+ end
59
+ end
@@ -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
@@ -624,10 +624,12 @@ module Clacky
624
624
 
625
625
  case role
626
626
  when "user"
627
+ display_files = display_files_for_archive(msg)
627
628
  lines << (msg[:task_id] ? "## User [Task #{msg[:task_id]}]" : "## User")
628
629
  lines << ""
629
- lines << format_message_content(content)
630
+ lines << format_archived_user_content(content, display_files)
630
631
  lines << ""
632
+ append_display_files_line(lines, display_files)
631
633
  append_ext_events_line(lines, msg)
632
634
  when "assistant"
633
635
  # If this message is itself a compressed summary, annotate the header
@@ -676,6 +678,55 @@ module Clacky
676
678
  lines
677
679
  end
678
680
 
681
+ # Serialize only the lightweight UI metadata needed to reconstruct file
682
+ # badges after compression. File paths, previews, sizes, MIME types, and
683
+ # contents deliberately stay out of the chunk archive.
684
+ def display_files_for_archive(msg)
685
+ files = Array(msg[:display_files]).dup
686
+ Array(msg[:content]).each do |block|
687
+ next unless block.is_a?(Hash)
688
+ next unless %w[image image_url].include?((block[:type] || block["type"]).to_s)
689
+
690
+ name = block[:image_name] || block["image_name"]
691
+ if name.nil? || name.to_s.strip.empty?
692
+ path = block[:image_path] || block["image_path"]
693
+ name = File.basename(path.to_s).sub(/\A[0-9a-f]{16}_/, "") unless path.to_s.empty?
694
+ end
695
+ name = "image" if name.nil? || name.to_s.strip.empty?
696
+ files << { name: name, type: "image" }
697
+ end
698
+
699
+ files.filter_map do |file|
700
+ next unless file.is_a?(Hash)
701
+
702
+ name = file[:name] || file["name"]
703
+ next if name.nil? || name.to_s.strip.empty?
704
+
705
+ type = file[:type] || file["type"] || "file"
706
+ type = "file" if type.to_s.strip.empty?
707
+ { name: name.to_s, type: type.to_s }
708
+ end.uniq { |file| [file[:name], file[:type]] }
709
+ end
710
+
711
+ # Once an image has a badge in the archive, omit the generic [image_url]
712
+ # placeholder from the visible replay text. Text blocks remain unchanged.
713
+ def format_archived_user_content(content, display_files)
714
+ return format_message_content(content) unless content.is_a?(Array)
715
+ return format_message_content(content) unless display_files.any? { |file| file[:type] == "image" }
716
+
717
+ visible_blocks = content.reject do |block|
718
+ block.is_a?(Hash) && %w[image image_url].include?((block[:type] || block["type"]).to_s)
719
+ end
720
+ format_message_content(visible_blocks)
721
+ end
722
+
723
+ def append_display_files_line(lines, files)
724
+ return if files.empty?
725
+
726
+ lines << "_Display files: #{files.to_json}_"
727
+ lines << ""
728
+ end
729
+
679
730
  # Serialize persisted extension events into the chunk MD so they survive
680
731
  # compression and can be replayed in place. Mirrors the "_Tool calls:"
681
732
  # convention; parse_chunk_md_to_rounds reads it back.