openclacky 0.8.1 → 0.8.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 570945e2d68d70788a86ecdbd7a21ed30dfcb34d975b8808218e657826108252
4
- data.tar.gz: 99feeab9141a907658b65eefe3df5cbff69334ab3143db54b72a3d46128b27cd
3
+ metadata.gz: 534d725368218baaf47a39be9b5ce86005b53ebec0f3a2c81d01d754232b56d6
4
+ data.tar.gz: 0d4cfa47f16a72ddc2cc35e4c7a714ef3bee1d961be5d135aa110804b692e9f1
5
5
  SHA512:
6
- metadata.gz: d2cb4c26ec397a9ee146562bcf9490ea1021937fc0a150e355d1880d2b2f9a0fc11a24e0694e5d611ccdbcda1d472c94acc67c5beeb869a141937ce12ec89bee
7
- data.tar.gz: a495aca929f8cee417fb5ae033068faee73f87fd879f715d20ed05216ba6657968a02354b147c7076d054cf6ba5c970ddb0e98cdffc89adfaaedd6ee308d9cd0
6
+ metadata.gz: 2d4250858cbb68fd49f0e25cadde03352ffd40566f05957d7de23b386e0e4a13668e02ae00858a0039ce3346eeff69a6bf2cba40da9e8074f3f954c22cc564c8
7
+ data.tar.gz: 4a794d9b85a3a0a6f64e70e18e047aadc4d69b64780965f97a5c827403fff75536068937e7b7f824064f4bb0efbd6e793abcf4eb0975a1523d3e21fe27633624
@@ -126,7 +126,7 @@ To use this skill, simply say:
126
126
  ```
127
127
 
128
128
  3. **Analyze and Categorize Commits**
129
- - Review each commit message and its changes
129
+ - Review each commit message AND its diff (`git show <hash> --stat`) to understand the actual change
130
130
  - Categorize into:
131
131
  - **Major Features**: User-visible functionality additions
132
132
  - **Improvements**: Performance, UX, architecture enhancements
@@ -134,6 +134,22 @@ To use this skill, simply say:
134
134
  - **Changes**: Breaking changes or significant refactoring
135
135
  - **Minor Details**: Small fixes, style changes, trivial updates
136
136
 
137
+ **⚠️ Critical: Do NOT over-merge commits on the same topic**
138
+
139
+ It is tempting to group multiple commits under one bullet because they share a theme (e.g., "all about memory"). Resist this — each commit with **independent user-facing value** deserves its own bullet.
140
+
141
+ Ask for every commit: *"Does this enable something the user couldn't do before, separate from other commits on this topic?"*
142
+ - YES → write a separate CHANGELOG bullet
143
+ - NO (pure refactor, stability fix, threshold tweak) → merge into a related bullet or put in "More"
144
+
145
+ **Example of the mistake to avoid:**
146
+ - `feat: add long-term memory update system` and `feat: skill template context and recall-memory meta injection` are both "about memory", but they describe distinct capabilities:
147
+ - First: agent writes memories after sessions
148
+ - Second: skills receive a pre-built index so agent can selectively load only relevant memories
149
+ - These must be two separate bullets, not one.
150
+
151
+ **Sanity check after writing:** Count your `### Added` bullets vs the number of `feat:` commits. If `feat` commits > bullets, you likely merged too aggressively — revisit.
152
+
137
153
  4. **Write CHANGELOG Entries**
138
154
 
139
155
  **Format for Significant Items:**
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.2] - 2026-03-09
11
+
12
+ ### Added
13
+ - **Skill count limits**: two-layer guard to keep context tokens bounded — at most 50 skills loaded from disk (`MAX_SKILLS`) and at most 30 injected into the system prompt (`MAX_CONTEXT_SKILLS`); excess skills are skipped and a warning is written to the file logger
14
+
15
+ ### Improved
16
+ - Skill `agent` field is now self-declared in each `SKILL.md` instead of being listed in `profile.yml` — makes skill-to-profile assignment portable and removes the need to edit profile config when adding skills
17
+ - Slash command autocomplete in the web UI now filters by the active session's agent profile, so only relevant skills appear
18
+
19
+ ### Fixed
20
+ - CLI startup crash: `ui: nil` keyword argument now correctly passed to `Agent.new`
21
+
10
22
  ## [0.8.1] - 2026-03-09
