openclacky 0.8.1 → 0.8.3

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: 473dceae1c35bc7eeb4095df2f35ea1403f6013168a3daf3318a709df4ce40ec
4
+ data.tar.gz: 9b7b97d917e13bf43ffff8fe50fda4f1eade74cd530e45959af46d9dbd313043
5
5
  SHA512:
6
- metadata.gz: d2cb4c26ec397a9ee146562bcf9490ea1021937fc0a150e355d1880d2b2f9a0fc11a24e0694e5d611ccdbcda1d472c94acc67c5beeb869a141937ce12ec89bee
7
- data.tar.gz: a495aca929f8cee417fb5ae033068faee73f87fd879f715d20ed05216ba6657968a02354b147c7076d054cf6ba5c970ddb0e98cdffc89adfaaedd6ee308d9cd0
6
+ metadata.gz: 6717eccea955e82d4dfa4381cb99707785d6ac36c3875ac622d51e854007d2997b16155af3e48d2b644aae3635d3989152a03e6f069bf525d8a8630049b6dfb0
7
+ data.tar.gz: 58cd3940c885363ae315e0430a318bb181713cdd0e5d1d4f7308343ce357f44075fd06cb8c4882f75824f857211c85c231b3d8b0cb2babe71ef6c76b5f21371c
@@ -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,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.3] - 2026-03-09
11
+
12
+ ### Added
13
+ - **Slash command skill injection**: skill content is now injected as an assistant message for all `/skill-name` commands, giving the agent full context of the skill instructions at invocation time
14
+ - **Collapsible `<think>` blocks** in web UI: model reasoning enclosed in `<think>…</think>` tags is rendered as a collapsible "Thinking…" section instead of raw text
15
+
16
+ ### Improved
17
+ - **Web UI settings panel**: refined layout and styles for the settings modal
18
+ - **Session state restored on page refresh**: "Thinking…" progress indicator and error messages are now restored from session status after a page reload instead of disappearing
19
+
20
+ ### Fixed
21
+ - **AgentConfig shallow-copy bug**: switching models in Settings no longer pollutes existing sessions — `deep_copy` (JSON round-trip) is now used everywhere instead of `dup` to prevent shared `@models` hash mutation across sessions
22
+
23
+ ## [0.8.2] - 2026-03-09
24
+
25
+ ### Added
26
+ - **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
27
+
28
+ ### Improved
29
+ - 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
30
+ - Slash command autocomplete in the web UI now filters by the active session's agent profile, so only relevant skills appear
31
+
32
+ ### Fixed
33
+ - CLI startup crash: `ui: nil` keyword argument now correctly passed to `Agent.new`
34
+
10
35
  ## [0.8.1] - 2026-03-09
11
36
 
12
37
  ### Added
@@ -14,14 +39,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
39
  - **Skill autocomplete dropdown** in the web UI: type `/` in the chat input to see a filtered list of available skills
15
40
  - **File-based logger** (`Clacky::Logger`): thread-safe structured logging to `~/.clacky/logs/` for debugging agent sessions
16
41
  - **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
42
+ - **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
43
+ - **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
44
  - **Compressed message archiving**: older messages are compressed and archived to chunk Markdown files to keep context window manageable
19
45
  - **Network pre-flight check**: connection is verified before agent starts; helpful VPN/proxy suggestions shown on failure
20
46
  - **Encrypted brand skills**: white-label brand skills can now be shipped as encrypted `.enc` files for privacy
21
47
 
22
48
  ### 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
49
+ - Memory update logic tightened: whitelist-driven approach, raised trigger threshold, and dynamic prompt reduces false writes and improves reliability
25
50
  - Slash commands in onboarding (`/create-task`, `/skill-add`) now use the pending-message pattern so they work correctly before WS connects
26
51
  - Sidebar shows "No sessions yet" placeholder during onboarding
27
52
  - Session delete is now optimistic — UI updates immediately without waiting for WS broadcast, and 404 ghost sessions are cleaned up automatically
@@ -52,6 +52,11 @@ module Clacky
52
52
  })
53
53
  end
54
54
  end
55
+
56
+ # Rebuild and refresh the system prompt so any newly installed skills
57
+ # (or other configuration changes since the session was saved) are
58
+ # reflected immediately — without requiring the user to create a new session.
59
+ refresh_system_prompt
55
60
  end
