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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -2,38 +2,45 @@
2
2
 
3
3
  module Clacky
4
4
  module Server
5
- # SessionRegistry manages runtime state for active Agent sessions in the web server.
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
- # Fields that already live on the Agent (name, created_at, working_dir, total_tasks,
11
- # total_cost) are read directly from the agent the registry never duplicates them.
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
- def initialize
18
- @sessions = {}
19
- @mutex = Mutex.new
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, # :idle | :running | :error
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, # IdleCompressionTimer instance
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 lightweight summary list (no agent/ui/thread objects) for API responses.
64
- # Sorted newest-first using agent.created_at (ISO8601 strings sort lexicographically).
65
- def list
66
- @mutex.synchronize do
67
- @sessions.values
68
- .map { |s| session_summary(s) }
69
- .sort_by { |s| s[:created_at] }
70
- .reverse
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
- # Delete a session. Also interrupts any running agent thread.
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 that have been idle longer than SESSION_TIMEOUT.
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
- private
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
- # Build a summary hash for API responses, reading authoritative fields from the
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&.name || "",
122
- working_dir: agent&.working_dir || "",
210
+ name: agent.name,
211
+ working_dir: agent.working_dir,
123
212
  status: session[:status],
124
- created_at: agent&.created_at || session[:updated_at].iso8601,
213
+ created_at: agent.created_at,
125
214
  updated_at: session[:updated_at].iso8601,
126
- total_tasks: agent&.total_tasks || 0,
127
- total_cost: agent&.total_cost || 0.0,
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&.permission_mode || ""
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, images: [])
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
- data[:images] = images if images && !images.empty?
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 10 sessions (best-effort, never block save)
34
+ # Keep only the most recent 200 sessions (best-effort, never block save)
35
35
  begin
36
- cleanup_by_count(keep: 10)
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
- # Get the path of the last saved session
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
- sessions = all_sessions
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
- # Get the most recent session for a specific working directory
58
- def latest_for_directory(working_dir)
59
- sessions = all_sessions
60
- sessions
61
- .select { |s| s[:working_dir] == working_dir }
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
- # Get the most recent N sessions for a specific working directory
66
- def latest_n_for_directory(working_dir, n = 5)
67
- all_sessions
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
- # List recent sessions, prioritizing those from current directory
75
- def list(current_dir: nil, limit: 5)
76
- sessions = all_sessions.sort_by { |s| Time.parse(s[:updated_at]) }.reverse
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 = sessions.reject { |s| s[:working_dir] == current_dir }
81
- (current_sessions + other_sessions).first(limit)
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
- # Delete old sessions (older than days)
88
- def cleanup(days: 30)
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
- deleted_count += 1
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, delete older ones
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.sort_by { |s| Time.parse(s[:updated_at]) }.reverse
109
-
102
+ sessions = all_sessions # already sorted newest-first
110
103
  return 0 if sessions.size <= keep
111
104
 
112
- sessions_to_delete = sessions[keep..]
113
- deleted_count = 0
114
-
115
- sessions_to_delete.each do |session|
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
- chunk_pattern = File.join(@sessions_dir, "#{base}-chunk-*.md")
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
- @directory.children.reject { |p| p.basename.to_s == "SKILL.md" }
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
- supporting_files.any?
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(arguments = "", shell_output: {}, template_context: {})
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 before argument substitution
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
- if has_supporting_files?
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 (`#{@directory}`):\n\n"
210
- supporting_files.each do |file|
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