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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/message_compressor_helper.rb +52 -1
- data/lib/clacky/agent/session_serializer.rb +73 -99
- data/lib/clacky/agent/time_machine.rb +9 -1
- data/lib/clacky/agent.rb +292 -283
- data/lib/clacky/agent_config.rb +41 -1
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +90 -1
- data/lib/clacky/brand_config.rb +17 -5
- data/lib/clacky/cli.rb +31 -2
- data/lib/clacky/client.rb +109 -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 +3 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +131 -26
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +503 -77
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- 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/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +177 -208
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/verifier.rb +15 -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 +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +6 -2
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +56 -9
- 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/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +71 -11
- data/lib/clacky/server/http_server.rb +308 -117
- 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/terminal.rb +27 -3
- 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/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- 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 +184 -70
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1004 -234
- 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 +45 -7
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +58 -2
- data/lib/clacky/web/components/model-picker.js +528 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- 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 +54 -0
- data/lib/clacky/web/features/billing/view.js +95 -27
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +31 -22
- 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/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +81 -7
- data/lib/clacky/web/features/workspace/view.js +446 -43
- data/lib/clacky/web/i18n.js +161 -14
- data/lib/clacky/web/index.html +105 -41
- data/lib/clacky/web/sessions.js +742 -629
- data/lib/clacky/web/settings.js +163 -190
- 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 +5 -0
- metadata +18 -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,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 <<
|
|
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.
|