56
61
 
57
62
  # Generate session data for saving
@@ -174,6 +179,10 @@ module Clacky
174
179
  ui.show_user_message(display_text, created_at: msg[:created_at])
175
180
 
176
181
  round[:events].each do |ev|
182
+ # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
183
+ # — they are internal scaffolding and must not be shown to the user.
184
+ next if ev[:system_injected]
185
+
177
186
  case ev[:role].to_s
178
187
  when "assistant"
179
188
  # Text content
@@ -210,6 +219,28 @@ module Clacky
210
219
 
211
220
  private
212
221
 
222
+ # Replace the system message in @messages with a freshly built system prompt.
223
+ # Called after restore_session so newly installed skills and any other
224
+ # configuration changes since the session was saved take effect immediately.
225
+ # If no system message exists yet (shouldn't happen in practice), a new one
226
+ # is prepended so the conversation stays well-formed.
227
+ def refresh_system_prompt
228
+ # Reload skills from disk to pick up anything installed since the session was saved
229
+ @skill_loader.load_all
230
+
231
+ fresh_prompt = build_system_prompt
232
+ system_index = @messages.index { |m| m[:role] == "system" }
233
+
234
+ if system_index
235
+ @messages[system_index] = { role: "system", content: fresh_prompt }
236
+ else
237
+ @messages.unshift({ role: "system", content: fresh_prompt })
238
+ end
239
+ rescue StandardError => e
240
+ # Log and continue — a stale system prompt is better than a broken restore
241
+ Clacky::Logger.warn("refresh_system_prompt failed during session restore: #{e.message}")
242
+ end
243
+
213
244
  # Extract text from message content (handles string and array formats)
214
245
  # @param content [String, Array, Object] Message content
215
246
  # @return [String] Extracted text
@@ -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"
@@ -113,16 +128,59 @@ module Clacky
113
128
  context
114
129
  end
115
130
 
131
+ # Inject a synthetic assistant message containing the skill content for slash
132
+ # commands (e.g. /pptx, /onboard).
133
+ #
134
+ # When a user types "/skill-name [arguments]", we immediately expand the skill
135
+ # content and inject it as an assistant message so the LLM receives the full
136
+ # instructions and acts on them — no waiting for the LLM to discover and call
137
+ # invoke_skill on its own.
138
+ #
139
+ # Message structure after injection:
140
+ # user: "/pptx write a deck about X"
141
+ # assistant: "[full skill content]" <- injected here
142
+ # (LLM continues from here)
143
+ #
144
+ # Fires when:
145
+ # 1. Input starts with "/"
146
+ # 2. The named skill exists and is user-invocable
147
+ #
148
+ # @param user_input [String] Raw user input
149
+ # @param task_id [Integer] Current task ID (for message tagging)
150
+ # @return [void]
151
+ def inject_skill_command_as_assistant_message(user_input, task_id)
152
+ parsed = parse_skill_command(user_input)
153
+ return unless parsed
154
+
155
+ skill = parsed[:skill]
156
+ arguments = parsed[:arguments]
157
+
158
+ # Expand skill content (substitutes $ARGUMENTS if present)
159
+ expanded_content = skill.process_content(arguments, template_context: build_template_context)
160
+
161
+ # Inject as a synthetic assistant message so the LLM treats it as already read
162
+ @messages << {
163
+ role: "assistant",
164
+ content: expanded_content,
165
+ task_id: task_id,
166
+ system_injected: true
167
+ }
168
+
169
+ @ui&.log("Injected skill content for /#{skill.identifier}", level: :info)
170
+ end
171
+
116
172
  private
117
173
 
118
- # Filter skills by the agent profile's skill whitelist.
119
- # If no profile is set, all skills are allowed (backward-compatible).
174
+ # Filter skills by the agent profile name using the skill's own `agent:` field.
175
+ # Each skill declares which agents it supports via its frontmatter `agent:` field.
176
+ # If the skill has no `agent:` field (defaults to "all"), it is allowed everywhere.
177
+ # If no agent profile is set, all skills are allowed (backward-compatible).
120
178
  # @param skills [Array<Skill>]
121
179
  # @return [Array<Skill>]