11
23
 
12
24
  ### Added
@@ -14,14 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
26
  - **Skill autocomplete dropdown** in the web UI: type `/` in the chat input to see a filtered list of available skills
15
27
  - **File-based logger** (`Clacky::Logger`): thread-safe structured logging to `~/.clacky/logs/` for debugging agent sessions
16
28
  - **Session persistence on startup**: server now restores the most recent session for the working directory automatically on boot
17
- - **Long-term memory update system**: agent automatically updates `~/.clacky/memories/` after sessions using a whitelist-driven approach
29
+ - **Long-term memory update system**: agent automatically updates `~/.clacky/memories/` after sessions using a whitelist-driven approach; memories persist across restarts and are injected into agent context on startup
30
+ - **recall-memory skill with smart meta injection**: the `recall-memory` skill now receives a pre-built index of all memory files (topic, description, last updated) so the agent can selectively load only relevant memories without reading every file
18
31
  - **Compressed message archiving**: older messages are compressed and archived to chunk Markdown files to keep context window manageable
19
32
  - **Network pre-flight check**: connection is verified before agent starts; helpful VPN/proxy suggestions shown on failure
20
33
  - **Encrypted brand skills**: white-label brand skills can now be shipped as encrypted `.enc` files for privacy
21
34
 
22
35
  ### Improved
23
- - Memory update logic tightened with a whitelist approach only writes memory when explicit criteria are met, reducing noise
24
- - Memory update threshold raised and prompt made dynamic for more reliable triggering
36
+ - Memory update logic tightened: whitelist-driven approach, raised trigger threshold, and dynamic prompt reduces false writes and improves reliability
25
37
  - Slash commands in onboarding (`/create-task`, `/skill-add`) now use the pending-message pattern so they work correctly before WS connects
26
38
  - Sidebar shows "No sessions yet" placeholder during onboarding
27
39
  - Session delete is now optimistic — UI updates immediately without waiting for WS broadcast, and 404 ghost sessions are cleaned up automatically
@@ -31,8 +31,8 @@ module Clacky
31
31
  # Check if user can invoke this skill
32
32
  return nil unless skill.user_invocable?
33
33
 
34
- # Check if this skill is allowed by the current agent profile
35
- return nil if @agent_profile && !@agent_profile.skill_allowed?(skill.identifier)
34
+ # Check if this skill is allowed for the current agent profile
35
+ return nil if @agent_profile && !skill.allowed_for_agent?(@agent_profile.name)
36
36
 
37
37
  { skill: skill, arguments: arguments }
38
38
  else
@@ -64,6 +64,10 @@ module Clacky
64
64
  expanded_content
65
65
  end
66
66
 
67
+ # Maximum number of skills injected into the system prompt.
68
+ # Keeps context tokens bounded regardless of how many skills are installed.
69
+ MAX_CONTEXT_SKILLS = 30
70
+
67
71
  # Generate skill context - loads all auto-invocable skills allowed by the agent profile
68
72
  # @return [String] Skill context to add to system prompt
69
73
  def build_skill_context
@@ -72,6 +76,17 @@ module Clacky
72
76
  all_skills = filter_skills_by_profile(all_skills)
73
77
  auto_invocable = all_skills.select(&:model_invocation_allowed?)
74
78
 
79
+ # Enforce system prompt injection limit to control token usage
80
+ if auto_invocable.size > MAX_CONTEXT_SKILLS
81
+ dropped = auto_invocable.size - MAX_CONTEXT_SKILLS
82
+ Clacky::Logger.warn(
83
+ "Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
84
+ "only injecting first #{MAX_CONTEXT_SKILLS} (#{dropped} dropped). " \
85
+ "Remove unused skills to restore full visibility."
86
+ )
87
+ auto_invocable = auto_invocable.first(MAX_CONTEXT_SKILLS)
88
+ end
89
+
75
90
  return "" if auto_invocable.empty?
76
91
 
77
92
  plain_skills = auto_invocable.reject(&:encrypted?)
@@ -82,7 +97,7 @@ module Clacky
82
97
  context += "=" * 80 + "\n\n"
83
98
  context += "CRITICAL SKILL USAGE RULES:\n"
84
99
  context += "- When user's request matches a skill description, you MUST use invoke_skill tool — invoke only the single BEST matching skill, do NOT call multiple skills for the same request\n"
85
- context += "- Example: invoke_skill(skill_name: 'code-explorer', task: 'Analyze project structure')\n"
100
+ context += "- Example: invoke_skill(skill_name: 'xxx', task: 'xxx')\n"
86
101
  context += "- SLASH COMMAND (HIGHEST PRIORITY): If user input starts with /skill_name, you MUST invoke_skill immediately as the first action with no exceptions.\n"
87
102
  context += "\n"
88
103
  context += "Available skills:\n\n"
@@ -115,14 +130,16 @@ module Clacky
115
130
 
116
131
  private
117
132
 
118
- # Filter skills by the agent profile's skill whitelist.
119
- # If no profile is set, all skills are allowed (backward-compatible).
133
+ # Filter skills by the agent profile name using the skill's own `agent:` field.
134
+ # Each skill declares which agents it supports via its frontmatter `agent:` field.
135
+ # If the skill has no `agent:` field (defaults to "all"), it is allowed everywhere.
136
+ # If no agent profile is set, all skills are allowed (backward-compatible).
120
137
  # @param skills [Array<Skill>]
121
138
  # @return [Array<Skill>]
122
139
  def filter_skills_by_profile(skills)
123
140
  return skills unless @agent_profile
124
141
 
125
- skills.select { |skill| @agent_profile.skill_allowed?(skill.identifier) }
142
+ skills.select { |skill| skill.allowed_for_agent?(@agent_profile.name) }
126
143
  end
127
144
 
128
145
  # Build template context for skill content expansion.
data/lib/clacky/agent.rb CHANGED
@@ -35,7 +35,7 @@ module Clacky
35
35
  attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
36
36
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile
37
37
 
38
- def initialize(client, config = {}, working_dir: nil, ui: nil, profile: "coding")
38
+ def initialize(client, config , working_dir: , ui: , profile: )
39
39
  @client = client # Client for current model
40
40
  @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
41
41
  @agent_profile = AgentProfile.load(profile)
@@ -90,8 +90,9 @@ module Clacky
90
90
  end
91
91
 
92
92
  # Restore from a saved session
93
- def self.from_session(client, config, session_data, ui: nil, profile: "coding")
94
- agent = new(client, config, ui: ui, profile: profile)
93
+ def self.from_session(client, config, session_data, ui: nil, profile:)
94
+ working_dir = session_data[:working_dir] || session_data["working_dir"] || Dir.pwd
95
+ agent = new(client, config, working_dir: working_dir, ui: ui, profile: profile)
95
96
  agent.restore_session(session_data)
96
97
  agent
97
98
  end
@@ -143,7 +144,7 @@ module Clacky
143
144
  def run(user_input, images: [])
144
145
  # Start new task for Time Machine
145
146
  task_id = start_new_task
146
-
147
+
147
148
  @start_time = Time.now
148
149
  @task_cost_source = :estimated # Reset for new task
149
150
  # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
@@ -259,7 +260,7 @@ module Clacky
259
260
  end
260
261
 
261
262
  result = build_result(:success)
262
-
263
+
263
264
  # Save snapshots of modified files for Time Machine
264
265
  if @modified_files_in_task && !@modified_files_in_task.empty?
265
266
  save_modified_files_snapshot(@modified_files_in_task)
@@ -488,16 +489,16 @@ module Clacky
488
489
  progress_shown = false
489
490
  progress_timer = nil
490
491
  output_buffer = nil
491
-
492
+
492
493
  if @ui
493
494
  progress_message = build_tool_progress_message(call[:name], args)
494
-
495
+
495
496
  # For shell commands, create shared output buffer
496
497
  if call[:name] == "shell" || call[:name] == "safe_shell"
497
498
  output_buffer = { content: "", timestamp: Time.now }
498
499
  args[:output_buffer] = output_buffer
499
500
  end
500
-
501
+
501
502
  progress_timer = Thread.new do
502
503
  sleep 2
503
504
  @ui.show_progress(progress_message, prefix_newline: false, output_buffer: output_buffer)
@@ -700,12 +701,13 @@ module Clacky
700
701
  anthropic_format: subagent_config.anthropic_format?
