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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87ba4a4c52b1e52b0af0aad921bac4b8addd3d95955f44442894753ffd73ad14
|
|
4
|
+
data.tar.gz: 0a861cf43fcecc64275cb243029f7cc621ac32dfab949e0768aadfaf7e0f9490
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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 =
|
|
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 ||
|
|
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 (
|
|
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 =
|
|
450
|
+
current_role = header[:role]
|
|
505
451
|
current_lines = []
|
|
506
|
-
current_nested_chunk =
|
|
507
|
-
current_task_id =
|
|
508
|
-
elsif (m = stripped.match(/\A## (User|Assistant)(?: \[Task ([1-9]\d*)\])?\z/))
|
|
509
|
-
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
510
|
-
current_role = m[1].downcase
|
|
511
|
-
current_lines = []
|
|
512
|
-
current_nested_chunk = nil
|
|
513
|
-
current_task_id = m[2]&.to_i
|
|
514
|
-
elsif stripped.match?(/\A### Tool Result:/)
|
|
515
|
-
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk, task_id: current_task_id } if current_role
|
|
516
|
-
current_role = "tool"
|
|
517
|
-
current_lines = []
|
|
518
|
-
current_nested_chunk = nil
|
|
519
|
-
current_task_id = nil
|
|
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 =
|
|
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
|
-
|
|
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
|