122
180
  def filter_skills_by_profile(skills)
123
181
  return skills unless @agent_profile
124
182
 
125
- skills.select { |skill| @agent_profile.skill_allowed?(skill.identifier) }
183
+ skills.select { |skill| skill.allowed_for_agent?(@agent_profile.name) }
126
184
  end
127
185
 
128
186
  # 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
@@ -176,6 +177,11 @@ module Clacky
176
177
  @messages << { role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f }
177
178
  @total_tasks += 1
178
179
 
180
+ # If the user typed a slash command targeting a skill with disable-model-invocation: true,
181
+ # inject the skill content as a synthetic assistant message so the LLM can act on it.
182
+ # Skills already in the system prompt (model_invocation_allowed?) are skipped.
183
+ inject_skill_command_as_assistant_message(user_input, task_id)
184
+
179
185
  @hooks.trigger(:on_start, user_input)
180
186
 
181
187
  begin
@@ -259,7 +265,7 @@ module Clacky
259
265
  end
260
266
 
261
267
  result = build_result(:success)
262
-
268
+
263
269
  # Save snapshots of modified files for Time Machine
264
270
  if @modified_files_in_task && !@modified_files_in_task.empty?
265
271
  save_modified_files_snapshot(@modified_files_in_task)
@@ -488,16 +494,16 @@ module Clacky
488
494
  progress_shown = false
489
495
  progress_timer = nil
490
496
  output_buffer = nil
491
-
497
+
492
498
  if @ui
493
499
  progress_message = build_tool_progress_message(call[:name], args)
494
-
500
+
495
501
  # For shell commands, create shared output buffer
496
502
  if call[:name] == "shell" || call[:name] == "safe_shell"
497
503
  output_buffer = { content: "", timestamp: Time.now }
498
504
  args[:output_buffer] = output_buffer
499
505
  end
500
-
506
+
501
507
  progress_timer = Thread.new do
502
508
  sleep 2
503
509
  @ui.show_progress(progress_message, prefix_newline: false, output_buffer: output_buffer)
@@ -670,7 +676,7 @@ module Clacky
670
676
  # @return [Agent] New subagent instance
671
677
  def fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil)
672
678
  # Clone config to avoid affecting parent
673
- subagent_config = @config.dup
679
+ subagent_config = @config.deep_copy
674
680
 
675
681
  # Switch to specified model if provided
676
682
  if model
@@ -700,12 +706,13 @@ module Clacky
700
706
  anthropic_format: subagent_config.anthropic_format?
701
707
  )
702
708
 
703
- # Create subagent (reuses all tools from parent)
709
+ # Create subagent (reuses all tools from parent, inherits agent profile from parent)
704
710
  subagent = self.class.new(
705
711
  subagent_client,
706
712
  subagent_config,
707
713
  working_dir: @working_dir,
708
- ui: @ui
714
+ ui: @ui,
715
+ profile: @agent_profile.name
709
716
  )
710
717
 
711
718
  # Deep clone messages to avoid cross-contamination
@@ -829,7 +836,7 @@ module Clacky
829
836
  # @param args [Hash] Arguments passed to the tool
830
837
  def track_modified_files(tool_name, args)
831
838
  @modified_files_in_task ||= []
832
-
839
+
833
840
  case tool_name
834
841
  when "write", "edit"
835
842
  file_path = args[:path]
@@ -226,6 +226,16 @@ module Clacky
226
226
  end
227
227
 
228
228
  # Save configuration to file
229
+ # Deep copy — models array contains mutable Hashes, so a shallow dup would
230
+ # let the copy share the same Hash objects with the original, causing
231
+ # Settings changes to silently mutate already-running session configs.
232
+ # JSON round-trip is the cleanest approach since @models is pure JSON-able data.
233
+ def deep_copy
234
+ copy = dup
235
+ copy.instance_variable_set(:@models, JSON.parse(JSON.generate(@models)))
236
+ copy
237
+ end
238
+
229
239
  def save(config_file = CONFIG_FILE)
230
240
  config_dir = File.dirname(config_file)
231
241
  FileUtils.mkdir_p(config_dir)
@@ -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)
@@ -1076,13 +1115,11 @@ module Clacky
1076
1115
  session_id = @registry.create(name: name, working_dir: working_dir)
