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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +108 -6
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +36 -5
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +14 -1
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -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
- Control user's real Chrome (146+) for web automation. Prefer web_fetch/web_search for read-only pages.
34
- Actions: snapshot | act | open | navigate | tabs | focus | close | screenshot | status.
35
- Always snapshot(interactive:true) before act. screenshot is EXPENSIVE use ref= for a single element.
36
- act kinds: click, dblclick, type, fill, press, hover, scroll, drag, select, wait, evaluate, click_at (coord fallback).
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'); screenshot: single element" },
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 only" },
66
- compact: { type: "boolean", description: "snapshot: compact" },
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 = 4000
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", { function: "() => { window.scrollBy(#{dx}, #{dy}) }" })
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 = opts[:js] || opts["js"] || ""
335
- pages = extract_pages(mcp_call("list_pages"))
336
- sel = pages.find { |p| p[:selected] }
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 "selected page has been closed".
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
- if e.message.include?("selected page has been closed")
430
- raise RuntimeError, "The browser tab was closed. Use action=open to open a new tab, then retry."
431
- else
432
- raise
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 - filtered.size
593
- filtered << "\n[snapshot compressed: #{removed} lines removed]\n" if removed > 0
594
- filtered.join
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 - 150
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
- first_part.join + "\n... [truncated: #{first_part.size}/#{lines.size} lines shown] ..."
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
- File.delete(file[:trash_file]) if File.exist?(file[:trash_file])
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
- visible_commands = @filtered_commands.take(max_items)
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
@@ -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