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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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 +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. 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