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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -34,7 +34,9 @@ module Clacky
|
|
|
34
34
|
Actions: open | navigate | snapshot | act | screenshot | tabs | focus | close | status
|
|
35
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
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
|
|
37
|
+
act kinds: click | dblclick | type | fill | fill_form | press | hover | scroll | drag | select | wait | evaluate | click_at
|
|
38
|
+
act auto-waits for network/DOM to settle and returns the post-action page state, so do NOT follow an act with a snapshot — read the result instead. Pass include_snapshot:false to skip it on trivial acts.
|
|
39
|
+
fill_form: ALWAYS prefer this over several individual fill/click calls on a form — one call, fewer turns. fill/type accept submit_key:"Enter" to submit in the same call.
|
|
38
40
|
evaluate: `js` is a JS expression, e.g. "document.title". For multi-line / async logic use an IIFE: "(async () => { const r = await fetch(...); return r.status })()". Result is JSON-encoded.
|
|
39
41
|
screenshot: expensive — pass `ref` to capture one element instead of the whole page.
|
|
40
42
|
DESC
|
|
@@ -48,11 +50,24 @@ module Clacky
|
|
|
48
50
|
},
|
|
49
51
|
kind: {
|
|
50
52
|
type: "string",
|
|
51
|
-
enum: %w[click dblclick type fill press hover drag select scroll wait evaluate click_at],
|
|
53
|
+
enum: %w[click dblclick type fill fill_form press hover drag select scroll wait evaluate click_at],
|
|
52
54
|
description: "act: interaction kind"
|
|
53
55
|
},
|
|
54
56
|
ref: { type: "string", description: "element ref from snapshot (e.g. 'e1')" },
|
|
55
57
|
text: { type: "string", description: "act type/fill text" },
|
|
58
|
+
submit_key: { type: "string", description: "act fill/type: key to press after filling (e.g. 'Enter')" },
|
|
59
|
+
elements: {
|
|
60
|
+
type: "array",
|
|
61
|
+
description: "act fill_form: [{ref, value}, ...] — fill a whole form in one call",
|
|
62
|
+
items: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
ref: { type: "string", description: "element ref from snapshot" },
|
|
66
|
+
value: { type: "string", description: "value; 'true'/'false' for checkboxes and toggles" }
|
|
67
|
+
},
|
|
68
|
+
required: ["ref", "value"]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
56
71
|
key: { type: "string", description: "act press key (e.g. 'Enter')" },
|
|
57
72
|
direction: { type: "string", enum: %w[up down left right], description: "act scroll" },
|
|
58
73
|
amount: { type: "integer", description: "act scroll pixels (default 300)" },
|
|
@@ -70,7 +85,11 @@ module Clacky
|
|
|
70
85
|
depth: { type: "integer", description: "snapshot: max tree depth" },
|
|
71
86
|
query: { type: "string", description: "snapshot: return window around first match" },
|
|
72
87
|
offset: { type: "integer", description: "snapshot: skip N lines (paging)" },
|
|
73
|
-
full_page: { type: "boolean", description: "screenshot: full page" }
|
|
88
|
+
full_page: { type: "boolean", description: "screenshot: full page" },
|
|
89
|
+
include_snapshot: {
|
|
90
|
+
type: "boolean",
|
|
91
|
+
description: "act: return the post-action page snapshot (default true). Set false for trivial acts."
|
|
92
|
+
}
|
|
74
93
|
},
|
|
75
94
|
required: ["action"]
|
|
76
95
|
}
|
|
@@ -90,6 +109,14 @@ module Clacky
|
|
|
90
109
|
"Target closed",
|
|
91
110
|
"page is detached"
|
|
92
111
|
].freeze
|
|
112
|
+
# chrome-devtools-mcp resolves refs against the last snapshot it took for a
|
|
113
|
+
# page, so a page that has never been snapshotted rejects every ref call.
|
|
114
|
+
MISSING_SNAPSHOT_ERROR = "No snapshot found for page"
|
|
115
|
+
# Raised when a ref exists in no/older snapshot — the AI needs fresh refs.
|
|
116
|
+
STALE_REF_ERRORS = [
|
|
117
|
+
"not found on page",
|
|
118
|
+
"no longer exists on the page"
|
|
119
|
+
].freeze
|
|
93
120
|
|
|
94
121
|
def execute(action:, profile: nil, working_dir: nil, **opts)
|
|
95
122
|
bypass = action.to_s == "status" ||
|
|
@@ -103,6 +130,23 @@ module Clacky
|
|
|
103
130
|
{ error: classify_browser_error(e) }
|
|
104
131
|
end
|
|
105
132
|
|
|
133
|
+
# Hidden once the user turns the browser off, so the model never sees an
|
|
134
|
+
# action it would only get rejected for. An unconfigured browser stays
|
|
135
|
+
# visible — that path guides the user into browser-setup.
|
|
136
|
+
#
|
|
137
|
+
# Checked once at registration: the tools array carries the prompt-cache
|
|
138
|
+
# breakpoint, so it must stay byte-identical for the whole process.
|
|
139
|
+
# Toggling the browser takes effect on the next restart.
|
|
140
|
+
def self.available?
|
|
141
|
+
return true unless File.exist?(BROWSER_CONFIG_PATH)
|
|
142
|
+
|
|
143
|
+
config = YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH),
|
|
144
|
+
permitted_classes: [Date, Time, Symbol])
|
|
145
|
+
config.is_a?(Hash) && config["enabled"] == true
|
|
146
|
+
rescue StandardError
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
106
150
|
def format_call(args)
|
|
107
151
|
action = args[:action] || args["action"] || "browser"
|
|
108
152
|
"browser(#{action})"
|
|
@@ -142,13 +186,14 @@ module Clacky
|
|
|
142
186
|
end
|
|
143
187
|
|
|
144
188
|
output = result[:output].to_s
|
|
145
|
-
|
|
146
|
-
|
|
189
|
+
has_snapshot = action == "snapshot" || result[:snapshot_included]
|
|
190
|
+
output = compress_snapshot(output) if has_snapshot
|
|
191
|
+
max_chars = has_snapshot ? MAX_SNAPSHOT_CHARS : MAX_LLM_OUTPUT_CHARS
|
|
147
192
|
|
|
148
193
|
{
|
|
149
194
|
action: action,
|
|
150
195
|
success: result[:success],
|
|
151
|
-
stdout: truncate_output(output, max_chars),
|
|
196
|
+
stdout: truncate_output(output, max_chars, paged: action == "snapshot"),
|
|
152
197
|
profile: result[:profile]
|
|
153
198
|
}.compact
|
|
154
199
|
end
|
|
@@ -302,6 +347,7 @@ module Clacky
|
|
|
302
347
|
private def do_user_act(opts)
|
|
303
348
|
kind = (opts[:kind] || opts["kind"] || "click").to_s
|
|
304
349
|
ref = opts[:ref] || opts["ref"]
|
|
350
|
+
snap = include_snapshot?(opts)
|
|
305
351
|
|
|
306
352
|
# Page-scoped MCP calls all benefit from an explicit pageId. We pass it
|
|
307
353
|
# transparently via with_page so the AI never has to think about it.
|
|
@@ -311,31 +357,48 @@ module Clacky
|
|
|
311
357
|
return uid if uid.is_a?(Hash)
|
|
312
358
|
args = { uid: uid }
|
|
313
359
|
args[:dblClick] = true if kind == "dblclick"
|
|
314
|
-
mcp_call("click", with_page(args))
|
|
360
|
+
return act_result(kind, mcp_call("click", with_page(with_snapshot(args, snap))), opts, snap)
|
|
315
361
|
|
|
316
362
|
when "fill", "type"
|
|
317
363
|
uid = require_ref(ref)
|
|
318
364
|
return uid if uid.is_a?(Hash)
|
|
319
|
-
|
|
365
|
+
submit = opts[:submit_key] || opts["submit_key"]
|
|
366
|
+
fill_args = { uid: uid, value: (opts[:text] || opts["text"] || "").to_s }
|
|
367
|
+
if submit && !submit.to_s.empty?
|
|
368
|
+
mcp_call("fill", with_page(fill_args))
|
|
369
|
+
result = mcp_call("press_key", with_page(with_snapshot({ key: submit.to_s }, snap)))
|
|
370
|
+
else
|
|
371
|
+
result = mcp_call("fill", with_page(with_snapshot(fill_args, snap)))
|
|
372
|
+
end
|
|
373
|
+
return act_result(kind, result, opts, snap)
|
|
374
|
+
|
|
375
|
+
when "fill_form"
|
|
376
|
+
elements = normalize_form_elements(opts[:elements] || opts["elements"])
|
|
377
|
+
return elements if elements.is_a?(Hash)
|
|
378
|
+
result = mcp_call("fill_form", with_page(with_snapshot({ elements: elements }, snap)))
|
|
379
|
+
return act_result(kind, result, opts, snap)
|
|
320
380
|
|
|
321
381
|
when "press"
|
|
322
|
-
|
|
382
|
+
args = { key: (opts[:key] || opts["key"] || "Enter").to_s }
|
|
383
|
+
return act_result(kind, mcp_call("press_key", with_page(with_snapshot(args, snap))), opts, snap)
|
|
323
384
|
|
|
324
385
|
when "hover"
|
|
325
386
|
uid = require_ref(ref)
|
|
326
387
|
return uid if uid.is_a?(Hash)
|
|
327
|
-
mcp_call("hover", with_page({ uid: uid }))
|
|
388
|
+
return act_result(kind, mcp_call("hover", with_page(with_snapshot({ uid: uid }, snap))), opts, snap)
|
|
328
389
|
|
|
329
390
|
when "drag"
|
|
330
391
|
uid = require_ref(ref)
|
|
331
392
|
return uid if uid.is_a?(Hash)
|
|
332
|
-
|
|
393
|
+
args = { from_uid: uid, to_uid: (opts[:target_ref] || opts["target_ref"] || "").to_s }
|
|
394
|
+
return act_result(kind, mcp_call("drag", with_page(with_snapshot(args, snap))), opts, snap)
|
|
333
395
|
|
|
334
396
|
when "select"
|
|
335
397
|
uid = require_ref(ref)
|
|
336
398
|
return uid if uid.is_a?(Hash)
|
|
337
399
|
values = Array(opts[:values] || opts["values"] || [])
|
|
338
|
-
|
|
400
|
+
args = { uid: uid, value: values.first.to_s }
|
|
401
|
+
return act_result(kind, mcp_call("fill", with_page(with_snapshot(args, snap))), opts, snap)
|
|
339
402
|
|
|
340
403
|
when "scroll"
|
|
341
404
|
direction = opts[:direction] || opts["direction"] || "down"
|
|
@@ -352,7 +415,8 @@ module Clacky
|
|
|
352
415
|
sleep(ms.to_i / 1000.0)
|
|
353
416
|
return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
|
|
354
417
|
elsif sel
|
|
355
|
-
|
|
418
|
+
# wait_for always attaches a snapshot server-side.
|
|
419
|
+
return act_result(kind, mcp_call("wait_for", with_page({ text: [sel] })), opts, true)
|
|
356
420
|
else
|
|
357
421
|
sleep(1)
|
|
358
422
|
end
|
|
@@ -367,8 +431,8 @@ module Clacky
|
|
|
367
431
|
x = opts[:x] || opts["x"]
|
|
368
432
|
y = opts[:y] || opts["y"]
|
|
369
433
|
return { error: "click_at requires x and y coordinates" } unless x && y
|
|
370
|
-
|
|
371
|
-
return
|
|
434
|
+
args = { x: x.to_f, y: y.to_f }
|
|
435
|
+
return act_result(kind, mcp_call("click_at", with_page(with_snapshot(args, snap))), opts, snap)
|
|
372
436
|
|
|
373
437
|
else
|
|
374
438
|
return { error: "Unknown act kind: #{kind}" }
|
|
@@ -377,6 +441,72 @@ module Clacky
|
|
|
377
441
|
{ action: "act", success: true, profile: "user", output: "#{kind} completed." }
|
|
378
442
|
end
|
|
379
443
|
|
|
444
|
+
# act defaults to returning the post-action snapshot: chrome-devtools-mcp
|
|
445
|
+
# renders it in the same response, so a follow-up snapshot call is waste.
|
|
446
|
+
private def include_snapshot?(opts)
|
|
447
|
+
val = opts.key?(:include_snapshot) ? opts[:include_snapshot] : opts["include_snapshot"]
|
|
448
|
+
return true if val.nil?
|
|
449
|
+
!(val == false || val.to_s == "false")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
private def with_snapshot(args, enabled)
|
|
453
|
+
enabled ? args.merge(includeSnapshot: true) : args
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
private def normalize_form_elements(raw)
|
|
457
|
+
list = Array(raw)
|
|
458
|
+
return { error: "fill_form requires a non-empty `elements` array of {ref, value}" } if list.empty?
|
|
459
|
+
|
|
460
|
+
normalized = list.map do |entry|
|
|
461
|
+
next nil unless entry.is_a?(Hash)
|
|
462
|
+
uid = entry[:ref] || entry["ref"] || entry[:uid] || entry["uid"]
|
|
463
|
+
value = entry[:value] || entry["value"] || entry[:text] || entry["text"]
|
|
464
|
+
next nil if uid.nil? || uid.to_s.empty?
|
|
465
|
+
{ uid: uid.to_s, value: value.to_s }
|
|
466
|
+
end.compact
|
|
467
|
+
|
|
468
|
+
return { error: "fill_form `elements` entries must each have a `ref`" } if normalized.empty?
|
|
469
|
+
normalized
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Render an act response: the MCP status line plus the post-action
|
|
473
|
+
# snapshot when one was attached.
|
|
474
|
+
private def act_result(kind, result, opts, snapshot_requested)
|
|
475
|
+
message = extract_act_message(result).strip
|
|
476
|
+
message = "#{kind} completed." if message.empty?
|
|
477
|
+
|
|
478
|
+
parts = [message]
|
|
479
|
+
included = false
|
|
480
|
+
|
|
481
|
+
if snapshot_requested
|
|
482
|
+
node = extract_snapshot(result)
|
|
483
|
+
if node.is_a?(Hash) && !node.empty?
|
|
484
|
+
text = build_ai_snapshot(node,
|
|
485
|
+
interactive: opts[:interactive] || opts["interactive"],
|
|
486
|
+
compact: opts[:compact] || opts["compact"],
|
|
487
|
+
max_depth: opts[:depth] || opts["depth"])
|
|
488
|
+
unless text.empty?
|
|
489
|
+
parts << "## Page snapshot after #{kind}\n#{text}"
|
|
490
|
+
included = true
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
{ action: "act", success: true, profile: "user",
|
|
496
|
+
output: parts.join("\n\n"), snapshot_included: included }
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Like extract_message, but drops the MCP-rendered snapshot block from the
|
|
500
|
+
# text fallback so it is not duplicated alongside our own rendering.
|
|
501
|
+
private def extract_act_message(result)
|
|
502
|
+
return "" unless result.is_a?(Hash)
|
|
503
|
+
|
|
504
|
+
structured = result["structuredContent"]
|
|
505
|
+
return structured["message"].to_s if structured.is_a?(Hash) && structured["message"]
|
|
506
|
+
|
|
507
|
+
extract_text_content(result).split("## Latest page snapshot").first.to_s
|
|
508
|
+
end
|
|
509
|
+
|
|
380
510
|
# Merge pageId into MCP args when we know the current page. Safe no-op
|
|
381
511
|
# if the cache is empty — the MCP server then falls back to its own
|
|
382
512
|
# selected-page state, matching the old behaviour.
|
|
@@ -460,11 +590,19 @@ module Clacky
|
|
|
460
590
|
# -----------------------------------------------------------------------
|
|
461
591
|
|
|
462
592
|
# Delegate to BrowserManager. Auto-retries once on transient page-context errors
|
|
463
|
-
# (closed/detached selected page, or "no active page" right after open)
|
|
593
|
+
# (closed/detached selected page, or "no active page" right after open) and on
|
|
594
|
+
# a missing page snapshot (fresh tab that ref-based calls cannot resolve yet).
|
|
464
595
|
private def mcp_call(tool_name, arguments = {})
|
|
465
596
|
Clacky::BrowserManager.instance.mcp_call(tool_name, arguments)
|
|
466
597
|
rescue RuntimeError => e
|
|
467
598
|
msg = e.message.to_s
|
|
599
|
+
|
|
600
|
+
if msg.include?(MISSING_SNAPSHOT_ERROR)
|
|
601
|
+
# Prime the page snapshot the MCP server resolves refs against, then retry once.
|
|
602
|
+
return Clacky::BrowserManager.instance.mcp_call(tool_name, arguments) if prime_page_snapshot
|
|
603
|
+
raise RuntimeError, "The page has no snapshot yet. Use action=snapshot first, then retry."
|
|
604
|
+
end
|
|
605
|
+
|
|
468
606
|
if RETRYABLE_PAGE_ERRORS.any? { |frag| msg.include?(frag) }
|
|
469
607
|
# Try to recover by re-selecting the most recent page, then retry once.
|
|
470
608
|
recovered = recover_selected_page
|
|
@@ -474,9 +612,23 @@ module Clacky
|
|
|
474
612
|
end
|
|
475
613
|
raise RuntimeError, "The browser tab is no longer available. Use action=open to open a new tab, then retry."
|
|
476
614
|
end
|
|
615
|
+
|
|
616
|
+
if STALE_REF_ERRORS.any? { |frag| msg.include?(frag) }
|
|
617
|
+
raise RuntimeError, "#{msg} The page changed — use action=snapshot to get fresh refs, then retry."
|
|
618
|
+
end
|
|
619
|
+
|
|
477
620
|
raise
|
|
478
621
|
end
|
|
479
622
|
|
|
623
|
+
# Take a snapshot purely so the MCP server has refs to resolve against.
|
|
624
|
+
# Returns true when the page now has one.
|
|
625
|
+
private def prime_page_snapshot
|
|
626
|
+
Clacky::BrowserManager.instance.mcp_call("take_snapshot", with_page({}))
|
|
627
|
+
true
|
|
628
|
+
rescue StandardError
|
|
629
|
+
false
|
|
630
|
+
end
|
|
631
|
+
|
|
480
632
|
# Pick the currently selected page, or fall back to the most recent one,
|
|
481
633
|
# and re-issue select_page so subsequent calls have a valid context.
|
|
482
634
|
# Returns the chosen pageId, or nil if there are no pages at all.
|
|
@@ -762,7 +914,7 @@ module Clacky
|
|
|
762
914
|
collapsed.join
|
|
763
915
|
end
|
|
764
916
|
|
|
765
|
-
private def truncate_output(output, max_chars)
|
|
917
|
+
private def truncate_output(output, max_chars, paged: true)
|
|
766
918
|
return output if output.length <= max_chars
|
|
767
919
|
|
|
768
920
|
lines = output.lines
|
|
@@ -774,8 +926,13 @@ module Clacky
|
|
|
774
926
|
first_part << line
|
|
775
927
|
acc += line.length
|
|
776
928
|
end
|
|
777
|
-
hint =
|
|
778
|
-
|
|
929
|
+
hint = if paged
|
|
930
|
+
"\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
|
|
931
|
+
"rerun with query=\"keyword\" or offset=#{first_part.size} to see more] ...\n"
|
|
932
|
+
else
|
|
933
|
+
"\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
|
|
934
|
+
"use action=snapshot with query=\"keyword\" if you need the rest] ...\n"
|
|
935
|
+
end
|
|
779
936
|
first_part.join + hint
|
|
780
937
|
end
|
|
781
938
|
end
|
|
@@ -30,10 +30,14 @@ module Clacky
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
# Ordered list of search providers to try in sequence.
|
|
33
|
-
#
|
|
34
|
-
|
|
33
|
+
# Parallel is the primary no-key provider; the HTML scrapers remain as
|
|
34
|
+
# fallbacks, with cn.bing.com accessible in mainland China without VPN.
|
|
35
|
+
PROVIDERS = %i[parallel duckduckgo bing].freeze
|
|
35
36
|
|
|
36
37
|
SEARCHER_TIMEOUT = 30
|
|
38
|
+
PARALLEL_ENDPOINT = "https://search.parallel.ai/mcp"
|
|
39
|
+
PARALLEL_SNIPPET_MAX_CHARS = 400
|
|
40
|
+
PROVIDER_COOLDOWN_SECONDS = 600
|
|
37
41
|
|
|
38
42
|
def execute(query:, max_results: 10, working_dir: nil)
|
|
39
43
|
if query.nil? || query.strip.empty?
|
|
@@ -57,8 +61,13 @@ module Clacky
|
|
|
57
61
|
error: nil
|
|
58
62
|
}
|
|
59
63
|
rescue StandardError => e
|
|
60
|
-
#
|
|
61
|
-
|
|
64
|
+
# Avoid paying the same network/handshake failure on every search.
|
|
65
|
+
if provider == :parallel
|
|
66
|
+
reset_parallel_client
|
|
67
|
+
@parallel_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
|
|
68
|
+
elsif provider == :duckduckgo
|
|
69
|
+
@ddg_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
|
|
70
|
+
end
|
|
62
71
|
last_error = e
|
|
63
72
|
next
|
|
64
73
|
end
|
|
@@ -93,13 +102,75 @@ module Clacky
|
|
|
93
102
|
# broken key or script look like a low-quality result set.
|
|
94
103
|
return [:custom] if Clacky::SearchConfig.script_path
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
PROVIDERS.reject do |provider|
|
|
106
|
+
unavailable_until = case provider
|
|
107
|
+
when :parallel then @parallel_unavailable_until
|
|
108
|
+
when :duckduckgo then @ddg_unavailable_until
|
|
109
|
+
end
|
|
110
|
+
unavailable_until && Time.now < unavailable_until
|
|
100
111
|
end
|
|
101
112
|
end
|
|
102
113
|
|
|
114
|
+
# ── Parallel Free MCP ──────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
private def search_parallel(query, max_results)
|
|
117
|
+
result = parallel_client.call_tool(
|
|
118
|
+
"web_search",
|
|
119
|
+
{
|
|
120
|
+
"objective" => query[0, 5000],
|
|
121
|
+
"search_queries" => [query[0, 200]],
|
|
122
|
+
"session_id" => parallel_logical_session_id
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if result["isError"]
|
|
127
|
+
message = extract_parallel_content_text(result)
|
|
128
|
+
raise(message.empty? ? "Parallel search failed" : message)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
payload = result["structuredContent"]
|
|
132
|
+
payload ||= JSON.parse(extract_parallel_content_text(result))
|
|
133
|
+
raise "Parallel returned malformed search results" unless payload.is_a?(Hash) && payload["results"].is_a?(Array)
|
|
134
|
+
|
|
135
|
+
payload["results"].filter_map do |item|
|
|
136
|
+
next unless item.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
url = item["url"].to_s
|
|
139
|
+
next if url.empty?
|
|
140
|
+
|
|
141
|
+
snippet = Array(item["excerpts"]).join(" ").gsub(/\s+/, " ").strip
|
|
142
|
+
{
|
|
143
|
+
title: item["title"].to_s,
|
|
144
|
+
url: url,
|
|
145
|
+
snippet: snippet[0, PARALLEL_SNIPPET_MAX_CHARS]
|
|
146
|
+
}
|
|
147
|
+
end.first(max_results)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
raise "Parallel returned malformed JSON"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private def parallel_client
|
|
153
|
+
@parallel_client ||= Clacky::Mcp::Client.from_spec(
|
|
154
|
+
"parallel-free",
|
|
155
|
+
{ "type" => "http", "url" => PARALLEL_ENDPOINT }
|
|
156
|
+
).tap(&:start)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private def reset_parallel_client
|
|
160
|
+
@parallel_client&.stop
|
|
161
|
+
@parallel_client = nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private def parallel_logical_session_id
|
|
165
|
+
@parallel_logical_session_id ||= SecureRandom.uuid
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private def extract_parallel_content_text(result)
|
|
169
|
+
Array(result["content"]).filter_map do |item|
|
|
170
|
+
item["text"] if item.is_a?(Hash) && item["type"] == "text"
|
|
171
|
+
end.join("\n")
|
|
172
|
+
end
|
|
173
|
+
|
|
103
174
|
# ── Configured searcher (~/.clacky/searchers/<provider>.rb) ────────────
|
|
104
175
|
|
|
105
176
|
private def search_custom(query, max_results)
|
|
@@ -16,7 +16,8 @@ module Clacky
|
|
|
16
16
|
{ command: "/clear", description: "Clear chat history and restart session" },
|
|
17
17
|
{ command: "/config", description: "Open configuration (models, API keys, settings)" },
|
|
18
18
|
{ command: "/model", description: "Quickly switch the current model" },
|
|
19
|
-
{ command: "/
|
|
19
|
+
{ command: "/think", description: "Set the thinking (reasoning) effort level" },
|
|
20
|
+
{ command: "/goal", description: "Set a standing goal for autonomous multi-turn work", argument_hint: "<goal text>" },
|
|
20
21
|
{ command: "/undo", description: "Undo the last task and restore previous state" },
|
|
21
22
|
{ command: "/help", description: "Show help information" },
|
|
22
23
|
{ command: "/exit", description: "Exit the chat session" },
|
|
@@ -62,6 +62,7 @@ module Clacky
|
|
|
62
62
|
working_dir: nil,
|
|
63
63
|
mode: nil,
|
|
64
64
|
model: nil,
|
|
65
|
+
reasoning_effort: nil, # nil = provider default; appended to model name when set
|
|
65
66
|
tasks: 0,
|
|
66
67
|
cost: 0.0,
|
|
67
68
|
cost_source: nil, # nil / :api / :price / :default — :default means pricing unknown, show N/A
|
|
@@ -145,15 +146,18 @@ module Clacky
|
|
|
145
146
|
# @param working_dir [String] Working directory
|
|
146
147
|
# @param mode [String] Permission mode
|
|
147
148
|
# @param model [String] AI model name
|
|
149
|
+
# @param reasoning_effort [String, nil] Thinking level; nil (explicit) appends nothing,
|
|
150
|
+
# :_unset (default) leaves the previous value untouched
|
|
148
151
|
# @param tasks [Integer] Number of completed tasks
|
|
149
152
|
# @param cost [Float] Total cost
|
|
150
153
|
# @param cost_source [Symbol, nil] :api / :price / :default — :default renders as N/A
|
|
151
154
|
# @param status [String] Workspace status ('idle' or 'working')
|
|
152
|
-
def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
155
|
+
def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, reasoning_effort: :_unset, tasks: nil, cost: nil, cost_source: nil, status: nil)
|
|
153
156
|
@sessionbar_info[:session_id] = session_id if session_id
|
|
154
157
|
@sessionbar_info[:working_dir] = working_dir if working_dir
|
|
155
158
|
@sessionbar_info[:mode] = mode if mode
|
|
156
159
|
@sessionbar_info[:model] = model if model
|
|
160
|
+
@sessionbar_info[:reasoning_effort] = reasoning_effort unless reasoning_effort == :_unset
|
|
157
161
|
@sessionbar_info[:tasks] = tasks if tasks
|
|
158
162
|
@sessionbar_info[:cost] = cost if cost
|
|
159
163
|
@sessionbar_info[:cost_source] = cost_source if cost_source
|
|
@@ -184,10 +188,23 @@ module Clacky
|
|
|
184
188
|
@command_suggestions.select_next
|
|
185
189
|
return { action: nil }
|
|
186
190
|
when :enter
|
|
187
|
-
# Accept selected command
|
|
191
|
+
# Accept selected command; submit immediately — unless the
|
|
192
|
+
# command takes arguments (argument_hint), in which case only
|
|
193
|
+
# complete the name so the user can type the arguments first
|
|
194
|
+
# (same behavior as Tab).
|
|
188
195
|
if @command_suggestions.has_suggestions?
|
|
189
196
|
selected = @command_suggestions.selected_command_text
|
|
190
197
|
if selected
|
|
198
|
+
hint = @command_suggestions.selected_argument_hint
|
|
199
|
+
if hint && !hint.empty?
|
|
200
|
+
completed = "#{selected} "
|
|
201
|
+
@lines = [completed]
|
|
202
|
+
@line_index = 0
|
|
203
|
+
@cursor_position = completed.length
|
|
204
|
+
@command_suggestions.hide
|
|
205
|
+
set_tips("Usage: #{selected} #{hint}", type: :info)
|
|
206
|
+
return { action: nil }
|
|
207
|
+
end
|
|
191
208
|
# Replace current input with selected command
|
|
192
209
|
@lines = [selected]
|
|
193
210
|
@line_index = 0
|
|
@@ -1187,6 +1204,9 @@ module Clacky
|
|
|
1187
1204
|
parts << theme.format_text(@sessionbar_info[:model], :statusbar_secondary)
|
|
1188
1205
|
end
|
|
1189
1206
|
|
|
1207
|
+
# Thinking level (nil renders as off = provider default)
|
|
1208
|
+
parts << theme.format_text((@sessionbar_info[:reasoning_effort] || 'off').to_s, :statusbar_secondary)
|
|
1209
|
+
|
|
1190
1210
|
# Tasks count
|
|
1191
1211
|
parts << theme.format_text("#{@sessionbar_info[:tasks]} tasks", :statusbar_secondary)
|
|
1192
1212
|
|
|
@@ -59,8 +59,18 @@ module Clacky
|
|
|
59
59
|
|
|
60
60
|
# Adjust height based on mode
|
|
61
61
|
if @mode == :menu
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
# Choices marked sticky: true (e.g. /config action buttons) are
|
|
63
|
+
# always rendered below the scrollable area, so they never scroll
|
|
64
|
+
# out of view no matter how many models precede them.
|
|
65
|
+
@scroll_count = @choices.count { |c| !c[:sticky] }
|
|
66
|
+
@sticky_count = @choices.length - @scroll_count
|
|
67
|
+
@visible_items = [@scroll_count, 15].min
|
|
68
|
+
@height = @visible_items + @sticky_count + 4 # +4 for title, borders, and instructions
|
|
69
|
+
# Scroll window: draw_menu_choices only renders this slice, so a
|
|
70
|
+
# menu with more choices than fit (e.g. /config with many models)
|
|
71
|
+
# scrolls instead of painting over the bottom border.
|
|
72
|
+
@window_start = 0
|
|
73
|
+
clamp_scroll_window
|
|
64
74
|
else
|
|
65
75
|
# Form mode - adjust height based on number of fields
|
|
66
76
|
# Each field takes 2 rows (label + input)
|
|
@@ -369,11 +379,17 @@ module Clacky
|
|
|
369
379
|
end
|
|
370
380
|
end
|
|
371
381
|
|
|
372
|
-
# Draw menu choices
|
|
382
|
+
# Draw menu choices (only the visible scroll window, then sticky tail)
|
|
373
383
|
private def draw_menu_choices(start_row, start_col)
|
|
374
|
-
@
|
|
375
|
-
|
|
376
|
-
draw_menu_choice(
|
|
384
|
+
window_end = [@window_start + @visible_items, @scroll_count].min
|
|
385
|
+
(@window_start...window_end).each do |index|
|
|
386
|
+
draw_menu_choice(@choices[index], index, start_row + index - @window_start, start_col)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
sticky_row = start_row + (window_end - @window_start)
|
|
390
|
+
(@scroll_count...@choices.length).each do |index|
|
|
391
|
+
draw_menu_choice(@choices[index], index, sticky_row, start_col)
|
|
392
|
+
sticky_row += 1
|
|
377
393
|
end
|
|
378
394
|
end
|
|
379
395
|
|
|
@@ -438,6 +454,19 @@ module Clacky
|
|
|
438
454
|
# Skip disabled choices
|
|
439
455
|
break unless @choices[@selected_index][:disabled]
|
|
440
456
|
end
|
|
457
|
+
clamp_scroll_window
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Keep the cursor inside the visible window, scrolling when needed.
|
|
461
|
+
# Sticky choices are always on screen, so they never move the window.
|
|
462
|
+
private def clamp_scroll_window
|
|
463
|
+
return if @selected_index >= @scroll_count
|
|
464
|
+
|
|
465
|
+
if @selected_index < @window_start
|
|
466
|
+
@window_start = @selected_index
|
|
467
|
+
elsif @selected_index >= @window_start + @visible_items
|
|
468
|
+
@window_start = @selected_index - @visible_items + 1
|
|
469
|
+
end
|
|
441
470
|
end
|
|
442
471
|
end
|
|
443
472
|
end
|