701
702
  )
702
703
 
703
- # Create subagent (reuses all tools from parent)
704
+ # Create subagent (reuses all tools from parent, inherits agent profile from parent)
704
705
  subagent = self.class.new(
705
706
  subagent_client,
706
707
  subagent_config,
707
708
  working_dir: @working_dir,
708
- ui: @ui
709
+ ui: @ui,
710
+ profile: @agent_profile.name
709
711
  )
710
712
 
711
713
  # Deep clone messages to avoid cross-contamination
@@ -829,7 +831,7 @@ module Clacky
829
831
  # @param args [Hash] Arguments passed to the tool
830
832
  def track_modified_files(tool_name, args)
831
833
  @modified_files_in_task ||= []
832
-
834
+
833
835
  case tool_name
834
836
  when "write", "edit"
835
837
  file_path = args[:path]
@@ -21,13 +21,12 @@ module Clacky
21
21
  DEFAULT_AGENTS_DIR = File.expand_path("../default_agents", __FILE__).freeze
22
22
  USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
23
23
 
24
- attr_reader :name, :description, :skills
24
+ attr_reader :name, :description
25
25
 
26
26
  def initialize(name)
27
27
  @name = name.to_s
28
28
  profile_data = load_profile_yml
29
29
  @description = profile_data["description"] || ""
30
- @skills = Array(profile_data["skills"])
31
30
  @system_prompt_content = load_agent_file("system_prompt.md")
32
31
  end
33
32
 
@@ -58,11 +57,6 @@ module Clacky
58
57
  load_global_file("USER.md")
59
58
  end
60
59
 
61
- # @return [Boolean] whether a given skill name is allowed for this profile
62
- def skill_allowed?(skill_name)
63
- @skills.include?(skill_name.to_s)
64
- end
65
-
66
60
  private def load_profile_yml
67
61
  path = find_agent_file("profile.yml")
68
62
  raise ArgumentError, "Agent profile '#{@name}' not found. " \
data/lib/clacky/cli.rb CHANGED
@@ -102,7 +102,7 @@ module Clacky
102
102
  end
103
103
 
104
104
  # Create new agent if no session loaded
105
- agent ||= Clacky::Agent.new(client, agent_config, working_dir: working_dir, profile: agent_profile)
105
+ agent ||= Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: nil, profile: agent_profile)
106
106
 
107
107
  # Change to working directory
108
108
  original_dir = Dir.pwd
@@ -368,7 +368,7 @@ module Clacky
368
368
  say ""
369
369
  end
370
370
 
371
- def load_latest_session(client, agent_config, session_manager, working_dir, profile: "coding")
371
+ def load_latest_session(client, agent_config, session_manager, working_dir, profile:)
372
372
  session_data = session_manager.latest_for_directory(working_dir)
373
373
 
374
374
  if session_data.nil?
@@ -380,7 +380,7 @@ module Clacky
380
380
  Clacky::Agent.from_session(client, agent_config, session_data, profile: profile)
381
381
  end
382
382
 
383
- def load_session_by_number(client, agent_config, session_manager, working_dir, identifier, profile: "coding")
383
+ def load_session_by_number(client, agent_config, session_manager, working_dir, identifier, profile:)
384
384
  # Get a larger list to search through (for ID prefix matching)
385
385
  sessions = session_manager.list(current_dir: working_dir, limit: 100)
386
386
 
@@ -480,7 +480,7 @@ module Clacky
480
480
  # {"type":"confirmation","id":"conf_1","result":"yes"} — answer to request_confirmation
481
481
  #
482
482
  # If a bare string line is received it is treated as a message content.
483
- def run_agent_with_json(agent, working_dir, agent_config, session_manager, client, profile: "coding")
483
+ def run_agent_with_json(agent, working_dir, agent_config, session_manager, client, profile:)
484
484
  json_ui = Clacky::JsonUIController.new
485
485
  agent.instance_variable_set(:@ui, json_ui)
486
486
 
@@ -514,7 +514,7 @@ module Clacky
514
514
  when "/exit", "/quit"
515
515
  break
516
516
  when "/clear"
517
- agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, profile: profile)
517
+ agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: nil, profile: profile)
518
518
  agent.instance_variable_set(:@ui, json_ui)
