openclacky 1.0.2 → 1.0.4
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/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +325 -0
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +89 -0
- data/lib/clacky/agent/cost_tracker.rb +8 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/memory_updater.rb +41 -30
- data/lib/clacky/agent/message_compressor.rb +15 -4
- data/lib/clacky/agent/message_compressor_helper.rb +41 -2
- data/lib/clacky/agent/skill_manager.rb +5 -2
- data/lib/clacky/agent/skill_reflector.rb +10 -1
- data/lib/clacky/agent/tool_registry.rb +109 -0
- data/lib/clacky/agent.rb +20 -0
- data/lib/clacky/agent_config.rb +17 -0
- data/lib/clacky/cli.rb +65 -0
- data/lib/clacky/client.rb +15 -0
- data/lib/clacky/default_agents/base_prompt.md +20 -20
- data/lib/clacky/default_agents/coding/system_prompt.md +51 -1
- data/lib/clacky/default_skills/channel-setup/SKILL.md +113 -5
- data/lib/clacky/default_skills/channel-setup/import_lark_skills.rb +97 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/clacky/providers.rb +48 -6
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
- data/lib/clacky/server/channel/channel_manager.rb +91 -0
- data/lib/clacky/server/discover.rb +77 -0
- data/lib/clacky/server/epipe_safe_io.rb +105 -0
- data/lib/clacky/server/http_server.rb +121 -41
- data/lib/clacky/server/server_master.rb +6 -0
- data/lib/clacky/skill.rb +30 -0
- data/lib/clacky/utils/file_processor.rb +71 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +58 -22
- data/lib/clacky/web/i18n.js +4 -2
- data/lib/clacky/web/sessions.js +29 -17
- metadata +33 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Server
|
|
7
|
+
# EPIPESafeIO — wraps an IO ($stdout / $stderr) so that writes never raise
|
|
8
|
+
# Errno::EPIPE / IOError to the calling code.
|
|
9
|
+
#
|
|
10
|
+
# Why this exists:
|
|
11
|
+
# The server worker process inherits fd 0/1/2 from the Master. If the
|
|
12
|
+
# Master itself was launched in a way where its stdout/stderr eventually
|
|
13
|
+
# becomes a broken pipe (e.g. launched by an installer that exits, or by
|
|
14
|
+
# a GUI/IDE process that closes its end), the worker's first `puts` after
|
|
15
|
+
# that pipe breaks raises Errno::EPIPE. Unhandled, this kills the worker
|
|
16
|
+
# — taking all in-memory sessions, agent loops, and SSE connections down
|
|
17
|
+
# with it, and triggering a crash loop because the new worker inherits
|
|
18
|
+
# the same broken fd.
|
|
19
|
+
#
|
|
20
|
+
# Behavior:
|
|
21
|
+
# - Healthy state: delegates every method to the underlying IO. Users
|
|
22
|
+
# see normal terminal output (banner, request logs, etc.).
|
|
23
|
+
# - First broken-pipe failure: catches Errno::EPIPE / IOError, swaps
|
|
24
|
+
# the underlying IO to /dev/null permanently, and returns silently.
|
|
25
|
+
# Subsequent writes succeed (into /dev/null) so the worker stays alive.
|
|
26
|
+
# - Session state, agent loops, SSE connections all preserved.
|
|
27
|
+
#
|
|
28
|
+
# Scope:
|
|
29
|
+
# We only wrap $stdout / $stderr (the global variables that Kernel#puts,
|
|
30
|
+
# Kernel#print, Kernel#warn, etc. use under the hood). We do NOT touch
|
|
31
|
+
# the STDOUT / STDERR constants — a codebase audit confirmed nothing in
|
|
32
|
+
# Clacky writes to those constants directly (only `STDOUT.flush` which
|
|
33
|
+
# cannot raise EPIPE).
|
|
34
|
+
class EPIPESafeIO < SimpleDelegator
|
|
35
|
+
# Methods that perform writes and may raise Errno::EPIPE.
|
|
36
|
+
# We override each one to rescue and degrade gracefully.
|
|
37
|
+
WRITE_METHODS = %i[write write_nonblock syswrite puts print printf << putc].freeze
|
|
38
|
+
|
|
39
|
+
WRITE_METHODS.each do |m|
|
|
40
|
+
define_method(m) do |*args, **kwargs, &blk|
|
|
41
|
+
if kwargs.empty?
|
|
42
|
+
__getobj__.public_send(m, *args, &blk)
|
|
43
|
+
else
|
|
44
|
+
__getobj__.public_send(m, *args, **kwargs, &blk)
|
|
45
|
+
end
|
|
46
|
+
rescue Errno::EPIPE, IOError => e
|
|
47
|
+
fall_back_to_null!(e)
|
|
48
|
+
# Retry the write into /dev/null so semantics (return value type) stay
|
|
49
|
+
# close to what the caller expects. If even this fails, swallow it —
|
|
50
|
+
# we must not raise from inside a write to $stdout/$stderr.
|
|
51
|
+
begin
|
|
52
|
+
if kwargs.empty?
|
|
53
|
+
__getobj__.public_send(m, *args, &blk)
|
|
54
|
+
else
|
|
55
|
+
__getobj__.public_send(m, *args, **kwargs, &blk)
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Some callers do `$stdout.flush`. Make it safe too.
|
|
64
|
+
def flush
|
|
65
|
+
__getobj__.flush
|
|
66
|
+
rescue Errno::EPIPE, IOError => e
|
|
67
|
+
fall_back_to_null!(e)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether this wrapper has already fallen back to /dev/null.
|
|
72
|
+
# Useful for tests and diagnostics.
|
|
73
|
+
def fell_back?
|
|
74
|
+
@fell_back == true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private def fall_back_to_null!(error)
|
|
78
|
+
return if @fell_back
|
|
79
|
+
|
|
80
|
+
@fell_back = true
|
|
81
|
+
begin
|
|
82
|
+
# Best-effort: try to log once via Clacky::Logger if available.
|
|
83
|
+
# Wrapped in rescue because Logger itself might be mid-init.
|
|
84
|
+
if defined?(Clacky::Logger)
|
|
85
|
+
Clacky::Logger.warn(
|
|
86
|
+
"[EPIPESafeIO] Underlying IO broken (#{error.class}: #{error.message}); " \
|
|
87
|
+
"falling back to /dev/null. Worker stays alive."
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError
|
|
91
|
+
# ignore
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
null = File.open(File::NULL, "w")
|
|
96
|
+
null.sync = true
|
|
97
|
+
__setobj__(null)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
# If even opening /dev/null fails, leave the original object — at
|
|
100
|
+
# worst the next write raises again and we rescue again.
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -57,7 +57,9 @@ module Clacky
|
|
|
57
57
|
def show_assistant_message(content, files:)
|
|
58
58
|
return if content.nil? || content.to_s.strip.empty?
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
# Rewrite local image paths to /api/local-image proxy URLs for browser rendering
|
|
61
|
+
rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
62
|
+
@events << { type: "assistant_message", session_id: @session_id, content: rewritten }
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
def show_tool_call(name, args)
|
|
@@ -203,15 +205,6 @@ module Clacky
|
|
|
203
205
|
|
|
204
206
|
Clacky::Logger.info("[HttpServer PID=#{Process.pid}] start() mode=#{@inherited_socket ? 'worker' : 'standalone'} inherited_socket=#{@inherited_socket.inspect} master_pid=#{@master_pid.inspect}")
|
|
205
207
|
|
|
206
|
-
# In standalone mode (no master), kill any stale server and manage our own PID file.
|
|
207
|
-
# In worker mode the master owns the PID file; we just skip this block.
|
|
208
|
-
if @inherited_socket.nil?
|
|
209
|
-
kill_existing_server(@port)
|
|
210
|
-
pid_file = File.join(Dir.tmpdir, "clacky-server-#{@port}.pid")
|
|
211
|
-
File.write(pid_file, Process.pid.to_s)
|
|
212
|
-
at_exit { File.delete(pid_file) if File.exist?(pid_file) }
|
|
213
|
-
end
|
|
214
|
-
|
|
215
208
|
# Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
|
|
216
209
|
# so they can call back into the server without hardcoding the port,
|
|
217
210
|
# and use the correct product name without re-reading brand.yml.
|
|
@@ -406,13 +399,20 @@ module Clacky
|
|
|
406
399
|
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
407
400
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
408
401
|
when ["POST", "/api/open-file"] then api_open_file(req, res)
|
|
402
|
+
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
409
403
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
410
404
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
411
405
|
when ["POST", "/api/restart"] then api_restart(req, res)
|
|
412
406
|
when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
413
407
|
when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
|
|
414
408
|
else
|
|
415
|
-
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/
|
|
409
|
+
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
|
|
410
|
+
platform = path.sub("/api/channels/", "").sub("/send", "")
|
|
411
|
+
api_send_channel_message(platform, req, res)
|
|
412
|
+
elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
|
|
413
|
+
platform = path.sub("/api/channels/", "").sub("/users", "")
|
|
414
|
+
api_list_channel_users(platform, res)
|
|
415
|
+
elsif method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
|
|
416
416
|
platform = path.sub("/api/channels/", "").sub("/test", "")
|
|
417
417
|
api_test_channel(platform, req, res)
|
|
418
418
|
elsif method == "POST" && path.start_with?("/api/channels/")
|
|
@@ -1439,6 +1439,79 @@ module Clacky
|
|
|
1439
1439
|
json_response(res, 200, { channels: platforms })
|
|
1440
1440
|
end
|
|
1441
1441
|
|
|
1442
|
+
# POST /api/channels/:platform/send
|
|
1443
|
+
# Proactively send a message to a user via the given IM platform.
|
|
1444
|
+
#
|
|
1445
|
+
# Body:
|
|
1446
|
+
# { "message": "hello", # required
|
|
1447
|
+
# "user_id": "some_user_id" } # optional — defaults to most-recently active user
|
|
1448
|
+
#
|
|
1449
|
+
# Response:
|
|
1450
|
+
# 200 { ok: true }
|
|
1451
|
+
# 400 { ok: false, error: "..." } — missing/invalid params or platform not running
|
|
1452
|
+
# 503 { ok: false, error: "..." } — no known users (nobody has messaged the bot yet)
|
|
1453
|
+
#
|
|
1454
|
+
# Constraints:
|
|
1455
|
+
# - The platform adapter must be running (channel must be enabled + connected).
|
|
1456
|
+
# - For Weixin (iLink protocol), a context_token is required per message. This is
|
|
1457
|
+
# automatically looked up from the in-memory cache populated by inbound messages.
|
|
1458
|
+
# If no token exists for the target user (i.e. the user has never messaged the bot
|
|
1459
|
+
# in this server session), the message cannot be delivered.
|
|
1460
|
+
def api_send_channel_message(platform, req, res)
|
|
1461
|
+
platform = platform.to_sym
|
|
1462
|
+
body = parse_json_body(req)
|
|
1463
|
+
message = body["message"].to_s.strip
|
|
1464
|
+
|
|
1465
|
+
if message.empty?
|
|
1466
|
+
json_response(res, 400, { ok: false, error: "message is required" })
|
|
1467
|
+
return
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
# Resolve target user_id
|
|
1471
|
+
user_id = body["user_id"].to_s.strip
|
|
1472
|
+
if user_id.empty?
|
|
1473
|
+
# Default to the most-recently active user for this platform
|
|
1474
|
+
known = @channel_manager.known_users(platform)
|
|
1475
|
+
if known.empty?
|
|
1476
|
+
json_response(res, 503, {
|
|
1477
|
+
ok: false,
|
|
1478
|
+
error: "No known users for :#{platform}. The user must send a message to the bot first."
|
|
1479
|
+
})
|
|
1480
|
+
return
|
|
1481
|
+
end
|
|
1482
|
+
user_id = known.last
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
result = @channel_manager.send_to_user(platform, user_id, message)
|
|
1486
|
+
if result.nil?
|
|
1487
|
+
json_response(res, 400, {
|
|
1488
|
+
ok: false,
|
|
1489
|
+
error: "Failed to send message. The :#{platform} adapter may not be running, or no context_token is available for user #{user_id}."
|
|
1490
|
+
})
|
|
1491
|
+
else
|
|
1492
|
+
json_response(res, 200, { ok: true, platform: platform, user_id: user_id })
|
|
1493
|
+
end
|
|
1494
|
+
rescue StandardError => e
|
|
1495
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
# GET /api/channels/:platform/users
|
|
1499
|
+
# Returns the list of known user IDs for the given platform.
|
|
1500
|
+
# These are users who have sent at least one message to the bot in this server session.
|
|
1501
|
+
#
|
|
1502
|
+
# For Weixin: returns users with a cached context_token (required for proactive messaging).
|
|
1503
|
+
# For Feishu / WeCom: returns user IDs extracted from channel session bindings.
|
|
1504
|
+
#
|
|
1505
|
+
# Response:
|
|
1506
|
+
# 200 { users: ["uid1", "uid2", ...] }
|
|
1507
|
+
def api_list_channel_users(platform, res)
|
|
1508
|
+
platform = platform.to_sym
|
|
1509
|
+
users = @channel_manager.known_users(platform)
|
|
1510
|
+
json_response(res, 200, { platform: platform, users: users })
|
|
1511
|
+
rescue StandardError => e
|
|
1512
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1513
|
+
end
|
|
1514
|
+
|
|
1442
1515
|
# POST /api/upload
|
|
1443
1516
|
# Accepts a multipart/form-data file upload (field name: "file").
|
|
1444
1517
|
# Runs the file through FileProcessor: saves original + generates structured
|
|
@@ -1485,6 +1558,43 @@ module Clacky
|
|
|
1485
1558
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1486
1559
|
end
|
|
1487
1560
|
|
|
1561
|
+
# GET /api/local-image?path=file:///path/to/image.png
|
|
1562
|
+
# GET /api/local-image?path=/path/to/image.png
|
|
1563
|
+
#
|
|
1564
|
+
# Serves a local image file with the correct Content-Type.
|
|
1565
|
+
# Used by the Web UI to render local images that would otherwise be blocked
|
|
1566
|
+
# by the browser's security policy (file:// from http:// origin).
|
|
1567
|
+
#
|
|
1568
|
+
def api_serve_local_image(req, res)
|
|
1569
|
+
raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
1570
|
+
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
1571
|
+
|
|
1572
|
+
# Strip file:// prefix if present
|
|
1573
|
+
path = raw_path.sub(%r{\Afile://}, "")
|
|
1574
|
+
path = CGI.unescape(path)
|
|
1575
|
+
path = File.expand_path(path)
|
|
1576
|
+
|
|
1577
|
+
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
1578
|
+
# Convert it to the Linux-side path so File.exist? works.
|
|
1579
|
+
path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
1580
|
+
|
|
1581
|
+
# Security: only serve image files
|
|
1582
|
+
ext = File.extname(path).downcase
|
|
1583
|
+
unless Utils::FileProcessor::LOCAL_IMAGE_EXTENSIONS.include?(ext)
|
|
1584
|
+
return json_response(res, 403, { error: "not an image file" })
|
|
1585
|
+
end
|
|
1586
|
+
|
|
1587
|
+
return json_response(res, 404, { error: "file not found" }) unless File.exist?(path)
|
|
1588
|
+
|
|
1589
|
+
mime = Utils::FileProcessor::MIME_TYPES[ext] || "application/octet-stream"
|
|
1590
|
+
res.status = 200
|
|
1591
|
+
res["Content-Type"] = mime
|
|
1592
|
+
res["Cache-Control"] = "private, max-age=3600"
|
|
1593
|
+
res.body = File.binread(path)
|
|
1594
|
+
rescue => e
|
|
1595
|
+
json_response(res, 500, { error: e.message })
|
|
1596
|
+
end
|
|
1597
|
+
|
|
1488
1598
|
# POST /api/channels/:platform
|
|
1489
1599
|
# Body: { fields... } (platform-specific credential fields)
|
|
1490
1600
|
# Saves credentials and optionally (re)starts the adapter.
|
|
@@ -3648,36 +3758,6 @@ module Clacky
|
|
|
3648
3758
|
res.body = "Not Found"
|
|
3649
3759
|
end
|
|
3650
3760
|
|
|
3651
|
-
# Stop any previously running server on the given port via its PID file.
|
|
3652
|
-
private def kill_existing_server(port)
|
|
3653
|
-
pid_file = File.join(Dir.tmpdir, "clacky-server-#{port}.pid")
|
|
3654
|
-
return unless File.exist?(pid_file)
|
|
3655
|
-
|
|
3656
|
-
pid = File.read(pid_file).strip.to_i
|
|
3657
|
-
return if pid <= 0
|
|
3658
|
-
# After exec-restart, the new process inherits the same PID as the old one.
|
|
3659
|
-
# Skip sending TERM to ourselves — we are already the new server.
|
|
3660
|
-
if pid == Process.pid
|
|
3661
|
-
Clacky::Logger.info("[Server] exec-restart detected (PID=#{pid}), skipping self-kill.")
|
|
3662
|
-
return
|
|
3663
|
-
end
|
|
3664
|
-
|
|
3665
|
-
begin
|
|
3666
|
-
Process.kill("TERM", pid)
|
|
3667
|
-
Clacky::Logger.info("[Server] Stopped existing server (PID=#{pid}) on port #{port}.")
|
|
3668
|
-
puts "Stopped existing server (PID: #{pid}) on port #{port}."
|
|
3669
|
-
# Give it a moment to release the port
|
|
3670
|
-
sleep 0.5
|
|
3671
|
-
rescue Errno::ESRCH
|
|
3672
|
-
Clacky::Logger.info("[Server] Existing server PID=#{pid} already gone.")
|
|
3673
|
-
rescue Errno::EPERM
|
|
3674
|
-
Clacky::Logger.warn("[Server] Could not stop existing server (PID=#{pid}) — permission denied.")
|
|
3675
|
-
puts "Could not stop existing server (PID: #{pid}) — permission denied."
|
|
3676
|
-
ensure
|
|
3677
|
-
File.delete(pid_file) if File.exist?(pid_file)
|
|
3678
|
-
end
|
|
3679
|
-
end
|
|
3680
|
-
|
|
3681
3761
|
# ── Inner classes ─────────────────────────────────────────────────────────
|
|
3682
3762
|
|
|
3683
3763
|
# Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
|
|
@@ -143,10 +143,16 @@ module Clacky
|
|
|
143
143
|
|
|
144
144
|
Clacky::Logger.info("[Master PID=#{Process.pid}] spawn: #{ruby} #{script} #{worker_argv.join(' ')}")
|
|
145
145
|
Clacky::Logger.info("[Master PID=#{Process.pid}] env: #{env.inspect}")
|
|
146
|
+
|
|
146
147
|
# pgroup: 0 puts worker in its own process group.
|
|
147
148
|
# This lets Master send TERM/KILL to the entire group (-pid) on shutdown,
|
|
148
149
|
# ensuring grandchildren (e.g. chrome-devtools-mcp node process) are also
|
|
149
150
|
# cleaned up even if the worker is force-killed before its shutdown_proc runs.
|
|
151
|
+
#
|
|
152
|
+
# NOTE on stdio: we deliberately let the worker inherit Master's fd 0/1/2
|
|
153
|
+
# so users see startup banner / request logs in their terminal. Protection
|
|
154
|
+
# against Errno::EPIPE on broken parent stdout is installed inside the
|
|
155
|
+
# worker itself (see cli.rb worker entry — EPIPESafeIO wrapper).
|
|
150
156
|
pid = spawn(env, ruby, script, *worker_argv, pgroup: 0)
|
|
151
157
|
Clacky::Logger.info("[Master PID=#{Process.pid}] Spawned worker PID=#{pid} pgroup=#{pid}")
|
|
152
158
|
pid
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -287,6 +287,36 @@ module Clacky
|
|
|
287
287
|
end
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
+
# Environment hint: if the skill references ${CLACKY_SERVER_HOST/PORT} but
|
|
291
|
+
# those vars were not injected (bare-CLI mode without a running server),
|
|
292
|
+
# the `${...}` placeholders will survive expansion as literal text. In that
|
|
293
|
+
# case append a non-fatal note so the LLM knows the skill's HTTP callbacks
|
|
294
|
+
# will not work, without blocking the skill entirely (the user may still
|
|
295
|
+
# want to read instructions, explore files, etc.).
|
|
296
|
+
if processed_content.match?(/\$\{CLACKY_SERVER_(HOST|PORT)\}/)
|
|
297
|
+
processed_content += <<~HINT
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
> ⚠️ **Environment note (auto-injected)**: this skill calls back into the
|
|
303
|
+
> Clacky HTTP server (via `${CLACKY_SERVER_HOST}` / `${CLACKY_SERVER_PORT}`),
|
|
304
|
+
> but those variables are **not set** in the current process. That means
|
|
305
|
+
> no local Clacky server was detected.
|
|
306
|
+
>
|
|
307
|
+
> Any `curl http://${CLACKY_SERVER_HOST}:...` command in the steps above
|
|
308
|
+
> will fail with a DNS/connection error. Before running those steps you
|
|
309
|
+
> should either:
|
|
310
|
+
>
|
|
311
|
+
> 1. Ask the user to start the server in another terminal: `clacky server`
|
|
312
|
+
> (then retry — the CLI auto-detects it via `/tmp/clacky-master-*.pid`), or
|
|
313
|
+
> 2. If the task can be completed without the server API, skip those steps
|
|
314
|
+
> and tell the user which parts require the server.
|
|
315
|
+
>
|
|
316
|
+
> This is an informational hint, not an error. Proceed with judgment.
|
|
317
|
+
HINT
|
|
318
|
+
end
|
|
319
|
+
|
|
290
320
|
processed_content
|
|
291
321
|
end
|
|
292
322
|
|
|
@@ -523,8 +523,79 @@ module Clacky
|
|
|
523
523
|
nil
|
|
524
524
|
end
|
|
525
525
|
|
|
526
|
+
# Image extensions that can be inlined as data URLs in markdown content.
|
|
527
|
+
LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
|
|
528
|
+
|
|
529
|
+
# Replace local image paths in markdown content with base64 data URLs.
|
|
530
|
+
#
|
|
531
|
+
# Handles both `file:///path/to/img.png` and bare `/path/to/img.png` in
|
|
532
|
+
# markdown image syntax ``.
|
|
533
|
+
#
|
|
534
|
+
# @param content [String] markdown text potentially containing local image references
|
|
535
|
+
# @return [String] content with local images replaced by data URLs
|
|
536
|
+
def self.inline_local_images(content)
|
|
537
|
+
return content if content.nil? || content.empty?
|
|
538
|
+
|
|
539
|
+
content.gsub(%r{(!\[[^\]]*\])\((file://)?(/[^)]+)\)}) do
|
|
540
|
+
prefix = $1
|
|
541
|
+
_scheme = $2
|
|
542
|
+
raw_path = $3
|
|
543
|
+
path = CGI.unescape(raw_path)
|
|
544
|
+
ext = File.extname(path).downcase
|
|
545
|
+
full_match = $&
|
|
546
|
+
|
|
547
|
+
unless LOCAL_IMAGE_EXTENSIONS.include?(ext) && File.exist?(path)
|
|
548
|
+
next full_match
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
begin
|
|
552
|
+
data_url = image_path_to_data_url(path)
|
|
553
|
+
Clacky::Logger.info("file_processor.inline_local_images", path: path, size: File.size(path))
|
|
554
|
+
"#{prefix}(#{data_url})"
|
|
555
|
+
rescue StandardError => e
|
|
556
|
+
Clacky::Logger.warn("file_processor.inline_local_images.failed", path: path, error: e.message)
|
|
557
|
+
full_match
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
526
562
|
private_class_method :parse_zip_listing, :parse_tar_listing, :save_preview, :sanitize_filename,
|
|
527
563
|
:downscale_png_chunky, :downscale_via_cli
|
|
564
|
+
|
|
565
|
+
# -------------------------------------------------------------------------
|
|
566
|
+
# Local image URL rewriting
|
|
567
|
+
# -------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
# Rewrite local image paths in markdown content to use the /api/local-image proxy.
|
|
570
|
+
#
|
|
571
|
+
# Matches two patterns inside ``:
|
|
572
|
+
# 1. file:// URLs → 
|
|
573
|
+
# 2. bare absolute paths → 
|
|
574
|
+
#
|
|
575
|
+
# https:// URLs and non-image files are left untouched.
|
|
576
|
+
#
|
|
577
|
+
# @param content [String, nil] markdown text
|
|
578
|
+
# @return [String, nil] rewritten content (or original if nothing matched)
|
|
579
|
+
def self.rewrite_local_image_urls(content)
|
|
580
|
+
return content if content.nil? || content.empty?
|
|
581
|
+
|
|
582
|
+
content.gsub(/!\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |match|
|
|
583
|
+
alt = Regexp.last_match(1)
|
|
584
|
+
href = Regexp.last_match(2)
|
|
585
|
+
|
|
586
|
+
# Extract the filesystem path from the href
|
|
587
|
+
path = href.sub(%r{\Afile://}, "")
|
|
588
|
+
path = CGI.unescape(path)
|
|
589
|
+
|
|
590
|
+
ext = File.extname(path).downcase
|
|
591
|
+
if LOCAL_IMAGE_EXTENSIONS.include?(ext) && File.exist?(path)
|
|
592
|
+
encoded = CGI.escape(href)
|
|
593
|
+
""
|
|
594
|
+
else
|
|
595
|
+
match # return original match unchanged
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
528
599
|
end
|
|
529
600
|
end
|
|
530
601
|
end
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -854,69 +854,106 @@ body {
|
|
|
854
854
|
display: none;
|
|
855
855
|
align-items: center;
|
|
856
856
|
justify-content: center;
|
|
857
|
-
width:
|
|
858
|
-
height:
|
|
857
|
+
width: 24px;
|
|
858
|
+
height: 24px;
|
|
859
859
|
background: transparent;
|
|
860
860
|
border: none;
|
|
861
|
-
border-radius:
|
|
861
|
+
border-radius: 4px;
|
|
862
862
|
color: var(--color-text-muted);
|
|
863
|
-
font-size: 14px;
|
|
864
|
-
line-height: 1;
|
|
865
863
|
cursor: pointer;
|
|
866
864
|
padding: 0;
|
|
867
|
-
margin-top: -3px;
|
|
868
865
|
transition: background .15s, color .15s;
|
|
869
|
-
|
|
870
|
-
letter-spacing: -1px;
|
|
866
|
+
align-self: center;
|
|
871
867
|
}
|
|
872
868
|
.session-item:hover .session-actions-btn { display: flex; }
|
|
873
869
|
.session-actions-btn:hover {
|
|
874
|
-
background: var(--color-
|
|
870
|
+
background: var(--color-border-primary);
|
|
875
871
|
color: var(--color-text-primary);
|
|
876
872
|
}
|
|
877
873
|
|
|
878
874
|
/* Pin icon in session name */
|
|
879
875
|
.session-pin-icon {
|
|
880
876
|
flex-shrink: 0;
|
|
881
|
-
|
|
882
|
-
|
|
877
|
+
display: inline-flex;
|
|
878
|
+
align-items: center;
|
|
879
|
+
opacity: 0.6;
|
|
883
880
|
margin-left: 2px;
|
|
881
|
+
color: var(--color-text-tertiary);
|
|
884
882
|
}
|
|
885
883
|
.session-item.active .session-pin-icon {
|
|
886
884
|
opacity: 1;
|
|
885
|
+
color: var(--color-accent-primary);
|
|
887
886
|
}
|
|
888
887
|
|
|
889
888
|
/* Actions menu dropdown */
|
|
890
889
|
.session-actions-menu {
|
|
891
890
|
background: var(--color-bg-secondary);
|
|
892
891
|
border: 1px solid var(--color-border-primary);
|
|
893
|
-
border-radius:
|
|
894
|
-
box-shadow: 0
|
|
895
|
-
padding:
|
|
896
|
-
min-width:
|
|
892
|
+
border-radius: 8px;
|
|
893
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
|
|
894
|
+
padding: 4px;
|
|
895
|
+
min-width: 160px;
|
|
897
896
|
z-index: 1000;
|
|
898
897
|
animation: fadeIn 0.15s ease;
|
|
899
898
|
}
|
|
900
899
|
[data-theme="dark"] .session-actions-menu {
|
|
901
|
-
box-shadow: 0
|
|
900
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
902
901
|
}
|
|
903
902
|
.session-actions-menu-item {
|
|
903
|
+
display: flex;
|
|
904
|
+
align-items: center;
|
|
905
|
+
gap: 8px;
|
|
904
906
|
padding: 6px 10px;
|
|
905
|
-
border-radius:
|
|
907
|
+
border-radius: 5px;
|
|
906
908
|
cursor: pointer;
|
|
907
909
|
color: var(--color-text-primary);
|
|
908
|
-
font-size:
|
|
909
|
-
|
|
910
|
+
font-size: 13px;
|
|
911
|
+
line-height: 1.4;
|
|
912
|
+
transition: background .12s ease, color .12s ease;
|
|
910
913
|
user-select: none;
|
|
911
914
|
}
|
|
915
|
+
.session-actions-menu-icon {
|
|
916
|
+
display: inline-flex;
|
|
917
|
+
align-items: center;
|
|
918
|
+
justify-content: center;
|
|
919
|
+
width: 14px;
|
|
920
|
+
height: 14px;
|
|
921
|
+
color: var(--color-text-secondary);
|
|
922
|
+
flex-shrink: 0;
|
|
923
|
+
}
|
|
924
|
+
.session-actions-menu-label {
|
|
925
|
+
flex: 1;
|
|
926
|
+
min-width: 0;
|
|
927
|
+
}
|
|
912
928
|
.session-actions-menu-item:hover {
|
|
913
929
|
background: var(--color-bg-hover);
|
|
914
930
|
}
|
|
931
|
+
.session-actions-menu-item:hover .session-actions-menu-icon {
|
|
932
|
+
color: var(--color-text-primary);
|
|
933
|
+
}
|
|
934
|
+
.session-actions-menu-item--danger .session-actions-menu-icon {
|
|
935
|
+
color: var(--color-text-secondary);
|
|
936
|
+
}
|
|
915
937
|
.session-actions-menu-item--danger {
|
|
916
|
-
|
|
938
|
+
margin-top: 5px;
|
|
939
|
+
position: relative;
|
|
940
|
+
}
|
|
941
|
+
.session-actions-menu-item--danger::before {
|
|
942
|
+
content: "";
|
|
943
|
+
position: absolute;
|
|
944
|
+
left: 0;
|
|
945
|
+
right: 0;
|
|
946
|
+
top: -3px;
|
|
947
|
+
height: 1px;
|
|
948
|
+
background: var(--color-border-secondary);
|
|
949
|
+
pointer-events: none;
|
|
917
950
|
}
|
|
918
951
|
.session-actions-menu-item--danger:hover {
|
|
919
952
|
background: var(--color-error-bg);
|
|
953
|
+
color: var(--color-error);
|
|
954
|
+
}
|
|
955
|
+
.session-actions-menu-item--danger:hover .session-actions-menu-icon {
|
|
956
|
+
color: var(--color-error);
|
|
920
957
|
}
|
|
921
958
|
|
|
922
959
|
@keyframes fadeIn {
|
|
@@ -1557,7 +1594,7 @@ body {
|
|
|
1557
1594
|
.msg-user .msg-time { color: var(--color-text-secondary); right: 0; left: auto; padding-right: 4px; }
|
|
1558
1595
|
.msg-assistant .msg-time { color: var(--color-text-secondary); left: 0; right: auto; padding-left: 4px; }
|
|
1559
1596
|
|
|
1560
|
-
.msg-user { background: var(--color-accent-primary); color: var(--color-button-primary-text); align-self: flex-end; }
|
|
1597
|
+
.msg-user { background: var(--color-accent-primary); color: var(--color-button-primary-text); align-self: flex-end; white-space: pre-wrap; }
|
|
1561
1598
|
[data-theme="dark"] .msg-user { background: var(--color-accent-hover); }
|
|
1562
1599
|
.msg-assistant { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-primary); align-self: flex-start; }
|
|
1563
1600
|
|
|
@@ -1610,7 +1647,6 @@ body {
|
|
|
1610
1647
|
}
|
|
1611
1648
|
|
|
1612
1649
|
/* ── Markdown rendering inside assistant messages ────────────────────────── */
|
|
1613
|
-
.msg-assistant p { margin: 0 0 0.6em; }
|
|
1614
1650
|
.msg-assistant p:last-child { margin-bottom: 0; }
|
|
1615
1651
|
.msg-assistant h1, .msg-assistant h2, .msg-assistant h3,
|
|
1616
1652
|
.msg-assistant h4, .msg-assistant h5, .msg-assistant h6 {
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -43,11 +43,13 @@ const I18n = (() => {
|
|
|
43
43
|
"chat.history_load_failed": "Could not load history",
|
|
44
44
|
"chat.history_start": "No more history",
|
|
45
45
|
"chat.image_expired": "Expired",
|
|
46
|
-
"chat.done": "Done — {{n}} iteration(s),
|
|
46
|
+
"chat.done": "Done — {{n}} iteration(s), {{cost}}",
|
|
47
47
|
"chat.interrupted": "Interrupted.",
|
|
48
48
|
"chat.feedback_hint": "Or type your own answer below ↓",
|
|
49
49
|
"chat.newMessageHint": "New messages ↓",
|
|
50
50
|
"chat.retry": "Retry",
|
|
51
|
+
"chat.resetSession": "Reset session",
|
|
52
|
+
"chat.resetSessionConfirm": "Reset will start a brand-new session. The current conversation history stays in your sidebar but will no longer be active. Continue?",
|
|
51
53
|
"chat.copy": "Copy",
|
|
52
54
|
"chat.copied": "Copied",
|
|
53
55
|
"chat.empty.title": "Start the conversation",
|
|
@@ -541,7 +543,7 @@ const I18n = (() => {
|
|
|
541
543
|
"chat.history_load_failed": "历史记录加载失败",
|
|
542
544
|
"chat.history_start": "没有更多历史了",
|
|
543
545
|
"chat.image_expired": "已过期",
|
|
544
|
-
"chat.done": "完成 — {{n}}
|
|
546
|
+
"chat.done": "完成 — {{n}} 步,{{cost}}",
|
|
545
547
|
"chat.interrupted": "已中断。",
|
|
546
548
|
"chat.feedback_hint": "或在下方输入框自由作答 ↓",
|
|
547
549
|
"chat.newMessageHint": "有新消息 ↓",
|