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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -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 +53 -98
- data/lib/clacky/agent.rb +477 -297
- data/lib/clacky/agent_config.rb +5 -3
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +7 -0
- data/lib/clacky/brand_config.rb +27 -19
- data/lib/clacky/cli.rb +92 -12
- data/lib/clacky/cli_guidance.rb +48 -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/api/handler.rb +4 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
- 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 +18 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/json_ui_controller.rb +4 -2
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/message_format/open_ai.rb +22 -5
- data/lib/clacky/message_format/open_ai_responses.rb +7 -3
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +44 -13
- data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +25 -7
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/dir_picker.rb +67 -4
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +391 -75
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/session_registry.rb +4 -0
- data/lib/clacky/server/web_ui_controller.rb +10 -5
- data/lib/clacky/session_manager.rb +52 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +91 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +37 -4
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/screen_buffer.rb +1 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -12
- data/lib/clacky/ui_interface.rb +1 -1
- 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 +118 -68
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +998 -168
- data/lib/clacky/web/app.js +19 -0
- 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/composer.js +3 -1
- data/lib/clacky/web/components/datepicker.js +19 -19
- data/lib/clacky/web/components/mentions.js +11 -14
- data/lib/clacky/web/components/model-picker.js +66 -23
- data/lib/clacky/web/components/quote-select.js +341 -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/extensions/store.js +4 -1
- data/lib/clacky/web/features/model-tester/store.js +37 -4
- 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 +474 -43
- data/lib/clacky/web/i18n.js +158 -23
- data/lib/clacky/web/index.html +57 -29
- data/lib/clacky/web/sessions.js +517 -127
- data/lib/clacky/web/settings.js +155 -6
- 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 +161 -4
- data/lib/clacky.rb +2 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 878e99c7061a59dee8c50f4ec416986c33c7d93538595e90719042827a9b774e
|
|
4
|
+
data.tar.gz: 51a940cdc2e2f25b95a6ce96d84cd7c45988154b324ca2c9962bcddf4f213fa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|