1077
1116
 
1078
1117
  client = @client_factory.call
1079
- config = @agent_config.dup
1118
+ config = @agent_config.deep_copy
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
@@ -1105,12 +1142,10 @@ module Clacky
1105
1142
  session_id: original_id)
1106
1143
 
1107
1144
  client = @client_factory.call
1108
- config = @agent_config.dup
1109
- agent = Clacky::Agent.from_session(client, config, session_data, profile: "general")
1110
-
1145
+ config = @agent_config.deep_copy
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.3"
5
5
  end
@@ -401,6 +401,7 @@ body {
401
401
  #chat-title { font-weight: 600; font-size: 15px; }
402
402
  .status-idle { font-size: 11px; color: #8b949e; }
403
403
  .status-running { font-size: 11px; color: #3fb950; }
404
+ .status-error { font-size: 11px; color: #f85149; }
404
405
 
405
406
  /* Delete session button in chat header */
406
407
  .btn-delete-session {
@@ -500,6 +501,52 @@ body {
500
501
  .tool-item-status.err { color: #f85149; }
501
502
  .tool-item-status.running { color: #58a6ff; animation: pulse 1.2s infinite; }
502
503
 
504
+ /* ── Thinking block (collapsible <think>...</think> sections) ─────────────── */
505
+ .thinking-block {
506
+ margin: 0 0 6px 0;
507
+ background: transparent;
508
+ overflow: hidden;
509
+ }
510
+
511
+ .thinking-summary {
512
+ display: inline-flex;
513
+ align-items: center;
514
+ gap: 5px;
515
+ padding: 0;
516
+ cursor: pointer;
517
+ user-select: none;
518
+ list-style: none;
519
+ color: #6e7681;
520
+ font-size: 12px;
521
+ font-style: italic;
522
+ transition: color 0.15s;
523
+ }
524
+ .thinking-summary::-webkit-details-marker { display: none; }
525
+ .thinking-summary:hover { color: #8b949e; }
526
+
527
+ .thinking-icon { display: none; }
528
+ .thinking-label { font-weight: 400; }
529
+ .thinking-chevron {
530
+ font-size: 12px;
531
+ color: inherit;
532
+ transition: transform 0.2s;
533
+ line-height: 1;
534
+ display: inline-block;
535
+ }
536
+ .thinking-block[open] .thinking-chevron { transform: rotate(90deg); }
537
+
538
+ .thinking-body {
539
+ margin-top: 2px;
540
+ margin-bottom: 10px;
541
+ padding-left: 14px;
542
+ font-size: 12px;
543
+ line-height: 1.5;
544
+ color: #6e7681;
545
+ white-space: pre-wrap;
546
+ word-break: break-word;
547
+ font-style: italic;
548
+ }
549
+
503
550
  /* Inline image thumbnails inside user message bubbles */
504
551
  .msg-image-thumb {
505
552
  display: block;
@@ -828,6 +875,7 @@ body {
828
875
  display: flex;
829
876
  flex-direction: column;
830
877
  gap: 14px;
878
+ margin-bottom: 16px;
831
879
  }
832
880
  .model-card-header {
833
881
  display: flex;
@@ -958,6 +1006,27 @@ body {
958
1006
  font-size: 13px;
959
1007
  padding: 6px 18px;
960
1008
  }
1009
+ .btn-set-default {
1010
+ font-size: 13px;
1011
+ padding: 6px 14px;
1012
+ background: #238636;
1013
+ color: #fff;
1014
+ border: none;
1015
+ border-radius: 6px;
1016
+ cursor: pointer;
1017
+ }
1018
+ .btn-set-default:hover {
1019
+ background: #2ea043;
1020
+ }
1021
+ .btn-set-default:disabled {
1022
+ background: #484f58;
1023
+ cursor: not-allowed;
1024
+ }
1025
+ .model-card-actions-row {
1026
+ display: flex;
1027
+ gap: 8px;
1028
+ align-items: center;
1029
+ }
961
1030
 
962
1031
  /* ── Modals ──────────────────────────────────────────────────────────────── */
963
1032
  .modal-overlay {
@@ -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;
@@ -23,6 +23,59 @@ const Sessions = (() => {
23
23
  let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
24
24
  let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
25
25
 
26
+ // ── Thinking block parser ──────────────────────────────────────────────
27
+ //
28
+ // Converts <think>...</think> blocks in assistant messages into collapsible
29
+ // "Thinking" sections. The block is collapsed by default and can be
30
+ // expanded by clicking the header.
31
+
32
+ function _parseThinkingBlocks(rawHtml) {
33
+ // rawHtml is already HTML-escaped text (via escapeHtml). We need to detect
34
+ // the escaped versions of <think> and </think>.
35
+ const OPEN = "&lt;think&gt;";
36
+ const CLOSE = "&lt;/think&gt;";
37
+
38
+ // Fast path: no thinking block present
39
+ if (!rawHtml.includes(OPEN)) return rawHtml;
40
+
41
+ let result = "";
42
+ let rest = rawHtml;
43
+
44
+ while (rest.includes(OPEN)) {
45
+ const openIdx = rest.indexOf(OPEN);
46
+ const closeIdx = rest.indexOf(CLOSE, openIdx + OPEN.length);
47
+
48
+ // Prepend any text before the <think> block
49
+ result += rest.slice(0, openIdx);
50
+
51
+ if (closeIdx === -1) {
52
+ // Unclosed <think> — treat remainder as plain text
53
+ result += rest.slice(openIdx);
54
+ rest = "";
55
+ break;
56
+ }
57
+
58
+ const thinkContent = rest.slice(openIdx + OPEN.length, closeIdx);
59
+ result += _buildThinkingBlock(thinkContent);
60
+ // Strip leading newlines after </think> to avoid blank space from pre-wrap
61
+ rest = rest.slice(closeIdx + CLOSE.length).replace(/^\n+/, "");
62
+ }
63
+
64
+ result += rest;
65
+ return result;
66
+ }
67
+
68
+ // Build the collapsible thinking block HTML for a given (already-escaped) content string.
69
+ function _buildThinkingBlock(escapedContent) {
70
+ return `<details class="thinking-block">` +
71
+ `<summary class="thinking-summary">` +
72
+ `<span class="thinking-chevron">›</span>` +
73
+ `<span class="thinking-label">Thought for a moment</span>` +
74
+ `</summary>` +
75
+ `<div class="thinking-body">${escapedContent}</div>` +
76
+ `</details>`;
77
+ }
78
+
26
79
  // ── Private helpers ────────────────────────────────────────────────────
27
80
 
28
81
  function _cacheActiveMessages() {
@@ -151,7 +204,7 @@ const Sessions = (() => {
151
204
  if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
152
205
  const el = document.createElement("div");
153
206
  el.className = "msg msg-assistant";
154
- el.innerHTML = escapeHtml(ev.content || "");
207
+ el.innerHTML = _parseThinkingBlocks(escapeHtml(ev.content || ""));
155
208
  container.appendChild(el);
156
209
  break;
157
210
  }
@@ -241,6 +294,21 @@ const Sessions = (() => {
241
294
  messages.appendChild(frag);
242
295
  messages.scrollTop = messages.scrollHeight;
243
296
  }
297
+
298
+ // Restore transient UI state based on session status after initial load
299
+ // (not prepend, which is scroll-up pagination — no need to re-restore then)
300
+ if (!prepend) {
301
+ const session = _sessions.find(s => s.id === id);
302
+ if (session) {
303
+ if (session.status === "running") {
304
+ // Agent is still running (e.g. page was refreshed mid-task)
305
+ Sessions.showProgress("Thinking…");
306
+ } else if (session.status === "error" && session.error) {
307
+ // Show the stored error message at the end of history
308
+ Sessions.appendMsg("error", session.error);
309
+ }
310
+ }
311
+ }
244
312
  }
245
313
  } finally {
246
314
  state.loading = false;
@@ -401,7 +469,13 @@ const Sessions = (() => {
401
469
 
402
470
  updateStatusBar(status) {
403
471
  $("chat-status").textContent = status || "idle";
404
- $("chat-status").className = status === "running" ? "status-running" : "status-idle";
472
+ if (status === "running") {
473
+ $("chat-status").className = "status-running";
474
+ } else if (status === "error") {
475
+ $("chat-status").className = "status-error";
476
+ } else {
477
+ $("chat-status").className = "status-idle";
478
+ }
405
479
  $("btn-interrupt").style.display = status === "running" ? "" : "none";
406
480
  },
407
481
 
@@ -447,7 +521,8 @@ const Sessions = (() => {
447
521
  const messages = $("messages");
448
522
  const el = document.createElement("div");
449
523
  el.className = `msg msg-${type}`;
450
- el.innerHTML = html;
524
+ // Parse thinking blocks out of assistant messages
525
+ el.innerHTML = type === "assistant" ? _parseThinkingBlocks(html) : html;
451
526
  messages.appendChild(el);
452
527
  messages.scrollTop = messages.scrollHeight;
453
528
  },
@@ -107,7 +107,10 @@ const Settings = (() => {
107
107
 
108
108
  <div class="model-card-footer">
109
109
  <span class="model-test-result" data-index="${index}"></span>
110
- <button class="btn-save-model btn-primary" data-index="${index}">Save</button>
110
+ <div class="model-card-actions-row">
111
+ ${!isDefault ? `<button class="btn-set-default" data-index="${index}" title="Set as default model">Set as Default</button>` : ""}
112
+ <button class="btn-save-model btn-primary" data-index="${index}">Save</button>
113
+ </div>
111
114
  </div>
112
115
  `;
113
116
 
@@ -143,6 +146,12 @@ const Settings = (() => {
143
146
  if (removeBtn) {
144
147
  removeBtn.addEventListener("click", () => _removeModel(index));
145
148
  }
149
+
150
+ // Set as default model
151
+ const setDefaultBtn = card.querySelector(".btn-set-default");
152
+ if (setDefaultBtn) {
153
+ setDefaultBtn.addEventListener("click", () => _setAsDefault(index));
154
+ }
146
155
  }
147
156
 
148
157
  // ── Read form values from a card ────────────────────────────────────────────
@@ -233,16 +242,69 @@ const Settings = (() => {
233
242
  el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
234
243
  }
235
244
 
245
+ // ── Set as Default Model ───────────────────────────────────────────────────
246
+
247
+ async function _setAsDefault(index) {
248
+ const btn = document.querySelector(`.btn-set-default[data-index="${index}"]`);
249
+ if (!btn) return;
250
+
251
+ btn.disabled = true;
252
+ btn.textContent = "Setting…";
253
+
254
+ // Set the selected one as "default", others as null (not just delete)
255
+ // Using null ensures the server explicitly updates/removes the type field
256
+ const updatedModels = _models.map((m, i) => {
257
+ const model = { ...m };
258
+ if (i === index) {
259
+ model.type = "default";
260
+ } else {
261
+ model.type = null;
262
+ }
263
+ return model;
264
+ });
265
+
266
+ try {
267
+ const res = await fetch("/api/config", {
268
+ method: "POST",
269
+ headers: { "Content-Type": "application/json" },
270
+ body: JSON.stringify({ models: updatedModels })
271
+ });
272
+ const data = await res.json();
273
+
274
+ if (data.ok) {
275
+ btn.textContent = "Done ✓";
276
+ // Reload to refresh the UI
277
+ setTimeout(_load, 800);
278
+ } else {
279
+ btn.textContent = "Set as Default";
280
+ btn.disabled = false;
281
+ alert(data.error || "Failed to set default model");
282
+ }
283
+ } catch (e) {
284
+ btn.textContent = "Set as Default";
285
+ btn.disabled = false;
286
+ alert("Error: " + e.message);
287
+ }
288
+ }
289
+
236
290
  // ── Add / Remove model ───────────────────────────────────────────────────────
237
291
 
238
292
  function _addModel() {
293
+ // When adding a new model, automatically set it as default.
294
+ // Set all existing models' type to null (not just delete) so server updates them.
295
+ _models = _models.map(m => {
296
+ const model = { ...m };
297
+ model.type = null;
298
+ return model;
299
+ });
300
+
239
301
  _models.push({
240
302
  index: _models.length,
241
303
  model: "",
242
304
  base_url: "",
243
305
  api_key_masked: "",
244
306
  anthropic_format: false,
245
- type: null
307
+ type: "default" // New model automatically becomes default
246
308
  });
247
309
  _renderCards();
248
310
  // Scroll to the new card
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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy