openclacky 1.5.4 → 1.5.5
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 +31 -0
- data/lib/clacky/agent/message_compressor_helper.rb +4 -1
- data/lib/clacky/agent/session_serializer.rb +1 -0
- data/lib/clacky/agent.rb +1 -1
- data/lib/clacky/agent_config.rb +4 -2
- data/lib/clacky/agent_profile.rb +25 -3
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +42 -33
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +4 -2
- data/lib/clacky/default_extensions/git/ext.yml +3 -0
- data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
- data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
- data/lib/clacky/default_skills/new/SKILL.md +17 -12
- data/lib/clacky/extension/api_extension.rb +2 -0
- data/lib/clacky/extension/loader.rb +1 -0
- data/lib/clacky/extension/packager.rb +60 -15
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/message_format/anthropic.rb +1 -1
- data/lib/clacky/message_format/bedrock.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +8 -0
- data/lib/clacky/providers.rb +61 -0
- data/lib/clacky/server/http_server.rb +154 -15
- data/lib/clacky/server/web_ui_controller.rb +12 -2
- data/lib/clacky/session_manager.rb +1 -0
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +301 -171
- data/lib/clacky/web/app.js +16 -2
- data/lib/clacky/web/features/extensions/store.js +214 -9
- data/lib/clacky/web/features/extensions/view.js +156 -11
- data/lib/clacky/web/features/new-session/store.js +25 -3
- data/lib/clacky/web/features/new-session/view.js +337 -140
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/i18n.js +29 -11
- data/lib/clacky/web/index.html +36 -64
- data/lib/clacky/web/sessions.js +2 -1
- data/lib/clacky/web/utils.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +39 -3
- metadata +1 -1
data/lib/clacky/providers.rb
CHANGED
|
@@ -477,6 +477,67 @@ module Clacky
|
|
|
477
477
|
"website_url" => "https://console.volcengine.com/ark/region:cn-beijing/overview"
|
|
478
478
|
}.freeze,
|
|
479
479
|
|
|
480
|
+
"ollama" => {
|
|
481
|
+
"name" => "Ollama (Cloud)",
|
|
482
|
+
# Ollama Cloud API — OpenAI-compatible endpoint serving both local and
|
|
483
|
+
# cloud models. Cloud models are available immediately without pulling.
|
|
484
|
+
# Authentication: API key via Authorization: Bearer <key> header.
|
|
485
|
+
# Create API keys at: https://ollama.com/settings/keys
|
|
486
|
+
"base_url" => "https://ollama.com",
|
|
487
|
+
"api" => "openai-completions",
|
|
488
|
+
"default_model" => "deepseek-v4-flash",
|
|
489
|
+
# Curated list of cloud-enabled models from the Ollama library.
|
|
490
|
+
# Users can type any Ollama model name manually; this list seeds the picker.
|
|
491
|
+
"models" => [
|
|
492
|
+
"glm-5.2",
|
|
493
|
+
"kimi-k3",
|
|
494
|
+
"gemma4",
|
|
495
|
+
"qwen3.5",
|
|
496
|
+
"glm-5.1",
|
|
497
|
+
"minimax-m2.7",
|
|
498
|
+
"nemotron-3-super",
|
|
499
|
+
"minimax-m3",
|
|
500
|
+
"kimi-k2.7-code",
|
|
501
|
+
"kimi-k2.6",
|
|
502
|
+
"deepseek-v4-flash",
|
|
503
|
+
"deepseek-v4-pro",
|
|
504
|
+
"nemotron-3-ultra",
|
|
505
|
+
"gemini-3-flash-preview",
|
|
506
|
+
"nemotron-3-nano",
|
|
507
|
+
"mistral-large-3",
|
|
508
|
+
"gpt-oss"
|
|
509
|
+
],
|
|
510
|
+
# Provider-level default: many Ollama cloud models are vision-capable.
|
|
511
|
+
"capabilities" => { "vision" => true }.freeze,
|
|
512
|
+
# Model-level overrides: text-only models that don't accept image input.
|
|
513
|
+
"model_capabilities" => {
|
|
514
|
+
"glm-5.2" => { "vision" => false }.freeze,
|
|
515
|
+
"glm-5.1" => { "vision" => false }.freeze,
|
|
516
|
+
"minimax-m2.7" => { "vision" => false }.freeze,
|
|
517
|
+
"deepseek-v4-pro" => { "vision" => false }.freeze,
|
|
518
|
+
"deepseek-v4-flash" => { "vision" => false }.freeze,
|
|
519
|
+
"nemotron-3-super" => { "vision" => false }.freeze,
|
|
520
|
+
"nemotron-3-ultra" => { "vision" => false }.freeze,
|
|
521
|
+
"nemotron-3-nano" => { "vision" => false }.freeze,
|
|
522
|
+
"gpt-oss" => { "vision" => false }.freeze
|
|
523
|
+
}.freeze,
|
|
524
|
+
# Per-primary lite pairing: subagents use smaller models for cheap/fast work.
|
|
525
|
+
"lite_models" => {
|
|
526
|
+
"glm-5.2" => "glm-5.1",
|
|
527
|
+
"kimi-k3" => "kimi-k2.6",
|
|
528
|
+
"gemma4" => "nemotron-3-nano",
|
|
529
|
+
"qwen3.5" => "gemini-3-flash-preview",
|
|
530
|
+
"minimax-m3" => "minimax-m2.7",
|
|
531
|
+
"kimi-k2.7-code" => "kimi-k2.6",
|
|
532
|
+
"deepseek-v4-pro" => "deepseek-v4-flash",
|
|
533
|
+
"mistral-large-3" => "nemotron-3-nano",
|
|
534
|
+
"gpt-oss" => "nemotron-3-nano",
|
|
535
|
+
"gemini-3-flash-preview" => "nemotron-3-nano"
|
|
536
|
+
},
|
|
537
|
+
"default_ocr_model" => "kimi-k2.7-code",
|
|
538
|
+
"website_url" => "https://ollama.com/settings/keys"
|
|
539
|
+
}.freeze,
|
|
540
|
+
|
|
480
541
|
"openai" => {
|
|
481
542
|
"name" => "OpenAI (GPT)",
|
|
482
543
|
"base_url" => "https://api.openai.com/v1",
|
|
@@ -217,6 +217,9 @@ module Clacky
|
|
|
217
217
|
# Lazy: process-wide MCP registry. Created on first /api/mcp/:name access
|
|
218
218
|
# so test setups that override Dir.home in before-hooks still work.
|
|
219
219
|
@mcp_registry_mutex = Mutex.new
|
|
220
|
+
# Install jobs: job_id => { stage:, progress:, error:, done: }
|
|
221
|
+
@install_jobs = {}
|
|
222
|
+
@install_jobs_mutex = Mutex.new
|
|
220
223
|
# Access key authentication:
|
|
221
224
|
# - localhost (127.0.0.1 / ::1) is always trusted; auth is skipped entirely.
|
|
222
225
|
# - Any other bind address requires CLACKY_ACCESS_KEY env var.
|
|
@@ -370,10 +373,13 @@ module Clacky
|
|
|
370
373
|
|
|
371
374
|
server.mount_proc("/") do |req, res|
|
|
372
375
|
if req.path == "/" || req.path == "/index.html"
|
|
373
|
-
|
|
376
|
+
brand = Clacky::BrandConfig.load
|
|
377
|
+
product_name = brand.product_name || "OpenClacky"
|
|
378
|
+
logo_url = brand.logo_url || "/logo_nav_dark.png"
|
|
374
379
|
pure = req.query["pure"] == "true"
|
|
375
380
|
html = File.read(index_html_path)
|
|
376
381
|
.gsub("{{BRAND_NAME}}", product_name)
|
|
382
|
+
.gsub("{{BRAND_LOGO_URL}}", logo_url)
|
|
377
383
|
.gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
|
|
378
384
|
res.status = 200
|
|
379
385
|
res["Content-Type"] = "text/html; charset=utf-8"
|
|
@@ -493,7 +499,9 @@ module Clacky
|
|
|
493
499
|
# Building/extracting a tar.gz of ~/.clacky can take a while.
|
|
494
500
|
120
|
|
495
501
|
elsif path == "/api/store/extension/install"
|
|
496
|
-
|
|
502
|
+
10 # now async — just spawns a thread and returns job_id immediately
|
|
503
|
+
elsif path == "/api/store/extension/install/status"
|
|
504
|
+
10
|
|
497
505
|
else
|
|
498
506
|
30
|
|
499
507
|
end
|
|
@@ -560,9 +568,11 @@ module Clacky
|
|
|
560
568
|
when ["GET", "/api/store/skills"] then api_store_skills(res)
|
|
561
569
|
when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
|
|
562
570
|
when ["GET", "/api/store/extensions/brand"] then api_store_extensions_brand(res)
|
|
571
|
+
when ["GET", "/api/store/extensions/system"] then api_store_extensions_system(res)
|
|
563
572
|
when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
|
|
564
573
|
when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
|
|
565
574
|
when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
|
|
575
|
+
when ["GET", "/api/store/extension/install/status"] then api_store_extension_install_status(req, res)
|
|
566
576
|
when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
|
|
567
577
|
when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
|
|
568
578
|
when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
|
|
@@ -2527,6 +2537,47 @@ module Clacky
|
|
|
2527
2537
|
end
|
|
2528
2538
|
end
|
|
2529
2539
|
|
|
2540
|
+
# GET /api/store/extensions/system
|
|
2541
|
+
#
|
|
2542
|
+
# Returns only the builtin (system) extensions shipped with the gem.
|
|
2543
|
+
def api_store_extensions_system(res)
|
|
2544
|
+
result = Clacky::ExtensionLoader.load_all
|
|
2545
|
+
disabled = Clacky::ExtensionLoader.disabled_ids
|
|
2546
|
+
|
|
2547
|
+
hidden = %w[coding general ext-studio].to_set
|
|
2548
|
+
|
|
2549
|
+
extensions = Array(result&.containers).filter_map do |ext_id, container|
|
|
2550
|
+
next unless container[:layer] == :builtin
|
|
2551
|
+
next if hidden.include?(ext_id)
|
|
2552
|
+
|
|
2553
|
+
local_name = container[:name].to_s.then { |n| n.empty? ? ext_id : n }
|
|
2554
|
+
local_desc = container.dig(:raw, "description").to_s
|
|
2555
|
+
local_desc_zh = container.dig(:raw, "description_zh").to_s
|
|
2556
|
+
local_author = container[:author].to_s
|
|
2557
|
+
local_units = units_from_container(container)
|
|
2558
|
+
{
|
|
2559
|
+
"id" => ext_id,
|
|
2560
|
+
"name" => local_name,
|
|
2561
|
+
"display_name" => container.dig(:raw, "display_name") || local_name,
|
|
2562
|
+
"display_name_zh" => container.dig(:raw, "display_name_zh"),
|
|
2563
|
+
"slug" => ext_id,
|
|
2564
|
+
"version" => container[:version],
|
|
2565
|
+
"description" => local_desc,
|
|
2566
|
+
"description_zh" => local_desc_zh,
|
|
2567
|
+
"author" => local_author,
|
|
2568
|
+
"units" => local_units,
|
|
2569
|
+
"layer" => "builtin",
|
|
2570
|
+
"installed" => true,
|
|
2571
|
+
"removable" => false,
|
|
2572
|
+
"disabled" => disabled.include?(ext_id),
|
|
2573
|
+
}
|
|
2574
|
+
end
|
|
2575
|
+
|
|
2576
|
+
json_response(res, 200, { ok: true, extensions: extensions })
|
|
2577
|
+
rescue StandardError => e
|
|
2578
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2579
|
+
end
|
|
2580
|
+
|
|
2530
2581
|
# GET /api/store/extensions/installed
|
|
2531
2582
|
#
|
|
2532
2583
|
# Returns all locally installed extensions (all layers: builtin, installed,
|
|
@@ -2656,6 +2707,7 @@ module Clacky
|
|
|
2656
2707
|
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2657
2708
|
container = extension_container(slug)
|
|
2658
2709
|
ext = ext.merge(
|
|
2710
|
+
"slug" => slug,
|
|
2659
2711
|
"installed" => !container.nil?,
|
|
2660
2712
|
"installed_version" => container&.dig(:version),
|
|
2661
2713
|
"removable" => container && container[:layer] == :installed,
|
|
@@ -2732,7 +2784,9 @@ module Clacky
|
|
|
2732
2784
|
json_response(res, 500, { ok: false, error: e.message })
|
|
2733
2785
|
end
|
|
2734
2786
|
|
|
2735
|
-
#
|
|
2787
|
+
# POST /api/store/extension/install body: { download_url:, name: }
|
|
2788
|
+
# Starts an async install job and returns immediately with a job_id.
|
|
2789
|
+
# Poll GET /api/store/extension/install/status?job_id=xxx for progress.
|
|
2736
2790
|
def api_store_extension_install(req, res)
|
|
2737
2791
|
body = parse_json_body(req)
|
|
2738
2792
|
download_url = body["download_url"].to_s.strip
|
|
@@ -2743,14 +2797,71 @@ module Clacky
|
|
|
2743
2797
|
return
|
|
2744
2798
|
end
|
|
2745
2799
|
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2800
|
+
job_id = SecureRandom.hex(8)
|
|
2801
|
+
@install_jobs_mutex.synchronize do
|
|
2802
|
+
sweep_stale_install_jobs!
|
|
2803
|
+
@install_jobs[job_id] = { stage: "downloading", progress: 0, error: nil, done: false, updated_at: Time.now }
|
|
2804
|
+
end
|
|
2805
|
+
|
|
2806
|
+
Thread.new do
|
|
2807
|
+
begin
|
|
2808
|
+
on_progress = lambda do |info|
|
|
2809
|
+
@install_jobs_mutex.synchronize do
|
|
2810
|
+
@install_jobs[job_id] = info.merge(error: nil, done: false, updated_at: Time.now)
|
|
2811
|
+
end
|
|
2812
|
+
end
|
|
2813
|
+
Clacky::ExtensionPackager.install(download_url, force: true, on_progress: on_progress)
|
|
2814
|
+
Clacky::ExtensionLoader.invalidate_cache!
|
|
2815
|
+
Clacky::Telemetry.extension_install!(name) unless name.empty?
|
|
2816
|
+
@install_jobs_mutex.synchronize do
|
|
2817
|
+
@install_jobs[job_id] = { stage: "done", progress: 100, error: nil, done: true, updated_at: Time.now }
|
|
2818
|
+
end
|
|
2819
|
+
rescue StandardError => e
|
|
2820
|
+
@install_jobs_mutex.synchronize do
|
|
2821
|
+
@install_jobs[job_id] = { stage: "error", progress: 0, error: e.message, done: true, updated_at: Time.now }
|
|
2822
|
+
end
|
|
2823
|
+
end
|
|
2824
|
+
end
|
|
2825
|
+
|
|
2826
|
+
json_response(res, 200, { ok: true, job_id: job_id })
|
|
2827
|
+
end
|
|
2828
|
+
|
|
2829
|
+
# GET /api/store/extension/install/status?job_id=xxx
|
|
2830
|
+
def api_store_extension_install_status(req, res)
|
|
2831
|
+
job_id = req.query["job_id"].to_s.strip
|
|
2832
|
+
if job_id.empty?
|
|
2833
|
+
json_response(res, 400, { ok: false, error: "Missing job_id." })
|
|
2834
|
+
return
|
|
2835
|
+
end
|
|
2836
|
+
job = @install_jobs_mutex.synchronize do
|
|
2837
|
+
sweep_stale_install_jobs!
|
|
2838
|
+
@install_jobs[job_id]
|
|
2839
|
+
end
|
|
2840
|
+
if job.nil?
|
|
2841
|
+
json_response(res, 404, { ok: false, error: "Job not found." })
|
|
2842
|
+
return
|
|
2843
|
+
end
|
|
2844
|
+
# Clean up finished jobs after they are read as done
|
|
2845
|
+
if job[:done]
|
|
2846
|
+
@install_jobs_mutex.synchronize { @install_jobs.delete(job_id) }
|
|
2847
|
+
end
|
|
2848
|
+
json_response(res, 200, { ok: true }.merge(job))
|
|
2849
|
+
end
|
|
2850
|
+
|
|
2851
|
+
# Install jobs can go stale in two ways:
|
|
2852
|
+
# - the client abandons the poll (tab closed, network drop) so a
|
|
2853
|
+
# finished job never gets picked up and deleted by the status endpoint
|
|
2854
|
+
# - the worker thread dies without ever updating the job hash (should
|
|
2855
|
+
# not happen given the rescue in api_store_extension_install, but a
|
|
2856
|
+
# hung Thread with no timeout upstream would otherwise pin memory
|
|
2857
|
+
# forever)
|
|
2858
|
+
# Sweep anything past its TTL. Must be called from inside
|
|
2859
|
+
# @install_jobs_mutex.synchronize.
|
|
2860
|
+
INSTALL_JOB_TTL = 30 * 60 # seconds
|
|
2861
|
+
|
|
2862
|
+
private def sweep_stale_install_jobs!
|
|
2863
|
+
cutoff = Time.now - INSTALL_JOB_TTL
|
|
2864
|
+
@install_jobs.delete_if { |_id, job| (job[:updated_at] || Time.at(0)) < cutoff }
|
|
2754
2865
|
end
|
|
2755
2866
|
|
|
2756
2867
|
def api_store_extension_uninstall(req, res)
|
|
@@ -4354,12 +4465,30 @@ module Clacky
|
|
|
4354
4465
|
# Each entry carries { id, title, title_zh, description, description_zh,
|
|
4355
4466
|
# source, order, layer, author }. Extensions can supply title_zh /
|
|
4356
4467
|
# description_zh / author in their ext.yml so the New Session cards read
|
|
4357
|
-
# naturally and credit their author.
|
|
4468
|
+
# naturally and credit their author. Third-party extension agents are
|
|
4469
|
+
# ordered by most-recently-used (derived from session history) so the
|
|
4470
|
+
# ones the user actually uses float to the front of that group.
|
|
4358
4471
|
def api_list_agents(res)
|
|
4359
|
-
|
|
4472
|
+
recency = agent_last_used_recency
|
|
4473
|
+
agents = Clacky::AgentProfile.all(recency: recency)
|
|
4360
4474
|
json_response(res, 200, { agents: agents })
|
|
4361
4475
|
end
|
|
4362
4476
|
|
|
4477
|
+
# Map agent_profile id => most recent session's updated_at (epoch seconds),
|
|
4478
|
+
# scanning every session on disk. Used to rank extension agent cards by
|
|
4479
|
+
# recent usage instead of a static declared `order`.
|
|
4480
|
+
private def agent_last_used_recency
|
|
4481
|
+
recency = {}
|
|
4482
|
+
@session_manager.all_sessions.each do |s|
|
|
4483
|
+
id = (s[:agent_profile] || "general").to_s
|
|
4484
|
+
ts = s[:updated_at] || s[:created_at]
|
|
4485
|
+
next unless ts
|
|
4486
|
+
epoch = Time.parse(ts.to_s).to_i rescue next
|
|
4487
|
+
recency[id] = epoch if epoch > (recency[id] || 0)
|
|
4488
|
+
end
|
|
4489
|
+
recency
|
|
4490
|
+
end
|
|
4491
|
+
|
|
4363
4492
|
# GET /api/sessions/:id/skills — list user-invocable skills for a session,
|
|
4364
4493
|
# filtered by the session's agent profile. Used by the frontend slash-command
|
|
4365
4494
|
# autocomplete so only skills valid for the current profile are suggested.
|
|
@@ -5667,6 +5796,7 @@ module Clacky
|
|
|
5667
5796
|
json_response(res, 200, {
|
|
5668
5797
|
ok: true,
|
|
5669
5798
|
enable_compression: @agent_config.enable_compression,
|
|
5799
|
+
enable_idle_compression: @agent_config.enable_idle_compression,
|
|
5670
5800
|
enable_prompt_caching: @agent_config.enable_prompt_caching,
|
|
5671
5801
|
memory_update_enabled: @agent_config.memory_update_enabled,
|
|
5672
5802
|
proxy_url: @agent_config.proxy_url.to_s,
|
|
@@ -5692,6 +5822,9 @@ module Clacky
|
|
|
5692
5822
|
if body.key?("enable_compression")
|
|
5693
5823
|
@agent_config.enable_compression = !!body["enable_compression"]
|
|
5694
5824
|
end
|
|
5825
|
+
if body.key?("enable_idle_compression")
|
|
5826
|
+
@agent_config.enable_idle_compression = !!body["enable_idle_compression"]
|
|
5827
|
+
end
|
|
5695
5828
|
if body.key?("enable_prompt_caching")
|
|
5696
5829
|
@agent_config.enable_prompt_caching = !!body["enable_prompt_caching"]
|
|
5697
5830
|
end
|
|
@@ -6264,7 +6397,7 @@ module Clacky
|
|
|
6264
6397
|
end
|
|
6265
6398
|
|
|
6266
6399
|
# PATCH /api/sessions/:id/reasoning_effort
|
|
6267
|
-
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" }
|
|
6400
|
+
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" | "max" }
|
|
6268
6401
|
def api_switch_session_reasoning_effort(session_id, req, res)
|
|
6269
6402
|
body = parse_json_body(req)
|
|
6270
6403
|
raw = body["reasoning_effort"]
|
|
@@ -6766,7 +6899,13 @@ module Clacky
|
|
|
6766
6899
|
msg_created_at = Time.now.to_f
|
|
6767
6900
|
web_ui = nil
|
|
6768
6901
|
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
6769
|
-
|
|
6902
|
+
# Pass the uploaded files through to the realtime broadcast so images and
|
|
6903
|
+
# attachments render in the bubble immediately. agent.run later appends the
|
|
6904
|
+
# authoritative display_files to history — but only after vision/OCR
|
|
6905
|
+
# processing finishes, which can take seconds. Without the preview here, a
|
|
6906
|
+
# page refresh inside that window shows the message without its image (the
|
|
6907
|
+
# "need to refresh several times before the image appears" bug).
|
|
6908
|
+
web_ui&.show_user_message(content, created_at: msg_created_at, source: :web, files: Array(files))
|
|
6770
6909
|
|
|
6771
6910
|
# File references are now handled inside agent.run — injected as a system_injected
|
|
6772
6911
|
# message after the user message, so replay_history skips them automatically.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
3
4
|
require "json"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
require_relative "../ui_interface"
|
|
@@ -70,11 +71,20 @@ module Clacky
|
|
|
70
71
|
data[:created_at] = created_at if created_at
|
|
71
72
|
# Build ev.images for the frontend renderer (history_user_message):
|
|
72
73
|
# - Images with data_url → pass the data_url directly (<img> thumbnail)
|
|
73
|
-
# - Disk files (
|
|
74
|
+
# - Disk image files (type=="image", has path) → /api/local-image proxy URL
|
|
75
|
+
# - Disk files (PDF, doc, etc., no data_url, no image path) → "pdf:name" sentinel
|
|
74
76
|
rendered = Array(files).filter_map do |f|
|
|
75
77
|
url = f[:data_url] || f["data_url"]
|
|
76
78
|
name = f[:name] || f["name"]
|
|
77
|
-
|
|
79
|
+
type = f[:type] || f["type"]
|
|
80
|
+
path = f[:path] || f["path"]
|
|
81
|
+
if url
|
|
82
|
+
url
|
|
83
|
+
elsif type.to_s == "image" && path && File.exist?(path.to_s)
|
|
84
|
+
"/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
85
|
+
elsif name
|
|
86
|
+
"pdf:#{name}"
|
|
87
|
+
end
|
|
78
88
|
end
|
|
79
89
|
data[:images] = rendered unless rendered.empty?
|
|
80
90
|
emit("history_user_message", **data)
|
|
@@ -36,6 +36,7 @@ module Clacky
|
|
|
36
36
|
# Keep only the most recent 200 sessions (best-effort, never block save)
|
|
37
37
|
begin
|
|
38
38
|
cleanup_by_count(keep: 200, keep_cron: 200)
|
|
39
|
+
cleanup_trash(days: 8)
|
|
39
40
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
40
41
|
# Cleanup is non-critical; swallow all errors (including AgentInterrupted)
|
|
41
42
|
end
|
|
@@ -480,9 +480,7 @@ module Clacky
|
|
|
480
480
|
# leaves the result as invalid UTF-8. Re-scrub after truncation so
|
|
481
481
|
# everything downstream (JSON.generate, format_result, UI) gets a
|
|
482
482
|
# guaranteed-valid UTF-8 string.
|
|
483
|
-
preview = cleaned
|
|
484
|
-
preview.force_encoding(Encoding::UTF_8)
|
|
485
|
-
preview = preview.scrub("?") unless preview.valid_encoding?
|
|
483
|
+
preview = smart_truncate(cleaned, OVERFLOW_PREVIEW_CHARS)
|
|
486
484
|
|
|
487
485
|
notice = if overflow_file
|
|
488
486
|
"\n\n...[Output truncated for LLM: showing first #{OVERFLOW_PREVIEW_CHARS} " \
|
|
@@ -1429,6 +1427,35 @@ module Clacky
|
|
|
1429
1427
|
nil
|
|
1430
1428
|
end
|
|
1431
1429
|
|
|
1430
|
+
# Smart truncation: keep head + tail within char budget.
|
|
1431
|
+
# Ensures error messages at end of output remain visible.
|
|
1432
|
+
private def smart_truncate(text, max_chars)
|
|
1433
|
+
total_chars = text.bytesize
|
|
1434
|
+
return text if total_chars <= max_chars
|
|
1435
|
+
|
|
1436
|
+
total_lines = text.lines.count
|
|
1437
|
+
|
|
1438
|
+
# 60% head, 40% tail
|
|
1439
|
+
head_budget = (max_chars * 0.6).to_i
|
|
1440
|
+
tail_budget = max_chars - head_budget
|
|
1441
|
+
|
|
1442
|
+
head = text.byteslice(0, head_budget)
|
|
1443
|
+
head.force_encoding(Encoding::UTF_8)
|
|
1444
|
+
head = head.scrub("?") unless head.valid_encoding?
|
|
1445
|
+
|
|
1446
|
+
tail = text.byteslice(-tail_budget, tail_budget)
|
|
1447
|
+
tail.force_encoding(Encoding::UTF_8)
|
|
1448
|
+
tail = tail.scrub("?") unless tail.valid_encoding?
|
|
1449
|
+
|
|
1450
|
+
# Align tail to line boundary if cut mid-line
|
|
1451
|
+
if tail.include?("\n") && !tail.start_with?("\n")
|
|
1452
|
+
first_nl = tail.index("\n")
|
|
1453
|
+
tail = tail[(first_nl + 1)..-1] || ""
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
"#{head}\n\n...[#{total_lines} lines, #{total_chars} chars total, truncated]...\n\n#{tail}"
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1432
1459
|
|
|
1433
1460
|
|
|
1434
1461
|
# Max visible length of a command inside the tool-call summary line.
|
data/lib/clacky/version.rb
CHANGED