519
519
  json_ui.emit("info", message: "Session cleared. Starting fresh.")
520
520
  next
@@ -1,12 +1,2 @@
1
1
  name: coding
2
2
  description: AI coding assistant and technical co-founder
3
- skills:
4
- - code-explorer
5
- - commit
6
- - deploy
7
- - gem-release
8
- - new
9
- - skill-add
10
- - create-task
11
- - recall-memory
12
- - onboard
@@ -1,7 +1,2 @@
1
1
  name: general
2
2
  description: A versatile digital employee living on your computer
3
- skills:
4
- - recall-memory
5
- - create-task
6
- - skill-add
7
- - onboard
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-explorer
3
3
  description: Use this skill when exploring, analyzing, or understanding project/code structure. Required for tasks like "analyze project", "explore codebase", "understand how X works".
4
+ agent: coding
4
5
  fork_agent: true
5
6
  forbidden_tools:
6
7
  - write
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: deploy
3
3
  description: Deploy Rails applications to Railway PaaS
4
+ agent: coding
4
5
  fork_agent: true
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: new
3
3
  description: Create a new project to start development quickly
4
+ agent: coding
4
5
  disable-model-invocation: false
5
6
  user-invocable: true
6
7
  ---
@@ -105,7 +105,7 @@ module Clacky
105
105
  session_registry: @registry,
106
106
  session_builder: method(:build_session)
107
107
  )
108
- @skill_loader = Clacky::SkillLoader.new
108
+ @skill_loader = Clacky::SkillLoader.new(nil, brand_config: Clacky::BrandConfig.load)
109
109
  end
110
110
 
111
111
  def start
@@ -210,7 +210,10 @@ module Clacky
210
210
  when ["GET", "/api/brand/skills"] then api_brand_skills(res)
211
211
  when ["GET", "/api/brand"] then api_brand_info(res)
212
212
  else
213
- if method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
213
+ if method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
214
+ session_id = path.sub("/api/sessions/", "").sub("/skills", "")
215
+ api_session_skills(session_id, res)
216
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
214
217
  session_id = path.sub("/api/sessions/", "").sub("/messages", "")
215
218
  api_session_messages(session_id, req, res)
216
219
  elsif method == "DELETE" && path.start_with?("/api/sessions/")
@@ -380,6 +383,9 @@ module Clacky
380
383
  result = @brand_test ? brand.activate_mock!(key) : brand.activate!(key)
381
384
 
382
385
  if result[:success]
386
+ # Refresh skill_loader with the now-activated brand config so brand
387
+ # skills are loadable from this point forward (e.g. after sync).
388
+ @skill_loader = Clacky::SkillLoader.new(nil, brand_config: brand)
383
389
  json_response(res, 200, { ok: true, brand_name: result[:brand_name] || brand.brand_name })
384
390
  else
385
391
  json_response(res, 422, { ok: false, error: result[:message] })
@@ -446,8 +452,9 @@ module Clacky
446
452
  result = @brand_test ? brand.install_mock_brand_skill!(skill_info) : brand.install_brand_skill!(skill_info)
447
453
 
448
454
  if result[:success]
449
- # Reload skills so the Agent can pick up the new skill immediately
450
- @skill_loader.load_all
455
+ # Reload skills so the Agent can pick up the new skill immediately.
456
+ # Re-create the loader with the current brand_config so brand skills are decryptable.
457
+ @skill_loader = Clacky::SkillLoader.new(nil, brand_config: brand)
451
458
  json_response(res, 200, { ok: true, slug: result[:slug], version: result[:version] })
452
459
  else
453
460
  json_response(res, 422, { ok: false, error: result[:error] })
@@ -655,6 +662,38 @@ module Clacky
655
662
  json_response(res, 200, { skills: skills })
656
663
  end
657
664
 
