openclacky 1.1.6 → 1.2.0
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 +37 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +108 -6
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +519 -15
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +24 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +36 -5
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +3 -0
- data/lib/clacky/web/i18n.js +242 -24
- data/lib/clacky/web/index.html +351 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +193 -11
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +14 -1
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -30,10 +30,13 @@ module Clacky
|
|
|
30
30
|
class Browser < Base
|
|
31
31
|
self.tool_name = "browser"
|
|
32
32
|
self.tool_description = <<~DESC.strip
|
|
33
|
-
|
|
34
|
-
Actions:
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
Drive the user's real Chrome for web automation. Prefer web_fetch for read-only pages.
|
|
34
|
+
Actions: open | navigate | snapshot | act | screenshot | tabs | focus | close | status
|
|
35
|
+
Workflow: open → snapshot(interactive:true) → act(ref=...). New tab from `open` is auto-selected; only use `focus` to switch back to a previously-opened tab.
|
|
36
|
+
snapshot: returns hierarchical a11y tree truncated to ~8KB. Use query="text" to seek, or offset=N to page.
|
|
37
|
+
act kinds: click | dblclick | type | fill | press | hover | scroll | drag | select | wait | evaluate | click_at
|
|
38
|
+
evaluate: `js` is a function body, e.g. `return document.title` or `const x=...; return x;` — result is JSON-encoded.
|
|
39
|
+
screenshot: expensive — pass `ref` to capture one element instead of the whole page.
|
|
37
40
|
DESC
|
|
38
41
|
self.tool_category = "web"
|
|
39
42
|
self.tool_parameters = {
|
|
@@ -48,23 +51,25 @@ module Clacky
|
|
|
48
51
|
enum: %w[click dblclick type fill press hover drag select scroll wait evaluate click_at],
|
|
49
52
|
description: "act: interaction kind"
|
|
50
53
|
},
|
|
51
|
-
ref: { type: "string", description: "element ref from snapshot (e.g. 'e1')
|
|
54
|
+
ref: { type: "string", description: "element ref from snapshot (e.g. 'e1')" },
|
|
52
55
|
text: { type: "string", description: "act type/fill text" },
|
|
53
56
|
key: { type: "string", description: "act press key (e.g. 'Enter')" },
|
|
54
57
|
direction: { type: "string", enum: %w[up down left right], description: "act scroll" },
|
|
55
|
-
amount: { type: "integer", description: "act scroll pixels" },
|
|
58
|
+
amount: { type: "integer", description: "act scroll pixels (default 300)" },
|
|
56
59
|
ms: { type: "integer", description: "act wait ms" },
|
|
57
|
-
selector: { type: "string", description: "act wait CSS selector" },
|
|
58
|
-
js: { type: "string", description: "act evaluate JS" },
|
|
60
|
+
selector: { type: "string", description: "act wait: text or CSS selector" },
|
|
61
|
+
js: { type: "string", description: "act evaluate: JS function body (use return)" },
|
|
59
62
|
target_ref: { type: "string", description: "act drag destination ref" },
|
|
60
63
|
values: { type: "array", items: { type: "string" }, description: "act select options" },
|
|
61
64
|
x: { type: "number", description: "click_at x px" },
|
|
62
65
|
y: { type: "number", description: "click_at y px" },
|
|
63
66
|
url: { type: "string", description: "open/navigate URL" },
|
|
64
67
|
target_id: { type: "string", description: "focus/close tab id" },
|
|
65
|
-
interactive: { type: "boolean", description: "snapshot: interactive
|
|
66
|
-
compact: { type: "boolean", description: "snapshot:
|
|
67
|
-
depth: { type: "integer", description: "snapshot: max depth" },
|
|
68
|
+
interactive: { type: "boolean", description: "snapshot: only interactive elements" },
|
|
69
|
+
compact: { type: "boolean", description: "snapshot: drop empty containers" },
|
|
70
|
+
depth: { type: "integer", description: "snapshot: max tree depth" },
|
|
71
|
+
query: { type: "string", description: "snapshot: return window around first match" },
|
|
72
|
+
offset: { type: "integer", description: "snapshot: skip N lines (paging)" },
|
|
68
73
|
full_page: { type: "boolean", description: "screenshot: full page" }
|
|
69
74
|
},
|
|
70
75
|
required: ["action"]
|
|
@@ -74,8 +79,17 @@ module Clacky
|
|
|
74
79
|
MCP_HANDSHAKE_TIMEOUT = 10
|
|
75
80
|
MCP_CALL_TIMEOUT = 60
|
|
76
81
|
MIN_NODE_MAJOR = 20
|
|
77
|
-
MAX_SNAPSHOT_CHARS =
|
|
82
|
+
MAX_SNAPSHOT_CHARS = 8000
|
|
78
83
|
MAX_LLM_OUTPUT_CHARS = 6000
|
|
84
|
+
SNAPSHOT_QUERY_WINDOW = 60 # lines around a query hit
|
|
85
|
+
# Errors that mean "the selected/active page is gone" — auto-retry once.
|
|
86
|
+
RETRYABLE_PAGE_ERRORS = [
|
|
87
|
+
"selected page has been closed",
|
|
88
|
+
"No page found",
|
|
89
|
+
"no active page",
|
|
90
|
+
"Target closed",
|
|
91
|
+
"page is detached"
|
|
92
|
+
].freeze
|
|
79
93
|
|
|
80
94
|
def execute(action:, profile: nil, working_dir: nil, **opts)
|
|
81
95
|
bypass = action.to_s == "status" ||
|
|
@@ -233,29 +247,39 @@ module Clacky
|
|
|
233
247
|
interactive: opts[:interactive] || opts["interactive"],
|
|
234
248
|
compact: opts[:compact] || opts["compact"],
|
|
235
249
|
max_depth: opts[:depth] || opts["depth"])
|
|
250
|
+
text = apply_snapshot_window(text,
|
|
251
|
+
query: opts[:query] || opts["query"],
|
|
252
|
+
offset: opts[:offset] || opts["offset"])
|
|
236
253
|
{ action: "snapshot", success: true, profile: "user", output: text }
|
|
237
254
|
|
|
238
255
|
when "open"
|
|
239
256
|
url = require_url(opts)
|
|
240
257
|
return url if url.is_a?(Hash)
|
|
258
|
+
invalidate_page_cache!
|
|
241
259
|
mcp_call("new_page", { url: url })
|
|
260
|
+
wait_for_page_ready
|
|
242
261
|
{ action: "open", success: true, profile: "user", url: url, output: "Opened: #{url}" }
|
|
243
262
|
|
|
244
263
|
when "navigate"
|
|
245
264
|
url = require_url(opts)
|
|
246
265
|
return url if url.is_a?(Hash)
|
|
266
|
+
invalidate_page_cache!
|
|
247
267
|
mcp_call("navigate_page", { type: "url", url: url })
|
|
268
|
+
wait_for_page_ready
|
|
248
269
|
{ action: "navigate", success: true, profile: "user", url: url, output: "Navigated to: #{url}" }
|
|
249
270
|
|
|
250
271
|
when "focus"
|
|
251
272
|
target_id = opts[:target_id] || opts["target_id"]
|
|
252
273
|
return { error: "target_id is required for focus. Use action=tabs to list open tabs." } if target_id.nil? || target_id.to_s.empty?
|
|
274
|
+
invalidate_page_cache!
|
|
253
275
|
mcp_call("select_page", { pageId: target_id.to_i, bringToFront: true })
|
|
276
|
+
@page_id_cache = target_id.to_i
|
|
254
277
|
{ action: "focus", success: true, profile: "user", output: "Focused tab #{target_id}" }
|
|
255
278
|
|
|
256
279
|
when "close"
|
|
257
280
|
target_id = opts[:target_id] || opts["target_id"]
|
|
258
281
|
return { error: "target_id is required for close. Use action=tabs to list open tabs." } if target_id.nil? || target_id.to_s.empty?
|
|
282
|
+
invalidate_page_cache!
|
|
259
283
|
mcp_call("close_page", { pageId: target_id.to_i })
|
|
260
284
|
{ action: "close", success: true, profile: "user", output: "Closed tab #{target_id}" }
|
|
261
285
|
|
|
@@ -279,44 +303,47 @@ module Clacky
|
|
|
279
303
|
kind = (opts[:kind] || opts["kind"] || "click").to_s
|
|
280
304
|
ref = opts[:ref] || opts["ref"]
|
|
281
305
|
|
|
306
|
+
# Page-scoped MCP calls all benefit from an explicit pageId. We pass it
|
|
307
|
+
# transparently via with_page so the AI never has to think about it.
|
|
282
308
|
case kind
|
|
283
309
|
when "click", "dblclick"
|
|
284
310
|
uid = require_ref(ref)
|
|
285
311
|
return uid if uid.is_a?(Hash)
|
|
286
312
|
args = { uid: uid }
|
|
287
313
|
args[:dblClick] = true if kind == "dblclick"
|
|
288
|
-
mcp_call("click", args)
|
|
314
|
+
mcp_call("click", with_page(args))
|
|
289
315
|
|
|
290
316
|
when "fill", "type"
|
|
291
317
|
uid = require_ref(ref)
|
|
292
318
|
return uid if uid.is_a?(Hash)
|
|
293
|
-
mcp_call("fill", { uid: uid, value: opts[:text] || opts["text"] || "" })
|
|
319
|
+
mcp_call("fill", with_page({ uid: uid, value: opts[:text] || opts["text"] || "" }))
|
|
294
320
|
|
|
295
321
|
when "press"
|
|
296
|
-
mcp_call("press_key", { key: opts[:key] || opts["key"] || "Enter" })
|
|
322
|
+
mcp_call("press_key", with_page({ key: opts[:key] || opts["key"] || "Enter" }))
|
|
297
323
|
|
|
298
324
|
when "hover"
|
|
299
325
|
uid = require_ref(ref)
|
|
300
326
|
return uid if uid.is_a?(Hash)
|
|
301
|
-
mcp_call("hover", { uid: uid })
|
|
327
|
+
mcp_call("hover", with_page({ uid: uid }))
|
|
302
328
|
|
|
303
329
|
when "drag"
|
|
304
330
|
uid = require_ref(ref)
|
|
305
331
|
return uid if uid.is_a?(Hash)
|
|
306
|
-
mcp_call("drag", { from_uid: uid, to_uid: opts[:target_ref] || opts["target_ref"] || "" })
|
|
332
|
+
mcp_call("drag", with_page({ from_uid: uid, to_uid: opts[:target_ref] || opts["target_ref"] || "" }))
|
|
307
333
|
|
|
308
334
|
when "select"
|
|
309
335
|
uid = require_ref(ref)
|
|
310
336
|
return uid if uid.is_a?(Hash)
|
|
311
337
|
values = Array(opts[:values] || opts["values"] || [])
|
|
312
|
-
mcp_call("fill", { uid: uid, value: values.first.to_s })
|
|
338
|
+
mcp_call("fill", with_page({ uid: uid, value: values.first.to_s }))
|
|
313
339
|
|
|
314
340
|
when "scroll"
|
|
315
341
|
direction = opts[:direction] || opts["direction"] || "down"
|
|
316
342
|
amount = (opts[:amount] || opts["amount"] || 300).to_i
|
|
317
343
|
dx = case direction; when "right" then amount; when "left" then -amount; else 0; end
|
|
318
344
|
dy = case direction; when "down" then amount; when "up" then -amount; else 0; end
|
|
319
|
-
mcp_call("evaluate_script",
|
|
345
|
+
mcp_call("evaluate_script",
|
|
346
|
+
with_page({ function: "() => { window.scrollBy(#{dx}, #{dy}) }" }))
|
|
320
347
|
|
|
321
348
|
when "wait"
|
|
322
349
|
ms = opts[:ms] || opts["ms"]
|
|
@@ -331,20 +358,16 @@ module Clacky
|
|
|
331
358
|
end
|
|
332
359
|
|
|
333
360
|
when "evaluate"
|
|
334
|
-
js
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
page_id = sel ? sel[:id] : (pages.first && pages.first[:id])
|
|
338
|
-
eval_args = { function: "() => { return (#{js}) }" }
|
|
339
|
-
eval_args[:pageId] = page_id if page_id
|
|
340
|
-
result = mcp_call("evaluate_script", eval_args)
|
|
361
|
+
js = (opts[:js] || opts["js"] || "").to_s
|
|
362
|
+
fn = build_evaluate_function(js)
|
|
363
|
+
result = mcp_call("evaluate_script", with_page({ function: fn }))
|
|
341
364
|
return { action: "act", success: true, profile: "user", output: extract_message(result).to_s }
|
|
342
365
|
|
|
343
366
|
when "click_at"
|
|
344
367
|
x = opts[:x] || opts["x"]
|
|
345
368
|
y = opts[:y] || opts["y"]
|
|
346
369
|
return { error: "click_at requires x and y coordinates" } unless x && y
|
|
347
|
-
result = mcp_call("click_at", { x: x.to_f, y: y.to_f })
|
|
370
|
+
result = mcp_call("click_at", with_page({ x: x.to_f, y: y.to_f }))
|
|
348
371
|
return { action: "act", success: true, profile: "user", output: extract_message(result).to_s }
|
|
349
372
|
|
|
350
373
|
else
|
|
@@ -354,6 +377,29 @@ module Clacky
|
|
|
354
377
|
{ action: "act", success: true, profile: "user", output: "#{kind} completed." }
|
|
355
378
|
end
|
|
356
379
|
|
|
380
|
+
# Merge pageId into MCP args when we know the current page. Safe no-op
|
|
381
|
+
# if the cache is empty — the MCP server then falls back to its own
|
|
382
|
+
# selected-page state, matching the old behaviour.
|
|
383
|
+
private def with_page(args)
|
|
384
|
+
pid = current_page_id
|
|
385
|
+
pid ? args.merge(pageId: pid) : args
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Wrap user-supplied JS as a Chrome-DevTools-MCP `function` argument.
|
|
389
|
+
# We treat `js` as a function body so users can write `const x = ...; return x;`
|
|
390
|
+
# naturally. For pure expressions ("document.title"), we auto-prepend `return`
|
|
391
|
+
# so the result still flows back. Detection is conservative — the presence of
|
|
392
|
+
# `return` or any top-level statement keyword skips the auto-return.
|
|
393
|
+
private def build_evaluate_function(js)
|
|
394
|
+
body = js.to_s.strip
|
|
395
|
+
return "() => {}" if body.empty?
|
|
396
|
+
|
|
397
|
+
looks_like_statement = body.match?(/(^|[\s;{])(return|const|let|var|if|for|while|throw|try|switch|function|class|do|await|async)\b/) ||
|
|
398
|
+
body.include?(";")
|
|
399
|
+
body = "return (#{body})" unless looks_like_statement
|
|
400
|
+
"() => { #{body} }"
|
|
401
|
+
end
|
|
402
|
+
|
|
357
403
|
SCREENSHOT_MAX_WIDTH = 800
|
|
358
404
|
SCREENSHOT_MAX_BASE64_BYTES = 150_000
|
|
359
405
|
|
|
@@ -422,15 +468,72 @@ module Clacky
|
|
|
422
468
|
# Chrome MCP
|
|
423
469
|
# -----------------------------------------------------------------------
|
|
424
470
|
|
|
425
|
-
# Delegate to BrowserManager. Auto-retries once on
|
|
471
|
+
# Delegate to BrowserManager. Auto-retries once on transient page-context errors
|
|
472
|
+
# (closed/detached selected page, or "no active page" right after open).
|
|
426
473
|
private def mcp_call(tool_name, arguments = {})
|
|
427
474
|
Clacky::BrowserManager.instance.mcp_call(tool_name, arguments)
|
|
428
475
|
rescue RuntimeError => e
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
476
|
+
msg = e.message.to_s
|
|
477
|
+
if RETRYABLE_PAGE_ERRORS.any? { |frag| msg.include?(frag) }
|
|
478
|
+
# Try to recover by re-selecting the most recent page, then retry once.
|
|
479
|
+
recovered = recover_selected_page
|
|
480
|
+
if recovered
|
|
481
|
+
@page_id_cache = nil
|
|
482
|
+
return Clacky::BrowserManager.instance.mcp_call(tool_name, arguments)
|
|
483
|
+
end
|
|
484
|
+
raise RuntimeError, "The browser tab is no longer available. Use action=open to open a new tab, then retry."
|
|
485
|
+
end
|
|
486
|
+
raise
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Pick the currently selected page, or fall back to the most recent one,
|
|
490
|
+
# and re-issue select_page so subsequent calls have a valid context.
|
|
491
|
+
# Returns the chosen pageId, or nil if there are no pages at all.
|
|
492
|
+
private def recover_selected_page
|
|
493
|
+
list = Clacky::BrowserManager.instance.mcp_call("list_pages")
|
|
494
|
+
pages = extract_pages(list)
|
|
495
|
+
return nil if pages.empty?
|
|
496
|
+
target = pages.find { |p| p[:selected] } || pages.last
|
|
497
|
+
Clacky::BrowserManager.instance.mcp_call("select_page",
|
|
498
|
+
{ pageId: target[:id].to_i, bringToFront: false })
|
|
499
|
+
target[:id].to_i
|
|
500
|
+
rescue StandardError
|
|
501
|
+
nil
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Cached lookup of the currently selected pageId. The MCP server tracks
|
|
505
|
+
# selected state internally, but several tools/call paths drop it under
|
|
506
|
+
# race conditions (tab just opened, focus mid-flight). Passing pageId
|
|
507
|
+
# explicitly to every page-scoped call eliminates "No page found" flakes.
|
|
508
|
+
# Cache is invalidated by retry path and by open/navigate/focus.
|
|
509
|
+
private def current_page_id
|
|
510
|
+
return @page_id_cache if @page_id_cache
|
|
511
|
+
list = Clacky::BrowserManager.instance.mcp_call("list_pages")
|
|
512
|
+
pages = extract_pages(list)
|
|
513
|
+
sel = pages.find { |p| p[:selected] } || pages.last
|
|
514
|
+
@page_id_cache = sel && sel[:id] && sel[:id].to_i
|
|
515
|
+
rescue StandardError
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
private def invalidate_page_cache!
|
|
520
|
+
@page_id_cache = nil
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# After open/navigate, briefly poll until a selected page exists.
|
|
524
|
+
# Avoids the classic race where the next act/snapshot fires before
|
|
525
|
+
# chrome-devtools-mcp has registered the new tab.
|
|
526
|
+
private def wait_for_page_ready(timeout: 1.5)
|
|
527
|
+
deadline = Time.now + timeout
|
|
528
|
+
while Time.now < deadline
|
|
529
|
+
pid = current_page_id
|
|
530
|
+
return pid if pid
|
|
531
|
+
sleep 0.1
|
|
532
|
+
invalidate_page_cache!
|
|
433
533
|
end
|
|
534
|
+
nil
|
|
535
|
+
rescue StandardError
|
|
536
|
+
nil
|
|
434
537
|
end
|
|
435
538
|
|
|
436
539
|
# -----------------------------------------------------------------------
|
|
@@ -578,27 +681,101 @@ module Clacky
|
|
|
578
681
|
# Output helpers
|
|
579
682
|
# -----------------------------------------------------------------------
|
|
580
683
|
|
|
684
|
+
# Apply optional `query` / `offset` to a freshly-built snapshot. When
|
|
685
|
+
# `query` matches, return a window of SNAPSHOT_QUERY_WINDOW lines around
|
|
686
|
+
# the first hit. When `offset` is given, drop the first N lines. Returns
|
|
687
|
+
# the original text untouched when neither is provided. Both options let
|
|
688
|
+
# the AI request just the slice it needs instead of the whole tree.
|
|
689
|
+
private def apply_snapshot_window(text, query: nil, offset: nil)
|
|
690
|
+
return text if (query.nil? || query.to_s.empty?) && (offset.nil? || offset.to_i <= 0)
|
|
691
|
+
|
|
692
|
+
lines = text.lines
|
|
693
|
+
total = lines.size
|
|
694
|
+
|
|
695
|
+
if query && !query.to_s.empty?
|
|
696
|
+
q = query.to_s
|
|
697
|
+
idx = lines.index { |l| l.include?(q) }
|
|
698
|
+
if idx.nil?
|
|
699
|
+
return "[snapshot: no match for query=#{q.inspect} in #{total} lines]\n"
|
|
700
|
+
end
|
|
701
|
+
half = SNAPSHOT_QUERY_WINDOW / 2
|
|
702
|
+
start = [idx - half, 0].max
|
|
703
|
+
stop = [idx + half, total - 1].min
|
|
704
|
+
window = lines[start..stop].join
|
|
705
|
+
header = "[snapshot window: lines #{start + 1}-#{stop + 1} of #{total}, match at line #{idx + 1}]\n"
|
|
706
|
+
return header + window
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
skip = offset.to_i
|
|
710
|
+
return "[snapshot: offset #{skip} >= #{total} total lines]\n" if skip >= total
|
|
711
|
+
header = "[snapshot offset: showing from line #{skip + 1} of #{total}]\n"
|
|
712
|
+
header + lines[skip..-1].join
|
|
713
|
+
end
|
|
714
|
+
|
|
581
715
|
private def compress_snapshot(output)
|
|
582
716
|
return output if output.empty?
|
|
583
717
|
|
|
584
718
|
lines = output.lines
|
|
585
719
|
orig = lines.size
|
|
720
|
+
|
|
721
|
+
# Pass 1: drop pure noise lines.
|
|
586
722
|
filtered = lines.reject do |line|
|
|
587
723
|
s = line.strip
|
|
588
724
|
s.start_with?("- /url:", "/url:", "- /placeholder:", "/placeholder:") ||
|
|
589
|
-
s == "- img" || s.match?(/\A-\s+img\s*\z/)
|
|
725
|
+
s == "- img" || s.match?(/\A-\s+img\s*\z/) ||
|
|
726
|
+
# statictext nodes that are just line numbers / single digits / bullets
|
|
727
|
+
s.match?(/\A-\s+statictext\s+"\d{1,3}"\s*\z/) ||
|
|
728
|
+
s.match?(/\A-\s+statictext\s+""\s*\z/) ||
|
|
729
|
+
# an empty statictext placeholder ([ref=…] but no content) is also noise
|
|
730
|
+
s.match?(/\A-\s+statictext\s*\z/)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Pass 2: collapse runs of consecutive statictext lines at the same
|
|
734
|
+
# indent into a single line, joining their quoted text with " / ".
|
|
735
|
+
# Long form pages (article / docs) easily explode 500+ statictext nodes;
|
|
736
|
+
# this is the single biggest token saver.
|
|
737
|
+
collapsed = []
|
|
738
|
+
run = nil # { indent:, texts:, ref: }
|
|
739
|
+
flush = lambda do
|
|
740
|
+
if run
|
|
741
|
+
head = "#{' ' * run[:indent]}- statictext \"#{run[:texts].join(' / ')}\""
|
|
742
|
+
head += " [ref=#{run[:ref]}]" if run[:ref]
|
|
743
|
+
collapsed << head + "\n"
|
|
744
|
+
run = nil
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
filtered.each do |line|
|
|
749
|
+
m = line.match(/\A(\s*)-\s+statictext\s+"(.*?)"(?:\s+\[ref=([^\]]+)\])?\s*\z/)
|
|
750
|
+
if m
|
|
751
|
+
indent = m[1].length
|
|
752
|
+
text = m[2]
|
|
753
|
+
ref = m[3]
|
|
754
|
+
if run && run[:indent] == indent && run[:ref].nil? && ref.nil?
|
|
755
|
+
# merge text-only statictext runs (no refs) — keep refs separate so
|
|
756
|
+
# the AI can still target individual elements.
|
|
757
|
+
run[:texts] << text unless run[:texts].last == text # cheap dedupe
|
|
758
|
+
else
|
|
759
|
+
flush.call
|
|
760
|
+
run = { indent: indent, texts: [text], ref: ref }
|
|
761
|
+
end
|
|
762
|
+
else
|
|
763
|
+
flush.call
|
|
764
|
+
collapsed << line
|
|
765
|
+
end
|
|
590
766
|
end
|
|
767
|
+
flush.call
|
|
591
768
|
|
|
592
|
-
removed = orig -
|
|
593
|
-
|
|
594
|
-
|
|
769
|
+
removed = orig - collapsed.size
|
|
770
|
+
collapsed << "\n[snapshot compressed: #{removed} lines removed]\n" if removed > 0
|
|
771
|
+
collapsed.join
|
|
595
772
|
end
|
|
596
773
|
|
|
597
774
|
private def truncate_output(output, max_chars)
|
|
598
775
|
return output if output.length <= max_chars
|
|
599
776
|
|
|
600
777
|
lines = output.lines
|
|
601
|
-
available = max_chars -
|
|
778
|
+
available = max_chars - 200
|
|
602
779
|
first_part = []
|
|
603
780
|
acc = 0
|
|
604
781
|
lines.each do |line|
|
|
@@ -606,7 +783,9 @@ module Clacky
|
|
|
606
783
|
first_part << line
|
|
607
784
|
acc += line.length
|
|
608
785
|
end
|
|
609
|
-
|
|
786
|
+
hint = "\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
|
|
787
|
+
"rerun with query=\"keyword\" or offset=#{first_part.size} to see more] ...\n"
|
|
788
|
+
first_part.join + hint
|
|
610
789
|
end
|
|
611
790
|
end
|
|
612
791
|
end
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "time"
|
|
5
6
|
require_relative "base"
|
|
6
7
|
require_relative "../utils/trash_directory"
|
|
8
|
+
require_relative "../session_manager"
|
|
7
9
|
|
|
8
10
|
module Clacky
|
|
9
11
|
module Tools
|
|
@@ -214,10 +216,17 @@ module Clacky
|
|
|
214
216
|
|
|
215
217
|
old_files.each do |file|
|
|
216
218
|
begin
|
|
217
|
-
|
|
219
|
+
if File.exist?(file[:trash_file])
|
|
220
|
+
if File.directory?(file[:trash_file])
|
|
221
|
+
freed_size += _dir_size(file[:trash_file])
|
|
222
|
+
FileUtils.rm_rf(file[:trash_file])
|
|
223
|
+
else
|
|
224
|
+
freed_size += File.size(file[:trash_file])
|
|
225
|
+
File.delete(file[:trash_file])
|
|
226
|
+
end
|
|
227
|
+
deleted_count += 1
|
|
228
|
+
end
|
|
218
229
|
File.delete("#{file[:trash_file]}.metadata.json") if File.exist?("#{file[:trash_file]}.metadata.json")
|
|
219
|
-
deleted_count += 1
|
|
220
|
-
freed_size += file[:file_size] || 0
|
|
221
230
|
rescue StandardError => e
|
|
222
231
|
# Continue processing other files, but log the error
|
|
223
232
|
end
|
|
@@ -297,6 +306,16 @@ module Clacky
|
|
|
297
306
|
deleted_files.sort_by { |f| f[:deleted_at] }.reverse
|
|
298
307
|
end
|
|
299
308
|
|
|
309
|
+
private def _dir_size(dir)
|
|
310
|
+
total = 0
|
|
311
|
+
Find.find(dir) do |path|
|
|
312
|
+
total += File.size(path) if File.file?(path)
|
|
313
|
+
end
|
|
314
|
+
total
|
|
315
|
+
rescue StandardError
|
|
316
|
+
0
|
|
317
|
+
end
|
|
318
|
+
|
|
300
319
|
def format_bytes(bytes)
|
|
301
320
|
return "0 B" if bytes.zero?
|
|
302
321
|
|
|
@@ -366,6 +385,138 @@ module Clacky
|
|
|
366
385
|
success ? "[OK] #{action} completed" : "[Error] #{action} failed"
|
|
367
386
|
end
|
|
368
387
|
end
|
|
388
|
+
|
|
389
|
+
# ── Session trash ─────────────────────────────────────────────────
|
|
390
|
+
# These class methods are the authoritative implementation for session
|
|
391
|
+
# soft-delete / restore / list / permanent-delete.
|
|
392
|
+
# SessionManager delegates here; it only provides generate_filename and
|
|
393
|
+
# load_session_file as filesystem helpers.
|
|
394
|
+
|
|
395
|
+
# Soft-delete a session: stamp deleted_at, write to sessions-trash/,
|
|
396
|
+
# remove the live JSON, and move all associated chunk MD files.
|
|
397
|
+
def self.soft_delete_session(session_id, sessions_dir:)
|
|
398
|
+
sm = Clacky::SessionManager.new(sessions_dir: sessions_dir)
|
|
399
|
+
session = sm.all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
|
|
400
|
+
return false unless session
|
|
401
|
+
|
|
402
|
+
filename = sm.send(:generate_filename, session[:session_id], session[:created_at])
|
|
403
|
+
json_path = File.join(sessions_dir, filename)
|
|
404
|
+
return false unless File.exist?(json_path)
|
|
405
|
+
|
|
406
|
+
trash_dir = Clacky::TrashDirectory.sessions_trash_dir
|
|
407
|
+
FileUtils.mkdir_p(trash_dir)
|
|
408
|
+
|
|
409
|
+
# Stamp deleted_at before moving so the API can show when it was trashed
|
|
410
|
+
trash_json = File.join(trash_dir, filename)
|
|
411
|
+
File.write(trash_json, JSON.pretty_generate(session.merge(deleted_at: Time.now.iso8601)))
|
|
412
|
+
FileUtils.chmod(0o600, trash_json)
|
|
413
|
+
File.delete(json_path)
|
|
414
|
+
|
|
415
|
+
# Move all associated chunk files
|
|
416
|
+
base = File.basename(json_path, ".json")
|
|
417
|
+
Dir.glob(File.join(sessions_dir, "#{base}-chunk-*.md")).each do |chunk|
|
|
418
|
+
FileUtils.mv(chunk, trash_dir)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
true
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Restore a soft-deleted session back to the active sessions directory.
|
|
425
|
+
def self.restore_session(session_id, sessions_dir:)
|
|
426
|
+
trash_dir = Clacky::TrashDirectory.sessions_trash_dir
|
|
427
|
+
return false unless Dir.exist?(trash_dir)
|
|
428
|
+
|
|
429
|
+
sm = Clacky::SessionManager.new(sessions_dir: sessions_dir)
|
|
430
|
+
session = _trash_sessions(sm).find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
|
|
431
|
+
return false unless session
|
|
432
|
+
|
|
433
|
+
filename = sm.send(:generate_filename, session[:session_id], session[:created_at])
|
|
434
|
+
json_path = File.join(trash_dir, filename)
|
|
435
|
+
return false unless File.exist?(json_path)
|
|
436
|
+
|
|
437
|
+
target_json = File.join(sessions_dir, filename)
|
|
438
|
+
return false if File.exist?(target_json)
|
|
439
|
+
|
|
440
|
+
FileUtils.mv(json_path, target_json)
|
|
441
|
+
|
|
442
|
+
base = filename.sub(/\.json\z/, "")
|
|
443
|
+
Dir.glob(File.join(trash_dir, "#{base}-chunk-*.md")).each do |chunk|
|
|
444
|
+
FileUtils.mv(chunk, sessions_dir)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
true
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# List all soft-deleted sessions (newest-first), each enriched with file_size.
|
|
451
|
+
def self.list_trash_sessions(sessions_dir:)
|
|
452
|
+
trash_dir = Clacky::TrashDirectory.sessions_trash_dir
|
|
453
|
+
return [] unless Dir.exist?(trash_dir)
|
|
454
|
+
|
|
455
|
+
sm = Clacky::SessionManager.new(sessions_dir: sessions_dir)
|
|
456
|
+
_trash_sessions(sm)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Permanently delete one session from the trash — cannot be undone.
|
|
460
|
+
def self.permanent_delete_trash_session(session_id, sessions_dir:)
|
|
461
|
+
trash_dir = Clacky::TrashDirectory.sessions_trash_dir
|
|
462
|
+
return false unless Dir.exist?(trash_dir)
|
|
463
|
+
|
|
464
|
+
sm = Clacky::SessionManager.new(sessions_dir: sessions_dir)
|
|
465
|
+
session = _trash_sessions(sm).find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
|
|
466
|
+
return false unless session
|
|
467
|
+
|
|
468
|
+
filename = sm.send(:generate_filename, session[:session_id], session[:created_at])
|
|
469
|
+
json_path = File.join(trash_dir, filename)
|
|
470
|
+
return false unless File.exist?(json_path)
|
|
471
|
+
|
|
472
|
+
sm.send(:_hard_delete_session_with_chunks, json_path)
|
|
473
|
+
true
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Permanently delete all sessions in the trash, or only those older than
|
|
477
|
+
# :days days. Returns the count of permanently deleted sessions.
|
|
478
|
+
def self.empty_trash_sessions(sessions_dir:, days: nil)
|
|
479
|
+
trash_dir = Clacky::TrashDirectory.sessions_trash_dir
|
|
480
|
+
return 0 unless Dir.exist?(trash_dir)
|
|
481
|
+
|
|
482
|
+
sm = Clacky::SessionManager.new(sessions_dir: sessions_dir)
|
|
483
|
+
cutoff = days ? Time.now - (days * 24 * 60 * 60) : nil
|
|
484
|
+
deleted = 0
|
|
485
|
+
|
|
486
|
+
Dir.glob(File.join(trash_dir, "*.json")).each do |filepath|
|
|
487
|
+
session = sm.send(:load_session_file, filepath)
|
|
488
|
+
next unless session
|
|
489
|
+
|
|
490
|
+
if cutoff
|
|
491
|
+
trash_time = session[:deleted_at] || session[:updated_at]
|
|
492
|
+
next unless Time.parse(trash_time.to_s) < cutoff
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
sm.send(:_hard_delete_session_with_chunks, filepath)
|
|
496
|
+
deleted += 1
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
deleted
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# ── private class helper ──────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
def self._trash_sessions(sm)
|
|
505
|
+
trash_dir = Clacky::TrashDirectory.sessions_trash_dir
|
|
506
|
+
Dir.glob(File.join(trash_dir, "*.json")).filter_map do |filepath|
|
|
507
|
+
session = sm.send(:load_session_file, filepath)
|
|
508
|
+
next nil unless session
|
|
509
|
+
|
|
510
|
+
total = File.size?(filepath).to_i
|
|
511
|
+
base = File.basename(filepath, ".json")
|
|
512
|
+
Dir.glob(File.join(trash_dir, "#{base}-chunk-*.md")).each do |chunk|
|
|
513
|
+
total += File.size?(chunk).to_i
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
session.merge(file_size: total)
|
|
517
|
+
end.sort_by { |s| s[:created_at] || "" }.reverse
|
|
518
|
+
end
|
|
519
|
+
private_class_method :_trash_sessions
|
|
369
520
|
end
|
|
370
521
|
end
|
|
371
522
|
end
|
|
@@ -145,7 +145,11 @@ module Clacky
|
|
|
145
145
|
|
|
146
146
|
output = []
|
|
147
147
|
max_items = 5 # Maximum visible items
|
|
148
|
-
|
|
148
|
+
|
|
149
|
+
# Sliding window: keep selected item visible
|
|
150
|
+
start_idx = [@selected_index - max_items + 1, 0].max
|
|
151
|
+
start_idx = [start_idx, [@filtered_commands.size - max_items, 0].max].min
|
|
152
|
+
visible_commands = @filtered_commands[start_idx, max_items] || []
|
|
149
153
|
|
|
150
154
|
# Header
|
|
151
155
|
header = @pastel.dim("┌─ Commands ") + @pastel.dim("─" * (width - 13)) + @pastel.dim("┐")
|
|
@@ -153,7 +157,7 @@ module Clacky
|
|
|
153
157
|
|
|
154
158
|
# Items
|
|
155
159
|
visible_commands.each_with_index do |cmd, idx|
|
|
156
|
-
is_selected = (idx == @selected_index)
|
|
160
|
+
is_selected = (start_idx + idx == @selected_index)
|
|
157
161
|
line = render_command_item(cmd, is_selected, width)
|
|
158
162
|
output << position_cursor(row + 1 + idx, col) + line
|
|
159
163
|
end
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Clacky
|
|
|
8
8
|
# @param content [String] text portion of the assistant reply (file:// links stripped)
|
|
9
9
|
# @param files [Array<Hash>] extracted file refs: [{ name:, path:, inline: }]
|
|
10
10
|
def show_assistant_message(content, files:); end
|
|
11
|
+
def show_feedback_request(question, context, options); end
|
|
11
12
|
def show_tool_call(name, args); end
|
|
12
13
|
def show_tool_result(result); end
|
|
13
14
|
def show_tool_stdout(lines); end
|