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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Server
|
|
9
|
+
# Preview panel routes: static workspace-file serving
|
|
10
|
+
# (/preview/s/<session>/<path>) and localhost dev-server proxying
|
|
11
|
+
# (/preview/p/<port>/<path>). Mixed into HttpServer so the route table
|
|
12
|
+
# (send(:serve_preview, ...)) dispatches into it unchanged.
|
|
13
|
+
module Preview
|
|
14
|
+
# MIME types served by the /preview/s/<session>/<path> route. Anything
|
|
15
|
+
# not listed falls back to a download (application/octet-stream) so an
|
|
16
|
+
# AI-generated page's sibling files can never execute as HTML.
|
|
17
|
+
PREVIEW_CONTENT_TYPES = {
|
|
18
|
+
".html" => "text/html; charset=utf-8",
|
|
19
|
+
".htm" => "text/html; charset=utf-8",
|
|
20
|
+
".css" => "text/css; charset=utf-8",
|
|
21
|
+
".js" => "text/javascript; charset=utf-8",
|
|
22
|
+
".mjs" => "text/javascript; charset=utf-8",
|
|
23
|
+
".json" => "application/json; charset=utf-8",
|
|
24
|
+
".map" => "application/json; charset=utf-8",
|
|
25
|
+
".svg" => "image/svg+xml",
|
|
26
|
+
".png" => "image/png",
|
|
27
|
+
".jpg" => "image/jpeg",
|
|
28
|
+
".jpeg" => "image/jpeg",
|
|
29
|
+
".gif" => "image/gif",
|
|
30
|
+
".webp" => "image/webp",
|
|
31
|
+
".avif" => "image/avif",
|
|
32
|
+
".ico" => "image/x-icon",
|
|
33
|
+
".bmp" => "image/bmp",
|
|
34
|
+
".woff" => "font/woff",
|
|
35
|
+
".woff2" => "font/woff2",
|
|
36
|
+
".ttf" => "font/ttf",
|
|
37
|
+
".otf" => "font/otf",
|
|
38
|
+
".eot" => "application/vnd.ms-fontobject",
|
|
39
|
+
".txt" => "text/plain; charset=utf-8",
|
|
40
|
+
".xml" => "application/xml; charset=utf-8",
|
|
41
|
+
".csv" => "text/csv; charset=utf-8",
|
|
42
|
+
".wasm" => "application/wasm",
|
|
43
|
+
".mp4" => "video/mp4",
|
|
44
|
+
".webm" => "video/webm",
|
|
45
|
+
".mp3" => "audio/mpeg",
|
|
46
|
+
".wav" => "audio/wav",
|
|
47
|
+
".ogg" => "audio/ogg"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Injected into preview HTML responses. The preview iframe is sandboxed
|
|
51
|
+
# without allow-same-origin, so the host page cannot read its location
|
|
52
|
+
# (and the iframe's src property does not follow internal navigations).
|
|
53
|
+
# Each served page therefore announces its own URL to the parent.
|
|
54
|
+
PREVIEW_NAV_REPORT = '<script>(function(){try{parent.postMessage({__clackyPreview:1,href:location.href},"*")}catch(e){}})()</script>'
|
|
55
|
+
|
|
56
|
+
# Headers dropped when proxying a localhost dev server through the
|
|
57
|
+
# preview panel. X-Frame-Options is what blocks the sandboxed iframe;
|
|
58
|
+
# hop-by-hop headers and Set-Cookie must not cross the proxy boundary
|
|
59
|
+
# (WEBrick recomputes the former, cookies would leak the upstream's
|
|
60
|
+
# domain into ours). CSP frame-ancestors is handled separately so the
|
|
61
|
+
# rest of the policy survives.
|
|
62
|
+
PROXY_SKIP_HEADERS = %w[
|
|
63
|
+
x-frame-options
|
|
64
|
+
transfer-encoding
|
|
65
|
+
content-length
|
|
66
|
+
connection
|
|
67
|
+
keep-alive
|
|
68
|
+
proxy-authenticate
|
|
69
|
+
proxy-authorization
|
|
70
|
+
te
|
|
71
|
+
trailer
|
|
72
|
+
upgrade
|
|
73
|
+
set-cookie
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
# Connection-stage failures when dialing the localhost dev server. The
|
|
77
|
+
# proxy retries the next loopback stack on these and answers 502 only
|
|
78
|
+
# when both stacks fail.
|
|
79
|
+
PROXY_CONNECT_ERRORS = [
|
|
80
|
+
Errno::ECONNREFUSED,
|
|
81
|
+
Errno::ECONNRESET,
|
|
82
|
+
Errno::EHOSTUNREACH,
|
|
83
|
+
Net::OpenTimeout,
|
|
84
|
+
SocketError
|
|
85
|
+
].freeze
|
|
86
|
+
|
|
87
|
+
private def preview_not_found(res)
|
|
88
|
+
res.status = 404
|
|
89
|
+
res.body = "not found"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private def preview_method_not_allowed(res)
|
|
93
|
+
res.status = 405
|
|
94
|
+
res["Allow"] = "GET, HEAD"
|
|
95
|
+
res.body = "method not allowed"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private def preview_invalid_port(res)
|
|
99
|
+
res.status = 400
|
|
100
|
+
res.body = "invalid port"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private def preview_unreachable(res, port)
|
|
104
|
+
res.status = 502
|
|
105
|
+
res.body = "cannot reach localhost:#{port}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# GET /preview/s/<session_id>/<rel> — serve a workspace file for the
|
|
109
|
+
# preview panel. The session id resolves to that session's working_dir
|
|
110
|
+
# and rel must stay inside it. Directory requests fall back to
|
|
111
|
+
# index.html so the page's relative asset references keep resolving.
|
|
112
|
+
# Extensions missing from PREVIEW_CONTENT_TYPES download (octet-stream)
|
|
113
|
+
# instead of rendering, so a non-listed file can never execute as HTML.
|
|
114
|
+
private def serve_preview(req, res)
|
|
115
|
+
unless req.request_method == "GET" || req.request_method == "HEAD"
|
|
116
|
+
preview_method_not_allowed(res)
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
rest = preview_decode_path(req.path.delete_prefix("/preview/"))
|
|
121
|
+
sid, _, rel = rest.delete_prefix("s/").partition("/")
|
|
122
|
+
|
|
123
|
+
session = sid.empty? ? nil : @session_manager.load(sid)
|
|
124
|
+
working_dir = session ? session[:working_dir].to_s : ""
|
|
125
|
+
if working_dir.empty?
|
|
126
|
+
preview_not_found(res)
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
root = File.expand_path(working_dir)
|
|
131
|
+
abs = File.expand_path(File.join(root, rel))
|
|
132
|
+
unless abs == root || abs.start_with?(root + File::SEPARATOR)
|
|
133
|
+
preview_not_found(res)
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
serve_preview_file(req, res, abs)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# GET /preview/f/<absolute-path> — serve a local file outside the
|
|
141
|
+
# workspace, addressed by its absolute path (the panel maps file:// URLs
|
|
142
|
+
# here). Restricted to the user's home directory so it cannot read
|
|
143
|
+
# system files elsewhere.
|
|
144
|
+
private def serve_preview_local(req, res)
|
|
145
|
+
unless req.request_method == "GET" || req.request_method == "HEAD"
|
|
146
|
+
preview_method_not_allowed(res)
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
abs = preview_decode_path(req.path.delete_prefix("/preview/f"))
|
|
151
|
+
return preview_not_found(res) if abs.empty? || !abs.start_with?("/")
|
|
152
|
+
|
|
153
|
+
abs = File.expand_path(abs)
|
|
154
|
+
home = File.expand_path(Dir.home)
|
|
155
|
+
unless abs == home || abs.start_with?(home + File::SEPARATOR)
|
|
156
|
+
preview_not_found(res)
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
serve_preview_file(req, res, abs)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Shared tail for the file-serving routes: resolve a directory to its
|
|
164
|
+
# index.html, then stream the file with the MIME whitelist and the
|
|
165
|
+
# injected navigation reporter.
|
|
166
|
+
private def serve_preview_file(req, res, abs)
|
|
167
|
+
abs = File.join(abs, "index.html") if File.directory?(abs)
|
|
168
|
+
unless File.file?(abs)
|
|
169
|
+
preview_not_found(res)
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
res.status = 200
|
|
174
|
+
res["Content-Type"] = PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "application/octet-stream"
|
|
175
|
+
res["Cache-Control"] = "no-store"
|
|
176
|
+
res["Pragma"] = "no-cache"
|
|
177
|
+
res["X-Content-Type-Options"] = "nosniff"
|
|
178
|
+
if req.request_method == "HEAD"
|
|
179
|
+
res["Content-Length"] = File.size(abs).to_s
|
|
180
|
+
res.body = ""
|
|
181
|
+
else
|
|
182
|
+
body = preview_body(abs)
|
|
183
|
+
res["Content-Length"] = body.bytesize.to_s
|
|
184
|
+
res.body = body
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Injected right after <head> (falling back to <html>, <!DOCTYPE>, or
|
|
189
|
+
# the document start) so the reporter runs before page scripts while a
|
|
190
|
+
# leading <!DOCTYPE> stays first — prepending would flip the document
|
|
191
|
+
# into quirks mode.
|
|
192
|
+
private def preview_body(abs)
|
|
193
|
+
body = File.binread(abs)
|
|
194
|
+
return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
|
|
195
|
+
|
|
196
|
+
html = body.force_encoding(Encoding::UTF_8)
|
|
197
|
+
if (m = html.match(/<head[^>]*>/i))
|
|
198
|
+
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
199
|
+
elsif (m = html.match(/<html[^>]*>/i))
|
|
200
|
+
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
201
|
+
elsif (m = html.match(/<!DOCTYPE[^>]*>/i))
|
|
202
|
+
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
203
|
+
else
|
|
204
|
+
html = PREVIEW_NAV_REPORT + html
|
|
205
|
+
end
|
|
206
|
+
html
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Decode %XX escapes in a preview path segment without treating "+" as
|
|
210
|
+
# space: "+" is a legal literal character in file names.
|
|
211
|
+
private def preview_decode_path(seg)
|
|
212
|
+
return seg unless seg.include?("%")
|
|
213
|
+
|
|
214
|
+
seg.gsub(/%([0-9A-Fa-f]{2})/) { |m| m[1, 2].to_i(16).chr }
|
|
215
|
+
.force_encoding(Encoding::UTF_8)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# GET /preview/p/<port>/<path> — proxy a localhost dev server through the
|
|
219
|
+
# preview panel. Rails/Next.js and friends ship X-Frame-Options / CSP
|
|
220
|
+
# frame-ancestors by default, which blocks a sandboxed iframe; the proxy
|
|
221
|
+
# strips those so the page renders. The host is restricted to the loopback
|
|
222
|
+
# interfaces (127.0.0.1 then ::1 — the port is the only variable), so this
|
|
223
|
+
# route can never be pointed at the public internet or another internal
|
|
224
|
+
# host — no SSRF.
|
|
225
|
+
private def serve_preview_proxy(req, res)
|
|
226
|
+
unless req.request_method == "GET" || req.request_method == "HEAD"
|
|
227
|
+
preview_method_not_allowed(res)
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
port_str, _, rel = req.path.delete_prefix("/preview/p/").partition("/")
|
|
232
|
+
unless port_str.match?(/\A\d+\z/)
|
|
233
|
+
preview_invalid_port(res)
|
|
234
|
+
return
|
|
235
|
+
end
|
|
236
|
+
port = port_str.to_i
|
|
237
|
+
if port < 1 || port > 65_535
|
|
238
|
+
preview_invalid_port(res)
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
target = "/" + rel
|
|
243
|
+
target += "?" + req.query_string if req.query_string && !req.query_string.empty?
|
|
244
|
+
|
|
245
|
+
klass = req.request_method == "HEAD" ? Net::HTTP::Head : Net::HTTP::Get
|
|
246
|
+
|
|
247
|
+
resp = nil
|
|
248
|
+
# Vite 5 binds only the IPv6 loopback (::1) on macOS, so connecting to
|
|
249
|
+
# 127.0.0.1 alone 502s even while the dev server is up. Try both stacks.
|
|
250
|
+
%w[127.0.0.1 ::1].each do |host|
|
|
251
|
+
up_req = klass.new(target)
|
|
252
|
+
# Accept-Encoding is dropped so the upstream answers with plain bytes
|
|
253
|
+
# instead of gzip.
|
|
254
|
+
%w[Accept User-Agent Cookie Accept-Language Referer Range].each do |h|
|
|
255
|
+
v = req[h]
|
|
256
|
+
up_req[h] = v if v && !v.empty?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
http = Net::HTTP.new(host, port)
|
|
260
|
+
http.open_timeout = 2
|
|
261
|
+
http.read_timeout = 20
|
|
262
|
+
begin
|
|
263
|
+
resp = http.request(up_req)
|
|
264
|
+
break
|
|
265
|
+
rescue *PROXY_CONNECT_ERRORS
|
|
266
|
+
next
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
if resp.nil?
|
|
271
|
+
preview_unreachable(res, port)
|
|
272
|
+
return
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
res.status = resp.code.to_i
|
|
276
|
+
is_html = false
|
|
277
|
+
is_css = false
|
|
278
|
+
upstream_frameable = true
|
|
279
|
+
resp.each_capitalized do |name, value|
|
|
280
|
+
down = name.downcase
|
|
281
|
+
upstream_frameable = false if down == "x-frame-options"
|
|
282
|
+
if down == "content-security-policy" || down == "content-security-policy-report-only"
|
|
283
|
+
upstream_frameable = false if value =~ /frame-ancestors/i
|
|
284
|
+
value = strip_frame_ancestors(value)
|
|
285
|
+
next if value.nil? || value.empty?
|
|
286
|
+
end
|
|
287
|
+
next if PROXY_SKIP_HEADERS.include?(down)
|
|
288
|
+
if down == "content-type"
|
|
289
|
+
is_html = value.start_with?("text/html")
|
|
290
|
+
is_css = value.start_with?("text/css")
|
|
291
|
+
end
|
|
292
|
+
res[name] = value
|
|
293
|
+
end
|
|
294
|
+
# Tells the preview panel whether the upstream can be framed directly.
|
|
295
|
+
# Vite-style dev servers send no X-Frame-Options, and their JS relies
|
|
296
|
+
# on absolute-path ESM imports the proxy cannot rewrite — for those,
|
|
297
|
+
# a direct iframe (which also keeps HMR websockets alive) is the only
|
|
298
|
+
# rendering path that works.
|
|
299
|
+
res["X-Clacky-Upstream-Frameable"] = upstream_frameable ? "1" : "0"
|
|
300
|
+
if req.request_method != "HEAD"
|
|
301
|
+
body = resp.body.to_s
|
|
302
|
+
if is_html
|
|
303
|
+
body = rewrite_preview_paths(body, port)
|
|
304
|
+
elsif is_css
|
|
305
|
+
body = rewrite_preview_css(body, port)
|
|
306
|
+
end
|
|
307
|
+
res.body = body
|
|
308
|
+
end
|
|
309
|
+
rescue Net::ReadTimeout, Timeout::Error, EOFError
|
|
310
|
+
preview_unreachable(res, port)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Removes the frame-ancestors directive from a Content-Security-Policy
|
|
314
|
+
# value so the proxied page may be framed, keeping the rest of the
|
|
315
|
+
# policy intact. Returns nil when nothing (or an empty policy) remains.
|
|
316
|
+
private def strip_frame_ancestors(csp)
|
|
317
|
+
stripped = csp.to_s.gsub(/frame-ancestors[^;]*;?/i, "")
|
|
318
|
+
stripped = stripped.gsub(/;\s*;/, ";").gsub(/^\s*;|;\s*$/, "").strip
|
|
319
|
+
stripped.empty? ? nil : stripped
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Rewrites absolute-path resource URLs (/assets/app.js) in a proxied HTML
|
|
323
|
+
# page to the proxy prefix so the browser resolves them against the dev
|
|
324
|
+
# server instead of the host origin. Protocol-relative (//), already
|
|
325
|
+
# proxied and scheme URLs are left untouched.
|
|
326
|
+
private def rewrite_preview_paths(html, port)
|
|
327
|
+
prefix = "/preview/p/#{port}"
|
|
328
|
+
html.gsub(/(\b(?:src|href)\s*=\s*["'])(\/[^"']*?)(["'])/i) do
|
|
329
|
+
if $2.start_with?("//", "/preview/p/")
|
|
330
|
+
$&
|
|
331
|
+
else
|
|
332
|
+
"#{$1}#{prefix}#{$2}#{$3}"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Rewrites absolute-path url() references inside a proxied stylesheet so
|
|
338
|
+
# fonts/images resolve against the dev server instead of the host origin.
|
|
339
|
+
private def rewrite_preview_css(css, port)
|
|
340
|
+
prefix = "/preview/p/#{port}"
|
|
341
|
+
css.gsub(/url\(\s*(["']?)(\/[^"')]*?)\1\s*\)/i) do
|
|
342
|
+
if $2.start_with?("//", "/preview/p/")
|
|
343
|
+
$&
|
|
344
|
+
else
|
|
345
|
+
"url(#{$1}#{prefix}#{$2}#{$1})"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -40,6 +40,10 @@ module Clacky
|
|
|
40
40
|
|
|
41
41
|
# Create a new (empty) session entry and return its id.
|
|
42
42
|
# agent/ui/thread are set later via with_session once they are constructed.
|
|
43
|
+
def agent_config_input_behavior
|
|
44
|
+
@agent_config.input_behavior
|
|
45
|
+
end
|
|
46
|
+
|
|
43
47
|
def create(session_id:)
|
|
44
48
|
raise ArgumentError, "session_id is required" if session_id.nil? || session_id.empty?
|
|
45
49
|
|
|
@@ -16,6 +16,10 @@ module Clacky
|
|
|
16
16
|
class WebUIController
|
|
17
17
|
include Clacky::UIInterface
|
|
18
18
|
|
|
19
|
+
def show_input_queue(entries)
|
|
20
|
+
@broadcaster.call(@session_id, { type: "input_queue", session_id: @session_id, entries: entries })
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
attr_reader :session_id
|
|
20
24
|
|
|
21
25
|
def initialize(session_id, broadcaster)
|
|
@@ -66,8 +70,9 @@ module Clacky
|
|
|
66
70
|
|
|
67
71
|
# === Output display ===
|
|
68
72
|
|
|
69
|
-
def show_user_message(content, created_at: nil, files: [], source: :web, skill_command: nil, skill_command_display: nil)
|
|
73
|
+
def show_user_message(content, created_at: nil, files: [], source: :web, skill_command: nil, skill_command_display: nil, steering: false)
|
|
70
74
|
data = { content: content }
|
|
75
|
+
data[:steering] = true if steering
|
|
71
76
|
data[:created_at] = created_at if created_at
|
|
72
77
|
data[:skill_command] = skill_command if skill_command
|
|
73
78
|
data[:skill_command_display] = skill_command_display if skill_command_display
|
|
@@ -105,7 +110,7 @@ module Clacky
|
|
|
105
110
|
# Channel subscribers receive the original content so they can deliver
|
|
106
111
|
# local images as native attachments via send_file().
|
|
107
112
|
web_content = Clacky::Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
108
|
-
emit("assistant_message", content: web_content, files: files, created_at: created_at)
|
|
113
|
+
emit("assistant_message", content: web_content, files: files, created_at: created_at, interim: interim)
|
|
109
114
|
forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files, interim: interim) }
|
|
110
115
|
end
|
|
111
116
|
|
|
@@ -144,10 +149,10 @@ module Clacky
|
|
|
144
149
|
forward_to_subscribers { |sub| sub.show_tool_call(name, args_data) }
|
|
145
150
|
end
|
|
146
151
|
|
|
147
|
-
def show_tool_result(result)
|
|
152
|
+
def show_tool_result(result, ui: nil)
|
|
148
153
|
@live_tool_call = nil # tool finished — no longer in-flight
|
|
149
|
-
emit("tool_result", result: result)
|
|
150
|
-
forward_to_subscribers { |sub| sub.show_tool_result(result) }
|
|
154
|
+
emit("tool_result", result: result, ui: ui)
|
|
155
|
+
forward_to_subscribers { |sub| sub.show_tool_result(result, ui: ui) }
|
|
151
156
|
end
|
|
152
157
|
|
|
153
158
|
def show_tool_error(error)
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "open3"
|
|
7
7
|
require "set"
|
|
8
|
+
require "time"
|
|
8
9
|
|
|
9
10
|
module Clacky
|
|
10
11
|
class SessionManager
|
|
@@ -370,10 +371,28 @@ module Clacky
|
|
|
370
371
|
sessions[(source == "regular" ? keep : grouped_keep)..] || []
|
|
371
372
|
end
|
|
372
373
|
|
|
374
|
+
# Ranking above works off the cheap header, which cannot see the messages
|
|
375
|
+
# that the heal in load_session_file derives the real last activity from.
|
|
376
|
+
# A session written by an older build therefore ranks by a stale stamp and
|
|
377
|
+
# lands in the victims purely for being quiet on paper while it was in use
|
|
378
|
+
# recently. Give those candidates a full parse before evicting them.
|
|
379
|
+
victims = victims.reject { |session| healed_activity?(session) }
|
|
380
|
+
|
|
373
381
|
victims.each { |session| soft_delete(session[:session_id]) }
|
|
374
382
|
victims.size
|
|
375
383
|
end
|
|
376
384
|
|
|
385
|
+
# True when a header-ranked candidate carries a stored stamp older than what
|
|
386
|
+
# a full parse reports, i.e. the ranking underestimated its last activity.
|
|
387
|
+
# Costs one parse per candidate, which is the tail of an already sorted
|
|
388
|
+
# list, not the full corpus.
|
|
389
|
+
private def healed_activity?(meta)
|
|
390
|
+
full = load_session_file(meta[:_path])
|
|
391
|
+
return false unless full
|
|
392
|
+
|
|
393
|
+
full[:updated_at].to_s > (meta[:updated_at] || meta[:created_at]).to_s
|
|
394
|
+
end
|
|
395
|
+
|
|
377
396
|
# A session belongs to the "regular" pool unless it is a folded grouped
|
|
378
397
|
# source (cron/ext) with no project. project_id only matters for cron/ext
|
|
379
398
|
# sessions, and it lives deep in the file, so we pay for a full parse only
|
|
@@ -488,11 +507,43 @@ module Clacky
|
|
|
488
507
|
end
|
|
489
508
|
|
|
490
509
|
def load_session_file(filepath)
|
|
491
|
-
JSON.parse(File.read(filepath), symbolize_names: true)
|
|
510
|
+
data = JSON.parse(File.read(filepath), symbolize_names: true)
|
|
511
|
+
heal_stale_updated_at!(data)
|
|
512
|
+
data
|
|
492
513
|
rescue JSON::ParserError, Errno::ENOENT
|
|
493
514
|
nil
|
|
494
515
|
end
|
|
495
516
|
|
|
517
|
+
# Trust the newest message over a stale updated_at.
|
|
518
|
+
#
|
|
519
|
+
# Sessions written before the serializer started remembering its own last
|
|
520
|
+
# stamp are already on disk with an updated_at that goes back to whenever
|
|
521
|
+
# the agent was restored — months behind the conversation. Left alone,
|
|
522
|
+
# such a session sorts (and is displayed) as old, "load more" walks past
|
|
523
|
+
# it, and cleanup ranks it as stale enough to delete.
|
|
524
|
+
private def heal_stale_updated_at!(data)
|
|
525
|
+
newest = nil
|
|
526
|
+
(data[:messages] || []).each do |message|
|
|
527
|
+
ts = message[:created_at]
|
|
528
|
+
next unless ts.is_a?(Numeric)
|
|
529
|
+
newest = ts if newest.nil? || ts > newest
|
|
530
|
+
end
|
|
531
|
+
return unless newest
|
|
532
|
+
|
|
533
|
+
stamped = begin
|
|
534
|
+
data[:updated_at] && Time.parse(data[:updated_at].to_s)
|
|
535
|
+
rescue ArgumentError, TypeError
|
|
536
|
+
nil
|
|
537
|
+
end
|
|
538
|
+
# updated_at is written as whole seconds while message timestamps are
|
|
539
|
+
# floats, so a healthy session saved in the same second as its last
|
|
540
|
+
# message looks stale by a few hundred milliseconds. Rewriting it would
|
|
541
|
+
# produce the identical string, so tolerate that much drift; genuine
|
|
542
|
+
# drift is minutes or days and is still caught.
|
|
543
|
+
newest_at = Time.at(newest)
|
|
544
|
+
data[:updated_at] = newest_at.iso8601 if stamped.nil? || newest_at - stamped > 1
|
|
545
|
+
end
|
|
546
|
+
|
|
496
547
|
# Read only the small scalar header of a session file without parsing the
|
|
497
548
|
# (potentially multi-MB) messages / time_machine payload. Session JSON is
|
|
498
549
|
# written with a stable field order, so the fields we care about here
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require "json"
|
|
4
5
|
require "pathname"
|
|
5
6
|
require_relative "utils/file_ignore_helper"
|
|
6
7
|
require_relative "utils/gitignore_parser"
|
|
@@ -618,6 +619,53 @@ module Clacky
|
|
|
618
619
|
content
|
|
619
620
|
end
|
|
620
621
|
|
|
622
|
+
class << self
|
|
623
|
+
# Rebuild a raw SKILL.md string with the editable frontmatter fields
|
|
624
|
+
# (name/name_zh/description/description_zh) and body updated, preserving
|
|
625
|
+
# every other frontmatter line untouched.
|
|
626
|
+
def update_frontmatter_fields(raw_content, fields)
|
|
627
|
+
scalar = lambda do |value|
|
|
628
|
+
s = value.to_s
|
|
629
|
+
if s.empty? || s != s.strip
|
|
630
|
+
JSON.generate(s)
|
|
631
|
+
elsif s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9 _.\-\/]*\z/)
|
|
632
|
+
s
|
|
633
|
+
else
|
|
634
|
+
JSON.generate(s)
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
editable = {
|
|
639
|
+
"name" => fields["name"].to_s.strip,
|
|
640
|
+
"name_zh" => fields["name_zh"].to_s.strip,
|
|
641
|
+
"description" => fields["description"].to_s.strip,
|
|
642
|
+
"description_zh" => fields["description_zh"].to_s.strip
|
|
643
|
+
}
|
|
644
|
+
body = fields["body"].to_s.sub(/\A\n+/, "")
|
|
645
|
+
|
|
646
|
+
match = raw_content.match(/\A---\n(.*?)\n---[ \t]*\n?/m)
|
|
647
|
+
yaml_block = match ? match[1] : ""
|
|
648
|
+
|
|
649
|
+
lines = yaml_block.empty? ? [] : yaml_block.split("\n", -1)
|
|
650
|
+
updated = {}
|
|
651
|
+
new_lines = lines.map do |line|
|
|
652
|
+
key = line[/\A([a-zA-Z0-9_-]+):/, 1]
|
|
653
|
+
if editable.key?(key) && !updated[key]
|
|
654
|
+
updated[key] = true
|
|
655
|
+
"#{key}: #{scalar.call(editable[key])}"
|
|
656
|
+
else
|
|
657
|
+
line
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
editable.each do |key, value|
|
|
661
|
+
next if updated[key]
|
|
662
|
+
new_lines << "#{key}: #{scalar.call(value)}"
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
"---\n#{new_lines.join("\n")}\n---\n\n#{body}"
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
621
669
|
|
|
622
670
|
end
|
|
623
671
|
end
|