665
+ # GET /api/sessions/:id/skills — list user-invocable skills for a session,
666
+ # filtered by the session's agent profile. Used by the frontend slash-command
667
+ # autocomplete so only skills valid for the current profile are suggested.
668
+ def api_session_skills(session_id, res)
669
+ session = @registry.get(session_id)
670
+ unless session
671
+ json_response(res, 404, { error: "Session not found" })
672
+ return
673
+ end
674
+
675
+ agent = session[:agent]
676
+ unless agent
677
+ json_response(res, 404, { error: "Agent not found" })
678
+ return
679
+ end
680
+
681
+ agent.skill_loader.load_all
682
+ profile = agent.agent_profile
683
+
684
+ skills = agent.skill_loader.user_invocable_skills
685
+ skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
686
+
687
+ skill_data = skills.map do |skill|
688
+ {
689
+ name: skill.identifier,
690
+ description: skill.description || skill.context_description
691
+ }
692
+ end
693
+
694
+ json_response(res, 200, { skills: skill_data })
695
+ end
696
+
658
697
  # PATCH /api/skills/:name/toggle — enable or disable a skill
659
698
  # Body: { enabled: true/false }
660
699
  def api_toggle_skill(name, req, res)
@@ -1078,11 +1117,9 @@ module Clacky
1078
1117
  client = @client_factory.call
1079
1118
  config = @agent_config.dup
1080
1119
  config.permission_mode = permission_mode
1081
- agent = Clacky::Agent.new(client, config, working_dir: working_dir, profile: profile)
1082
-
1083
1120
  broadcaster = method(:broadcast)
1084
1121
  ui = WebUIController.new(session_id, broadcaster)
1085
- agent.instance_variable_set(:@ui, ui)
1122
+ agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile)
1086
1123
 
1087
1124
  @registry.with_session(session_id) do |s|
1088
1125
  s[:agent] = agent
@@ -1106,11 +1143,9 @@ module Clacky
1106
1143
 
1107
1144
  client = @client_factory.call
1108
1145
  config = @agent_config.dup
1109
- agent = Clacky::Agent.from_session(client, config, session_data, profile: "general")
1110
-
1111
1146
  broadcaster = method(:broadcast)
1112
1147
  ui = WebUIController.new(session_id, broadcaster)
1113
- agent.instance_variable_set(:@ui, ui)
1148
+ agent = Clacky::Agent.from_session(client, config, session_data, ui: ui, profile: "general")
1114
1149
 
1115
1150
  @registry.with_session(session_id) do |s|
1116
1151
  s[:agent] = agent
data/lib/clacky/skill.rb CHANGED
@@ -95,6 +95,30 @@ module Clacky
95
95
  @auto_summarize != false
96
96
  end
97
97
 
98
+ # Get the agent scope for this skill.
99
+ # Parsed from the `agent:` frontmatter field.
100
+ # Returns an array of agent names, or ["all"] if not specified.
101
+ # @return [Array<String>]
102
+ def agents_scope
103
+ return ["all"] if @agent_type.nil?
104
+
105
+ case @agent_type
106
+ when "all" then ["all"]
107
+ when Array then @agent_type.map(&:to_s)
108
+ else [@agent_type.to_s]
109
+ end
110
+ end
111
+
112
+ # Check if this skill is allowed for the given agent profile name.
113
+ # Returns true when the skill's `agent:` field is "all" (default) or
114
+ # includes the given profile name.
115
+ # @param profile_name [String] e.g. "coding", "general"
116
+ # @return [Boolean]
117
+ def allowed_for_agent?(profile_name)
118
+ scope = agents_scope
119
+ scope.include?("all") || scope.include?(profile_name.to_s)
120
+ end
121
+
98
122
  # Get the slash command for this skill
99
123
  # @return [String] e.g., "/explain-code"
100
124
  def slash_command
@@ -18,6 +18,11 @@ module Clacky
18
18
  :brand # ~/.clacky/brand_skills/ (encrypted, license-gated)
19
19
  ].freeze
20
20
 
21
+ # Maximum number of skills that can be loaded in total.
22
+ # When exceeded, a warning is recorded in @errors and extra skills are dropped.
23
+ # This prevents runaway memory usage and excessively long system prompts.
24
+ MAX_SKILLS = 50
25
+
21
26
  # Initialize the skill loader and automatically load all skills
22
27
  # @param working_dir [String] Current working directory for project-level discovery
23
28
  # @param brand_config [Clacky::BrandConfig, nil] Optional brand config used to
@@ -334,6 +339,14 @@ module Clacky
334
339
  end
335
340
  end
336
341
 
