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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -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
@@ -105,7 +105,7 @@ module Clacky
105
105
  # Channel subscribers receive the original content so they can deliver
106
106
  # local images as native attachments via send_file().
107
107
  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)
108
+ emit("assistant_message", content: web_content, files: files, created_at: created_at, interim: interim)
109
109
  forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files, interim: interim) }
110
110
  end
111
111
 
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