openclacky 0.9.7 → 0.9.9
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/.clacky/skills/gem-release/SKILL.md +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- data/scripts/install_agent_browser.sh +0 -67
|
@@ -2,38 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
module Clacky
|
|
4
4
|
module Server
|
|
5
|
-
# SessionRegistry
|
|
6
|
-
# Each entry holds the Agent instance plus web-server-specific runtime fields that
|
|
7
|
-
# the Agent itself doesn't own: status, error, execution thread, UI controller, and
|
|
8
|
-
# transient scheduling fields (pending_task / pending_working_dir).
|
|
5
|
+
# SessionRegistry is the single authoritative source for session state.
|
|
9
6
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# It owns two concerns:
|
|
8
|
+
# 1. Runtime state — agent instance, thread, status, pending_task, idle_timer.
|
|
9
|
+
# 2. Session list — reads from disk (via session_manager) and enriches with
|
|
10
|
+
# live runtime status. `list` is the only place the session
|
|
11
|
+
# list is assembled; no callers should build it elsewhere.
|
|
12
|
+
#
|
|
13
|
+
# Lazy restore: `ensure(session_id)` loads a disk session into the registry on
|
|
14
|
+
# demand. All session-specific APIs call this before touching the registry so
|
|
15
|
+
# disk-only sessions (e.g. loaded via loadMore) just work transparently.
|
|
12
16
|
#
|
|
13
17
|
# Thread safety: all public methods are protected by a Mutex.
|
|
14
18
|
class SessionRegistry
|
|
15
19
|
SESSION_TIMEOUT = 24 * 60 * 60 # 24 hours of inactivity before cleanup
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
# session_manager: Clacky::SessionManager instance
|
|
22
|
+
# session_restorer: callable(session_data) → session_id — builds agent + wires into registry
|
|
23
|
+
def initialize(session_manager: nil, session_restorer: nil)
|
|
24
|
+
@sessions = {}
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@session_manager = session_manager
|
|
27
|
+
@session_restorer = session_restorer
|
|
20
28
|
end
|
|
21
29
|
|
|
22
|
-
# Create a new session entry and return its id.
|
|
23
|
-
# session_id must come from the caller (use SessionManager.generate_id).
|
|
30
|
+
# Create a new (empty) session entry and return its id.
|
|
24
31
|
# agent/ui/thread are set later via with_session once they are constructed.
|
|
25
32
|
def create(session_id:)
|
|
26
33
|
raise ArgumentError, "session_id is required" if session_id.nil? || session_id.empty?
|
|
27
34
|
|
|
28
35
|
session = {
|
|
29
36
|
id: session_id,
|
|
30
|
-
status: :idle,
|
|
37
|
+
status: :idle,
|
|
31
38
|
error: nil,
|
|
32
39
|
updated_at: Time.now,
|
|
33
40
|
agent: nil,
|
|
34
41
|
ui: nil,
|
|
35
42
|
thread: nil,
|
|
36
|
-
idle_timer: nil,
|
|
43
|
+
idle_timer: nil,
|
|
37
44
|
pending_task: nil,
|
|
38
45
|
pending_working_dir: nil
|
|
39
46
|
}
|
|
@@ -42,13 +49,45 @@ module Clacky
|
|
|
42
49
|
session_id
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
# Ensure a session is in the registry, loading from disk if necessary.
|
|
53
|
+
# Returns true if the session is now available, false if not found anywhere.
|
|
54
|
+
def ensure(session_id)
|
|
55
|
+
return true if exist?(session_id)
|
|
56
|
+
return false unless @session_manager && @session_restorer
|
|
57
|
+
|
|
58
|
+
session_data = @session_manager.load(session_id)
|
|
59
|
+
return false unless session_data
|
|
60
|
+
|
|
61
|
+
@session_restorer.call(session_data)
|
|
62
|
+
exist?(session_id)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Restore all sessions from disk (up to n per source type) into the registry.
|
|
66
|
+
# Used at startup. Already-registered sessions are skipped.
|
|
67
|
+
def restore_from_disk(n: 5)
|
|
68
|
+
return unless @session_manager && @session_restorer
|
|
69
|
+
|
|
70
|
+
all = @session_manager.all_sessions
|
|
71
|
+
.sort_by { |s| s[:created_at] || "" }
|
|
72
|
+
.reverse
|
|
73
|
+
|
|
74
|
+
# Take up to n per source type
|
|
75
|
+
counts = Hash.new(0)
|
|
76
|
+
all.each do |session_data|
|
|
77
|
+
src = (session_data[:source] || "manual").to_s
|
|
78
|
+
next if counts[src] >= n
|
|
79
|
+
next if exist?(session_data[:session_id])
|
|
80
|
+
@session_restorer.call(session_data)
|
|
81
|
+
counts[src] += 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
45
85
|
# Retrieve a session hash by id (returns nil if not found).
|
|
46
86
|
def get(session_id)
|
|
47
87
|
@mutex.synchronize { @sessions[session_id]&.dup }
|
|
48
88
|
end
|
|
49
89
|
|
|
50
90
|
# Update arbitrary runtime fields of a session (status, error, pending_*, etc.).
|
|
51
|
-
# Always stamps updated_at.
|
|
52
91
|
def update(session_id, **fields)
|
|
53
92
|
@mutex.synchronize do
|
|
54
93
|
session = @sessions[session_id]
|
|
@@ -60,18 +99,68 @@ module Clacky
|
|
|
60
99
|
end
|
|
61
100
|
end
|
|
62
101
|
|
|
63
|
-
# Return a
|
|
64
|
-
# Sorted
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
102
|
+
# Return a session list from disk enriched with live registry status.
|
|
103
|
+
# Sorted by created_at descending (newest first).
|
|
104
|
+
#
|
|
105
|
+
# Parameters (all optional, independent):
|
|
106
|
+
# source: "manual"|"cron"|"channel"|"setup"|nil
|
|
107
|
+
# nil = no source filter (all sessions)
|
|
108
|
+
# profile: "general"|"coding"|nil
|
|
109
|
+
# nil = no agent_profile filter
|
|
110
|
+
# limit: max sessions to return
|
|
111
|
+
# before: ISO8601 cursor — only sessions with created_at < before
|
|
112
|
+
#
|
|
113
|
+
# source and profile are orthogonal — either can be nil independently.
|
|
114
|
+
def list(limit: nil, before: nil, source: nil, profile: nil)
|
|
115
|
+
return [] unless @session_manager
|
|
116
|
+
|
|
117
|
+
live = @mutex.synchronize do
|
|
118
|
+
@sessions.transform_values { |s| { status: s[:status], error: s[:error] } }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
all = @session_manager.all_sessions # already sorted newest-first
|
|
122
|
+
|
|
123
|
+
# ── source filter ────────────────────────────────────────────────────
|
|
124
|
+
all = all.select { |s| s_source(s) == source } if source
|
|
125
|
+
# source == nil → no filter, return all
|
|
126
|
+
|
|
127
|
+
# ── profile filter ───────────────────────────────────────────────────
|
|
128
|
+
all = all.select { |s| (s[:agent_profile] || "general").to_s == profile } if profile
|
|
129
|
+
|
|
130
|
+
all = all.select { |s| (s[:created_at] || "") < before } if before
|
|
131
|
+
all = all.first(limit) if limit
|
|
132
|
+
|
|
133
|
+
all.map do |s|
|
|
134
|
+
id = s[:session_id]
|
|
135
|
+
ls = live[id]
|
|
136
|
+
{
|
|
137
|
+
id: id,
|
|
138
|
+
name: s[:name] || "",
|
|
139
|
+
status: ls ? ls[:status].to_s : "idle",
|
|
140
|
+
error: ls ? ls[:error] : nil,
|
|
141
|
+
source: s_source(s),
|
|
142
|
+
agent_profile: (s[:agent_profile] || "general").to_s,
|
|
143
|
+
working_dir: s[:working_dir],
|
|
144
|
+
created_at: s[:created_at],
|
|
145
|
+
updated_at: s[:updated_at],
|
|
146
|
+
total_tasks: s.dig(:stats, :total_tasks) || 0,
|
|
147
|
+
total_cost: s.dig(:stats, :total_cost_usd) || 0.0,
|
|
148
|
+
}
|
|
71
149
|
end
|
|
72
150
|
end
|
|
73
151
|
|
|
74
|
-
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# Normalize source field from a disk session hash.
|
|
155
|
+
# "system" is a legacy value renamed to "setup" — treat them as equivalent.
|
|
156
|
+
def s_source(s)
|
|
157
|
+
src = (s[:source] || "manual").to_s
|
|
158
|
+
src == "system" ? "setup" : src
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
public
|
|
162
|
+
|
|
163
|
+
# Delete a session from registry (and interrupt its thread).
|
|
75
164
|
def delete(session_id)
|
|
76
165
|
@mutex.synchronize do
|
|
77
166
|
session = @sessions.delete(session_id)
|
|
@@ -83,23 +172,21 @@ module Clacky
|
|
|
83
172
|
end
|
|
84
173
|
end
|
|
85
174
|
|
|
86
|
-
# True if the session exists.
|
|
175
|
+
# True if the session exists in registry (runtime).
|
|
87
176
|
def exist?(session_id)
|
|
88
177
|
@mutex.synchronize { @sessions.key?(session_id) }
|
|
89
178
|
end
|
|
90
179
|
|
|
91
180
|
# Execute a block with exclusive access to the raw session hash.
|
|
92
|
-
# Use this to set agent/ui/thread references that must not be dup'd.
|
|
93
181
|
def with_session(session_id)
|
|
94
182
|
@mutex.synchronize do
|
|
95
183
|
session = @sessions[session_id]
|
|
96
184
|
return nil unless session
|
|
97
|
-
|
|
98
185
|
yield session
|
|
99
186
|
end
|
|
100
187
|
end
|
|
101
188
|
|
|
102
|
-
# Remove sessions
|
|
189
|
+
# Remove sessions idle longer than SESSION_TIMEOUT.
|
|
103
190
|
def cleanup_stale!
|
|
104
191
|
cutoff = Time.now - SESSION_TIMEOUT
|
|
105
192
|
@mutex.synchronize do
|
|
@@ -109,25 +196,29 @@ module Clacky
|
|
|
109
196
|
end
|
|
110
197
|
end
|
|
111
198
|
|
|
112
|
-
|
|
199
|
+
# Build a summary hash for API responses (for in-registry sessions).
|
|
200
|
+
# Used when we need live agent fields (name, cost, etc.) after ensure().
|
|
201
|
+
def session_summary(session_id)
|
|
202
|
+
session = @mutex.synchronize { @sessions[session_id] }
|
|
203
|
+
return nil unless session
|
|
204
|
+
agent = session[:agent]
|
|
205
|
+
return nil unless agent
|
|
113
206
|
|
|
114
|
-
|
|
115
|
-
# agent and runtime-only fields from the registry entry.
|
|
116
|
-
def session_summary(session)
|
|
117
|
-
agent = session[:agent]
|
|
118
|
-
model_info = agent&.current_model_info
|
|
207
|
+
model_info = agent.current_model_info
|
|
119
208
|
{
|
|
120
209
|
id: session[:id],
|
|
121
|
-
name: agent
|
|
122
|
-
working_dir: agent
|
|
210
|
+
name: agent.name,
|
|
211
|
+
working_dir: agent.working_dir,
|
|
123
212
|
status: session[:status],
|
|
124
|
-
created_at: agent
|
|
213
|
+
created_at: agent.created_at,
|
|
125
214
|
updated_at: session[:updated_at].iso8601,
|
|
126
|
-
total_tasks: agent
|
|
127
|
-
total_cost: agent
|
|
215
|
+
total_tasks: agent.total_tasks || 0,
|
|
216
|
+
total_cost: agent.total_cost || 0.0,
|
|
128
217
|
error: session[:error],
|
|
129
|
-
model: model_info&.dig(:model)
|
|
130
|
-
permission_mode: agent
|
|
218
|
+
model: model_info&.dig(:model),
|
|
219
|
+
permission_mode: agent.permission_mode,
|
|
220
|
+
source: agent.source.to_s,
|
|
221
|
+
agent_profile: agent.agent_profile.name,
|
|
131
222
|
}
|
|
132
223
|
end
|
|
133
224
|
end
|
|
@@ -65,11 +65,23 @@ module Clacky
|
|
|
65
65
|
|
|
66
66
|
# === Output display ===
|
|
67
67
|
|
|
68
|
-
def show_user_message(content, created_at: nil,
|
|
68
|
+
def show_user_message(content, created_at: nil, files: [], source: :web)
|
|
69
69
|
data = { content: content }
|
|
70
70
|
data[:created_at] = created_at if created_at
|
|
71
|
-
|
|
71
|
+
# Build ev.images for the frontend renderer (history_user_message):
|
|
72
|
+
# - Images with data_url → pass the data_url directly (<img> thumbnail)
|
|
73
|
+
# - Disk files (PDF, doc, etc., no data_url) → "pdf:name" sentinel (renders a badge)
|
|
74
|
+
rendered = Array(files).filter_map do |f|
|
|
75
|
+
url = f[:data_url] || f["data_url"]
|
|
76
|
+
name = f[:name] || f["name"]
|
|
77
|
+
url || (name ? "pdf:#{name}" : nil)
|
|
78
|
+
end
|
|
79
|
+
data[:images] = rendered unless rendered.empty?
|
|
72
80
|
emit("history_user_message", **data)
|
|
81
|
+
# Only forward to channel subscribers when the message originated from the WebUI,
|
|
82
|
+
# to avoid echoing channel messages back to the same channel.
|
|
83
|
+
return unless source == :web
|
|
84
|
+
forward_to_subscribers { |sub| sub.show_user_message(content) if sub.respond_to?(:show_user_message) }
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
def show_assistant_message(content, files:)
|
|
@@ -161,6 +173,13 @@ module Clacky
|
|
|
161
173
|
forward_to_subscribers { |sub| sub.show_info(message) }
|
|
162
174
|
end
|
|
163
175
|
|
|
176
|
+
# Emit a two-phase idle compression status update.
|
|
177
|
+
# The frontend uses the same DOM element for both phases so it renders as one line.
|
|
178
|
+
# phase: :start → show spinner message; phase: :end → update in-place with final result
|
|
179
|
+
def show_idle_status(phase:, message:)
|
|
180
|
+
emit("idle_status", phase: phase.to_s, message: message)
|
|
181
|
+
end
|
|
182
|
+
|
|
164
183
|
def show_warning(message)
|
|
165
184
|
emit("warning", message: message)
|
|
166
185
|
forward_to_subscribers { |sub| sub.show_warning(message) }
|
|
@@ -21,7 +21,7 @@ module Clacky
|
|
|
21
21
|
ensure_sessions_dir
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# Save a session
|
|
24
|
+
# Save a session. Returns the file path.
|
|
25
25
|
def save(session_data)
|
|
26
26
|
filename = generate_filename(session_data[:session_id], session_data[:created_at])
|
|
27
27
|
filepath = File.join(@sessions_dir, filename)
|
|
@@ -31,98 +31,81 @@ module Clacky
|
|
|
31
31
|
|
|
32
32
|
@last_saved_path = filepath
|
|
33
33
|
|
|
34
|
-
# Keep only the most recent
|
|
34
|
+
# Keep only the most recent 200 sessions (best-effort, never block save)
|
|
35
35
|
begin
|
|
36
|
-
cleanup_by_count(keep:
|
|
36
|
+
cleanup_by_count(keep: 200)
|
|
37
37
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
38
38
|
# Cleanup is non-critical; swallow all errors (including AgentInterrupted)
|
|
39
|
-
# so that the session file is always saved successfully
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
filepath
|
|
43
42
|
end
|
|
44
43
|
|
|
45
|
-
#
|
|
44
|
+
# Path of the last saved session file.
|
|
46
45
|
def last_saved_path
|
|
47
46
|
@last_saved_path
|
|
48
47
|
end
|
|
49
48
|
|
|
50
|
-
# Load a specific session by ID
|
|
49
|
+
# Load a specific session by ID. Returns nil if not found.
|
|
51
50
|
def load(session_id)
|
|
52
|
-
|
|
53
|
-
session = sessions.find { |s| s[:session_id].start_with?(session_id) }
|
|
54
|
-
session
|
|
51
|
+
all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
|
|
55
52
|
end
|
|
56
53
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.max_by { |s| Time.parse(s[:updated_at]) }
|
|
63
|
-
end
|
|
54
|
+
# Physical delete — removes disk file + associated chunk files.
|
|
55
|
+
# Returns true if found and deleted, false if not found.
|
|
56
|
+
def delete(session_id)
|
|
57
|
+
session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
|
|
58
|
+
return false unless session
|
|
64
59
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.select { |s| s[:working_dir] == working_dir }
|
|
69
|
-
.sort_by { |s| Time.parse(s[:updated_at]) }
|
|
70
|
-
.reverse
|
|
71
|
-
.first(n)
|
|
60
|
+
filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
|
|
61
|
+
delete_session_with_chunks(filepath)
|
|
62
|
+
true
|
|
72
63
|
end
|
|
73
64
|
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
# All sessions from disk, newest-first (sorted by created_at).
|
|
66
|
+
# Optional filters:
|
|
67
|
+
# current_dir: (String) if given, sessions matching working_dir come first
|
|
68
|
+
# limit: (Integer) max number of sessions to return
|
|
69
|
+
def all_sessions(current_dir: nil, limit: nil)
|
|
70
|
+
sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
|
|
71
|
+
load_session_file(filepath)
|
|
72
|
+
end.sort_by { |s| s[:created_at] || "" }.reverse
|
|
77
73
|
|
|
78
74
|
if current_dir
|
|
79
75
|
current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
|
|
80
|
-
other_sessions
|
|
81
|
-
|
|
82
|
-
else
|
|
83
|
-
sessions.first(limit)
|
|
76
|
+
other_sessions = sessions.reject { |s| s[:working_dir] == current_dir }
|
|
77
|
+
sessions = current_sessions + other_sessions
|
|
84
78
|
end
|
|
85
|
-
end
|
|
86
79
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
cutoff_time = Time.now - (days * 24 * 60 * 60)
|
|
90
|
-
deleted_count = 0
|
|
80
|
+
limit ? sessions.first(limit) : sessions
|
|
81
|
+
end
|
|
91
82
|
|
|
83
|
+
# Delete sessions not accessed within the given number of days (default: 90).
|
|
84
|
+
# Returns count of deleted sessions.
|
|
85
|
+
def cleanup(days: 90)
|
|
86
|
+
cutoff = Time.now - (days * 24 * 60 * 60)
|
|
87
|
+
deleted = 0
|
|
92
88
|
Dir.glob(File.join(@sessions_dir, "*.json")).each do |filepath|
|
|
93
89
|
session = load_session_file(filepath)
|
|
94
90
|
next unless session
|
|
95
|
-
|
|
96
|
-
updated_at = Time.parse(session[:updated_at])
|
|
97
|
-
if updated_at < cutoff_time
|
|
91
|
+
if Time.parse(session[:updated_at]) < cutoff
|
|
98
92
|
delete_session_with_chunks(filepath)
|
|
99
|
-
|
|
93
|
+
deleted += 1
|
|
100
94
|
end
|
|
101
95
|
end
|
|
102
|
-
|
|
103
|
-
deleted_count
|
|
96
|
+
deleted
|
|
104
97
|
end
|
|
105
98
|
|
|
106
|
-
# Keep only the most recent N sessions
|
|
99
|
+
# Keep only the most recent N sessions by created_at; delete the rest.
|
|
100
|
+
# Returns count of deleted sessions.
|
|
107
101
|
def cleanup_by_count(keep:)
|
|
108
|
-
sessions = all_sessions
|
|
109
|
-
|
|
102
|
+
sessions = all_sessions # already sorted newest-first
|
|
110
103
|
return 0 if sessions.size <= keep
|
|
111
104
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
filename = generate_filename(session[:session_id], session[:created_at])
|
|
117
|
-
filepath = File.join(@sessions_dir, filename)
|
|
118
|
-
|
|
119
|
-
if File.exist?(filepath)
|
|
120
|
-
delete_session_with_chunks(filepath)
|
|
121
|
-
deleted_count += 1
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
deleted_count
|
|
105
|
+
sessions[keep..].each do |session|
|
|
106
|
+
filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
|
|
107
|
+
delete_session_with_chunks(filepath) if File.exist?(filepath)
|
|
108
|
+
end.size
|
|
126
109
|
end
|
|
127
110
|
|
|
128
111
|
private
|
|
@@ -137,24 +120,11 @@ module Clacky
|
|
|
137
120
|
"#{datetime}-#{short_id}.json"
|
|
138
121
|
end
|
|
139
122
|
|
|
140
|
-
# Delete a session JSON file and all its associated chunk MD files
|
|
141
|
-
# Chunk files follow the pattern: {base}-chunk-{n}.md
|
|
123
|
+
# Delete a session JSON file and all its associated chunk MD files.
|
|
142
124
|
def delete_session_with_chunks(json_filepath)
|
|
143
|
-
# Delete the main session JSON
|
|
144
125
|
File.delete(json_filepath) if File.exist?(json_filepath)
|
|
145
|
-
|
|
146
|
-
# Find and delete associated chunk MD files
|
|
147
126
|
base = File.basename(json_filepath, ".json")
|
|
148
|
-
|
|
149
|
-
Dir.glob(chunk_pattern).each do |chunk_file|
|
|
150
|
-
File.delete(chunk_file)
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def all_sessions
|
|
155
|
-
Dir.glob(File.join(@sessions_dir, "*.json")).map do |filepath|
|
|
156
|
-
load_session_file(filepath)
|
|
157
|
-
end.compact
|
|
127
|
+
Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).each { |f| File.delete(f) }
|
|
158
128
|
end
|
|
159
129
|
|
|
160
130
|
def load_session_file(filepath)
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "pathname"
|
|
5
|
+
require_relative "utils/file_ignore_helper"
|
|
6
|
+
require_relative "utils/gitignore_parser"
|
|
5
7
|
|
|
6
8
|
module Clacky
|
|
7
9
|
# Represents a skill with its metadata and content.
|
|
@@ -169,47 +171,97 @@ module Clacky
|
|
|
169
171
|
|
|
170
172
|
# Get all supporting files in the skill directory (excluding SKILL.md)
|
|
171
173
|
# @return [Array<Pathname>]
|
|
174
|
+
# Return plain-text supporting files for this skill (non-encrypted skills only).
|
|
175
|
+
# For encrypted skills this always returns [] — the decrypted files live in a
|
|
176
|
+
# tmpdir created by SkillManager at invoke time (see has_supporting_scripts?).
|
|
177
|
+
# @return [Array<Pathname>]
|
|
172
178
|
def supporting_files
|
|
173
179
|
return [] unless @directory.exist?
|
|
180
|
+
return [] if encrypted?
|
|
181
|
+
|
|
182
|
+
dir = @directory.to_s
|
|
183
|
+
gitignore_path = Utils::FileIgnoreHelper.find_gitignore(dir)
|
|
184
|
+
gitignore = gitignore_path ? GitignoreParser.new(gitignore_path) : nil
|
|
174
185
|
|
|
175
|
-
|
|
186
|
+
Dir.glob(File.join(dir, "**", "*"))
|
|
187
|
+
.reject { |f| File.directory?(f) }
|
|
188
|
+
.reject { |f| File.basename(f) == "SKILL.md" }
|
|
189
|
+
.reject { |f| Utils::FileIgnoreHelper.should_ignore_file?(f, dir, gitignore) }
|
|
190
|
+
.map { |f| Pathname.new(f) }
|
|
191
|
+
.sort
|
|
176
192
|
end
|
|
177
193
|
|
|
178
|
-
# Check if this skill has supporting files
|
|
194
|
+
# Check if this skill has any supporting files/scripts beyond SKILL.md.
|
|
195
|
+
# For encrypted skills, checks for .enc files that are not SKILL.md.enc or the manifest.
|
|
196
|
+
# For plain skills, checks for any files other than SKILL.md.
|
|
179
197
|
# @return [Boolean]
|
|
180
198
|
def has_supporting_files?
|
|
181
|
-
|
|
199
|
+
return false unless @directory.exist?
|
|
200
|
+
|
|
201
|
+
if encrypted?
|
|
202
|
+
Dir.glob(File.join(@directory.to_s, "**", "*.enc")).any? do |f|
|
|
203
|
+
base = File.basename(f)
|
|
204
|
+
base != "SKILL.md.enc" && base != "MANIFEST.enc.json"
|
|
205
|
+
end
|
|
206
|
+
else
|
|
207
|
+
supporting_files.any?
|
|
208
|
+
end
|
|
182
209
|
end
|
|
183
210
|
|
|
211
|
+
|
|
212
|
+
|
|
184
213
|
# Process the skill content with argument substitution and template expansion
|
|
185
214
|
# @param arguments [String] Arguments passed to the skill
|
|
186
215
|
# @param shell_output [Hash] Shell command outputs for !command` syntax (optional)
|
|
187
216
|
# @param template_context [Hash] Named values for <%= key %> template expansion (optional)
|
|
217
|
+
# @param script_dir [String, nil] When provided, the Supporting Files block uses this
|
|
218
|
+
# directory instead of @directory. Used by SkillManager to point the LLM at the
|
|
219
|
+
# tmpdir containing decrypted scripts for encrypted brand skills.
|
|
188
220
|
# @return [String] Processed content
|
|
189
|
-
def process_content(
|
|
221
|
+
def process_content(shell_output: {}, template_context: {}, script_dir: nil)
|
|
190
222
|
# For brand skills, decrypt content in memory at invoke time.
|
|
191
223
|
# For plain skills, use the already-loaded @content.
|
|
192
224
|
processed_content = decrypted_content.dup
|
|
193
225
|
|
|
194
|
-
# Expand <%= key %> templates
|
|
226
|
+
# Expand <%= key %> templates
|
|
195
227
|
processed_content = expand_templates(processed_content, template_context)
|
|
196
228
|
|
|
197
|
-
# Replace argument placeholders
|
|
198
|
-
processed_content = substitute_arguments(processed_content, arguments)
|
|
199
|
-
|
|
200
229
|
# Replace shell command outputs
|
|
201
230
|
shell_output.each do |command, output|
|
|
202
231
|
placeholder = "!`#{command}`"
|
|
203
232
|
processed_content.gsub!(placeholder, output.to_s)
|
|
204
233
|
end
|
|
205
234
|
|
|
206
|
-
# Append supporting files list if any exist
|
|
207
|
-
|
|
235
|
+
# Append supporting files list if any exist.
|
|
236
|
+
# When script_dir is given (encrypted skill with decrypted tmpdir), use that
|
|
237
|
+
# directory for both the path label and the file listing so the LLM sees real
|
|
238
|
+
# paths it can actually execute.
|
|
239
|
+
effective_dir = script_dir || @directory.to_s
|
|
240
|
+
effective_files = if script_dir && Dir.exist?(script_dir)
|
|
241
|
+
gitignore_path = Utils::FileIgnoreHelper.find_gitignore(script_dir)
|
|
242
|
+
gitignore = gitignore_path ? GitignoreParser.new(gitignore_path) : nil
|
|
243
|
+
Dir.glob(File.join(script_dir, "**", "*"))
|
|
244
|
+
.reject { |f| File.directory?(f) }
|
|
245
|
+
.reject { |f| Utils::FileIgnoreHelper.should_ignore_file?(f, script_dir, gitignore) }
|
|
246
|
+
.map { |f| f.sub("#{script_dir}/", "") }
|
|
247
|
+
.sort
|
|
248
|
+
else
|
|
249
|
+
supporting_files.map { |p| p.relative_path_from(@directory).to_s }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
if effective_files.any?
|
|
253
|
+
max_files = 20
|
|
254
|
+
truncated = effective_files.length > max_files
|
|
255
|
+
listed_files = effective_files.first(max_files)
|
|
256
|
+
|
|
208
257
|
processed_content += "\n\n## Supporting Files\n\n"
|
|
209
|
-
processed_content += "The following files are available in this skill's directory (`#{
|
|
210
|
-
|
|
258
|
+
processed_content += "The following files are available in this skill's directory (`#{effective_dir}`):\n\n"
|
|
259
|
+
listed_files.each do |file|
|
|
211
260
|
processed_content += "- `#{file}`\n"
|
|
212
261
|
end
|
|
262
|
+
if truncated
|
|
263
|
+
processed_content += "\n_(#{effective_files.length - max_files} more files not shown)_\n"
|
|
264
|
+
end
|
|
213
265
|
end
|
|
214
266
|
|
|
215
267
|
processed_content
|
|
@@ -492,36 +544,6 @@ module Clacky
|
|
|
492
544
|
content
|
|
493
545
|
end
|
|
494
546
|
|
|
495
|
-
def substitute_arguments(content, arguments)
|
|
496
|
-
# Split arguments by whitespace for indexed access ($0, $1, $ARGUMENTS[N]).
|
|
497
|
-
# Skill arguments are natural language, not shell commands — shellsplit is
|
|
498
|
-
# intentionally avoided here to prevent errors on apostrophes and other
|
|
499
|
-
# characters that have special meaning in shell but not in plain text.
|
|
500
|
-
args_array = arguments.split
|
|
501
547
|
|
|
502
|
-
# Replace $SKILL_DIR with the absolute path of this skill's directory.
|
|
503
|
-
# Allows SKILL.md files to reference bundled scripts without runtime `find`.
|
|
504
|
-
result = content.gsub("$SKILL_DIR", @directory.to_s)
|
|
505
|
-
|
|
506
|
-
# Replace $ARGUMENTS with all arguments
|
|
507
|
-
result = result.gsub("$ARGUMENTS", arguments.to_s)
|
|
508
|
-
|
|
509
|
-
# Replace $ARGUMENTS[N] with specific argument
|
|
510
|
-
result.gsub!(/\$ARGUMENTS\[(\d+)\]/) do
|
|
511
|
-
index = $1.to_i
|
|
512
|
-
args_array[index] || ""
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
# Replace $N shorthand ($0, $1, etc.)
|
|
516
|
-
result.gsub!(/\$([0-9]+)/) do
|
|
517
|
-
index = $1.to_i
|
|
518
|
-
args_array[index] || ""
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
# Replace ${CLAUDE_SESSION_ID} with empty string (session not available in current context)
|
|
522
|
-
result.gsub!(/\${CLAUDE_SESSION_ID}/, "")
|
|
523
|
-
|
|
524
|
-
result
|
|
525
|
-
end
|
|
526
548
|
end
|
|
527
549
|
end
|