342
+ # Enforce skill count limit
343
+ if @skills.size >= MAX_SKILLS
344
+ msg = "Skill limit reached (max #{MAX_SKILLS}): skipping brand skill '#{skill.identifier}' from #{skill_dir}"
345
+ @errors << msg
346
+ Clacky::Logger.warn(msg)
347
+ return nil
348
+ end
349
+
337
350
  @skills[skill.identifier] = skill
338
351
  @skills_by_command[skill.slash_command] = skill
339
352
  @loaded_from[skill.identifier] = :brand
@@ -368,6 +381,14 @@ module Clacky
368
381
  end
369
382
  end
370
383
 
384
+ # Enforce skill count limit
385
+ if @skills.size >= MAX_SKILLS
386
+ msg = "Skill limit reached (max #{MAX_SKILLS}): skipping '#{skill.identifier}' from #{skill_dir}"
387
+ @errors << msg
388
+ Clacky::Logger.warn(msg)
389
+ return nil
390
+ end
391
+
371
392
  # Register skill
372
393
  @skills[skill.identifier] = skill
373
394
  @skills_by_command[skill.slash_command] = skill
@@ -46,7 +46,7 @@ module Clacky
46
46
  return unless skill_loader
47
47
 
48
48
  skills = skill_loader.user_invocable_skills
49
- skills = skills.select { |s| agent_profile.skill_allowed?(s.identifier) } if agent_profile
49
+ skills = skills.select { |s| s.allowed_for_agent?(agent_profile.name) } if agent_profile
50
50
 
51
51
  @skill_commands = skills.map do |skill|
52
52
  {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.8.1"
4
+ VERSION = "0.8.2"
5
5
  end
@@ -136,6 +136,9 @@ const Router = (() => {
136
136
  Sessions.renderList();
137
137
  $("user-input").focus();
138
138
 
139
+ // Load session-scoped skill list (filtered by agent profile) for slash autocomplete
140
+ SkillAC.loadForSession(id);
141
+
139
142
  // Always reload history on every switch (cache is not used)
140
143
  Sessions.loadHistory(id);
141
144
  break;
@@ -553,9 +556,24 @@ $("user-input").addEventListener("blur", () => {
553
556
 
554
557
  // ── Skill autocomplete ────────────────────────────────────────────────────
555
558
  const SkillAC = (() => {
556
- let _visible = false;
557
- let _activeIndex = -1;
558
- let _items = []; // filtered [{ name, description }]
559
+ let _visible = false;
560
+ let _activeIndex = -1;
561
+ let _items = []; // filtered [{ name, description }]
562
+ let _sessionSkills = []; // skills allowed for the active session's profile
563
+
564
+ /** Fetch session-specific skill list from the server and cache it.
565
+ * Called whenever the active session changes. */
566
+ async function _loadForSession(sessionId) {
567
+ if (!sessionId) { _sessionSkills = []; return; }
568
+ try {
569
+ const res = await fetch(`/api/sessions/${sessionId}/skills`);
570
+ const data = await res.json();
571
+ _sessionSkills = data.skills || [];
572
+ } catch (e) {
573
+ console.error("[SkillAC] loadForSession failed", e);
574
+ _sessionSkills = [];
575
+ }
576
+ }
559
577
 
560
578
  /** Return the /xxx prefix if the entire input is a slash command, else null. */
561
579
  function _getSlashQuery(value) {
@@ -568,7 +586,8 @@ const SkillAC = (() => {
568
586
  }
569
587
 
570
588
  function _render(query) {
571
- const all = Skills.all;
589
+ // Use session-scoped skill list when available; fall back to global Skills list
590
+ const all = _sessionSkills.length > 0 ? _sessionSkills : Skills.all;
572
591
  _items = all.filter(s => s.name.toLowerCase().startsWith(query));
573
592
 
574
593
  if (_items.length === 0) { _hide(); return; }
@@ -665,6 +684,9 @@ const SkillAC = (() => {
665
684
  /** Open dropdown with all skills (triggered by / button). */
666
685
  openAll: _openAll,
667
686
 
687
+ /** Reload session-scoped skill list when the active session changes. */
688
+ loadForSession: _loadForSession,
689
+
668
690
  /** Handle keyboard nav inside the dropdown. Returns true if event was consumed. */
669
691
  handleKey(e) {
670
692
  if (!_visible) return false;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy