anima-core 1.0.1 → 1.0.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.
@@ -5,21 +5,37 @@
5
5
  # aggregated tool counter instead. Verbose mode returns tool name
6
6
  # and a formatted preview of the input arguments. Debug mode shows
7
7
  # full untruncated input as pretty-printed JSON with tool_use_id.
8
+ #
9
+ # Think tool calls are special: "aloud" thoughts are shown in all
10
+ # view modes (with a thought bubble), while "inner" thoughts are
11
+ # visible only in verbose and debug modes.
8
12
  class ToolCallDecorator < EventDecorator
9
- # @return [nil] tool calls are hidden in basic mode
13
+ THINK_TOOL = "think"
14
+
15
+ # In basic mode, only "aloud" think calls are visible.
16
+ # All other tool calls are hidden (represented by the tool counter).
17
+ #
18
+ # @return [Hash, nil] structured think data for aloud thoughts, nil otherwise
10
19
  def render_basic
11
- nil
20
+ return unless think?
21
+ return unless aloud?
22
+
23
+ {role: :think, content: thoughts, visibility: "aloud"}
12
24
  end
13
25
 
14
26
  # @return [Hash] structured tool call data
15
27
  # `{role: :tool_call, tool: String, input: String, timestamp: Integer|nil}`
16
28
  def render_verbose
29
+ return render_think_verbose if think?
30
+
17
31
  {role: :tool_call, tool: payload["tool_name"], input: format_input, timestamp: timestamp}
18
32
  end
19
33
 
20
34
  # @return [Hash] full tool call data with untruncated input and tool_use_id
21
35
  # `{role: :tool_call, tool: String, input: String, tool_use_id: String|nil, timestamp: Integer|nil}`
22
36
  def render_debug
37
+ return render_think_debug if think?
38
+
23
39
  {
24
40
  role: :tool_call,
25
41
  tool: payload["tool_name"],
@@ -29,8 +45,49 @@ class ToolCallDecorator < EventDecorator
29
45
  }
30
46
  end
31
47
 
48
+ # Think calls get full text — the agent's reasoning IS the signal.
49
+ # Other tool calls show tool name + params (compact JSON).
50
+ # @return [String] transcript line for the analytical brain
51
+ def render_brain
52
+ if think?
53
+ "Think: #{thoughts}"
54
+ else
55
+ "Tool call: #{payload["tool_name"]}(#{tool_input.to_json})"
56
+ end
57
+ end
58
+
32
59
  private
33
60
 
61
+ def think?
62
+ payload["tool_name"] == THINK_TOOL
63
+ end
64
+
65
+ def aloud?
66
+ tool_input.dig("visibility") == "aloud"
67
+ end
68
+
69
+ def thoughts
70
+ tool_input.dig("thoughts").to_s
71
+ end
72
+
73
+ def tool_input
74
+ payload["tool_input"] || {}
75
+ end
76
+
77
+ def visibility
78
+ tool_input.dig("visibility") || "inner"
79
+ end
80
+
81
+ # @return [Hash] think event for verbose mode — both inner and aloud visible
82
+ def render_think_verbose
83
+ {role: :think, content: thoughts, visibility: visibility, timestamp: timestamp}
84
+ end
85
+
86
+ # @return [Hash] think event for debug mode — full metadata
87
+ def render_think_debug
88
+ {role: :think, content: thoughts, visibility: visibility, tool_use_id: payload["tool_use_id"], timestamp: timestamp}
89
+ end
90
+
34
91
  # Formats tool input for display, with tool-specific formatting for
35
92
  # known tools and generic JSON fallback for others.
36
93
  # @return [String] formatted input preview
@@ -5,15 +5,22 @@
5
5
  # aggregated tool counter instead. Verbose mode returns truncated
6
6
  # output with a success/failure indicator. Debug mode shows full
7
7
  # untruncated output with tool_use_id and estimated token count.
8
+ #
9
+ # Think tool responses ("OK") are hidden in basic and verbose modes
10
+ # because the value is in the tool_call (the thoughts), not the response.
8
11
  class ToolResponseDecorator < EventDecorator
12
+ THINK_TOOL = "think"
13
+
9
14
  # @return [nil] tool responses are hidden in basic mode
10
15
  def render_basic
11
16
  nil
12
17
  end
13
18
 
14
- # @return [Hash] structured tool response data
15
- # `{role: :tool_response, content: String, success: Boolean, timestamp: Integer|nil}`
19
+ # Think responses are hidden in verbose mode — the "OK" adds no information.
20
+ # @return [Hash, nil] structured tool response data, nil for think responses
16
21
  def render_verbose
22
+ return if think?
23
+
17
24
  {
18
25
  role: :tool_response,
19
26
  content: truncate_lines(content, max_lines: 3),
@@ -34,4 +41,19 @@ class ToolResponseDecorator < EventDecorator
34
41
  timestamp: timestamp
35
42
  }.merge(token_info)
36
43
  end
44
+
45
+ # Think responses ("OK") are noise — excluded from the brain's transcript.
46
+ # Other tool responses are compressed to success/failure indicators only.
47
+ # @return [String, nil] ✅ or ❌ indicator, nil for think responses
48
+ def render_brain
49
+ return if think?
50
+
51
+ (payload["success"] != false) ? "\u2705" : "\u274C"
52
+ end
53
+
54
+ private
55
+
56
+ def think?
57
+ payload["tool_name"] == THINK_TOOL
58
+ end
37
59
  end
@@ -26,6 +26,12 @@ class UserMessageDecorator < EventDecorator
26
26
  render_verbose.merge(token_info)
27
27
  end
28
28
 
29
+ # @return [String] user message for the analytical brain, middle-truncated
30
+ # if very long (preserves intent at start and conclusion at end)
31
+ def render_brain
32
+ "User: #{truncate_middle(content)}"
33
+ end
34
+
29
35
  private
30
36
 
31
37
  # @return [Boolean] true when this message is queued but not yet sent to LLM
@@ -64,6 +64,7 @@ class AgentRequestJob < ApplicationJob
64
64
  session.schedule_analytical_brain!
65
65
  ensure
66
66
  release_processing(session_id)
67
+ clear_interrupt(session_id)
67
68
  agent_loop&.finalize
68
69
  end
69
70
 
@@ -96,6 +97,13 @@ class AgentRequestJob < ApplicationJob
96
97
  Session.where(id: session_id).update_all(processing: false)
97
98
  end
98
99
 
100
+ # Safety-net clearing of the interrupt flag. The primary clear happens in
101
+ # {LLM::Client#clear_interrupt!} after handling the interrupt; this ensures
102
+ # the flag is reset even if the job crashes before reaching that code path.
103
+ def clear_interrupt(session_id)
104
+ Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
105
+ end
106
+
99
107
  # Emits a system message before each retry so the user sees
100
108
  # "retrying..." instead of nothing.
101
109
  def retry_job(options = {})
@@ -0,0 +1,5 @@
1
+ class AddInterruptRequestedToSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :sessions, :interrupt_requested, :boolean, default: false, null: false
4
+ end
5
+ end
data/lib/agent_loop.rb CHANGED
@@ -65,7 +65,11 @@ class AgentLoop
65
65
  # propagate — designed for callers like {AgentRequestJob} that handle
66
66
  # retries and need errors to bubble up.
67
67
  #
68
- # @return [String] the agent's response text
68
+ # When the user interrupts, +chat_with_tools+ returns nil. Tool results
69
+ # are already persisted; no agent message is emitted so the conversation
70
+ # ends at the interrupted tool result.
71
+ #
72
+ # @return [String, nil] the agent's response text, or nil when interrupted
69
73
  # @raise [Providers::Anthropic::TransientError] on retryable network/server errors
70
74
  # @raise [Providers::Anthropic::AuthenticationError] on auth failures
71
75
  def run
@@ -82,6 +86,8 @@ class AgentLoop
82
86
  options[:system] = prompt if prompt
83
87
 
84
88
  response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id, **options)
89
+ return unless response
90
+
85
91
  Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
86
92
  response
87
93
  end
@@ -94,7 +100,7 @@ class AgentLoop
94
100
 
95
101
  # Tool classes available to all sessions by default.
96
102
  # @return [Array<Class<Tools::Base>>]
97
- STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet].freeze
103
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think].freeze
98
104
 
99
105
  # Name-to-class mapping for tool restriction validation and registry building.
100
106
  # @return [Hash{String => Class<Tools::Base>}]
@@ -129,7 +129,7 @@ module AnalyticalBrain
129
129
  events = recent_events
130
130
  return [] if events.empty?
131
131
 
132
- transcript = events.filter_map { |event| format_event(event) }.join("\n")
132
+ transcript = events.filter_map { |event| EventDecorator.for(event)&.render("brain") }.join("\n")
133
133
  content = <<~MSG.strip
134
134
  The main session is working on this:
135
135
  ```
@@ -151,24 +151,6 @@ module AnalyticalBrain
151
151
  .reverse
152
152
  end
153
153
 
154
- # Formats a single event for the analytical brain's transcript.
155
- # User/agent messages get 500 chars to preserve conversation context;
156
- # tool responses get 200 chars to reduce noise from verbose outputs.
157
- #
158
- # @param event [Event]
159
- # @return [String, nil] formatted line, or nil for unhandled event types
160
- def format_event(event)
161
- payload = event.payload
162
- summary = payload["content"].to_s.truncate(500)
163
-
164
- case event.event_type
165
- when "user_message" then "User: #{summary}"
166
- when "agent_message" then "Assistant: #{summary}"
167
- when "tool_call" then "Tool call: #{payload["tool_name"]}"
168
- when "tool_response" then "Tool result: #{summary.truncate(200)}"
169
- end
170
- end
171
-
172
154
  # Builds the system prompt with current session state, skills catalog,
173
155
  # and currently active skills.
174
156
  #
data/lib/anima/cli.rb CHANGED
@@ -20,6 +20,38 @@ module Anima
20
20
  Installer.new.run
21
21
  end
22
22
 
23
+ desc "update", "Upgrade gem and migrate config"
24
+ option :migrate_only, type: :boolean, default: false, desc: "Skip gem upgrade, only migrate config"
25
+ def update
26
+ unless options[:migrate_only]
27
+ say "Upgrading anima-core gem..."
28
+ unless system("gem", "update", "anima-core")
29
+ say "Gem update failed.", :red
30
+ exit 1
31
+ end
32
+
33
+ # Re-exec with the updated gem so migration uses the new template.
34
+ exec(File.join(Gem.bindir, "anima"), "update", "--migrate-only")
35
+ end
36
+
37
+ say "Migrating configuration..."
38
+ require_relative "config_migrator"
39
+ result = Anima::ConfigMigrator.new.run
40
+
41
+ case result.status
42
+ when :not_found
43
+ say "Config file not found. Run 'anima install' first.", :red
44
+ exit 1
45
+ when :up_to_date
46
+ say "Config is already up to date."
47
+ when :updated
48
+ result.additions.each do |addition|
49
+ say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
50
+ end
51
+ say "Config updated. Changes take effect immediately — no restart needed."
52
+ end
53
+ end
54
+
23
55
  # Start the Anima brain server (Puma + Solid Queue) via Foreman.
24
56
  # Environment precedence: -e flag > RAILS_ENV env var > "development".
25
57
  # Requires prior installation (~/.anima must exist).
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+ require "pathname"
5
+
6
+ module Anima
7
+ # Merges new default settings into an existing config.toml without
8
+ # overwriting user-customized values.
9
+ #
10
+ # Preserves the user's formatting and comments by extracting text blocks
11
+ # from the template and appending them to the config file. Missing entire
12
+ # sections are appended with their separator comments; missing keys within
13
+ # existing sections are inserted at the end of the section.
14
+ #
15
+ # @example
16
+ # result = ConfigMigrator.new.run
17
+ # result.status #=> :updated
18
+ # result.additions #=> [#<Addition section="paths" key="soul" value="/home/...">]
19
+ class ConfigMigrator
20
+ ANIMA_HOME = File.expand_path("~/.anima")
21
+ TEMPLATE_PATH = File.expand_path("../../templates/config.toml", __dir__).freeze
22
+
23
+ # A single config key that was added during migration.
24
+ # @!attribute [r] section [String] TOML section name
25
+ # @!attribute [r] key [String] key name within the section
26
+ # @!attribute [r] value [Object] default value from the template
27
+ Addition = Data.define(:section, :key, :value)
28
+
29
+ # Outcome of a migration run.
30
+ # @!attribute [r] status [Symbol] :not_found, :up_to_date, or :updated
31
+ # @!attribute [r] additions [Array<Addition>] keys that were added
32
+ Result = Data.define(:status, :additions)
33
+
34
+ # Section separator pattern used in the template (e.g. "# ─── LLM ───...").
35
+ SEPARATOR_PATTERN = /^# ─── /
36
+
37
+ # @param config_path [String] path to the user's config.toml
38
+ # @param template_path [String] path to the default config template
39
+ # @param anima_home [String] expanded path to ~/.anima (for template interpolation)
40
+ def initialize(config_path: File.join(ANIMA_HOME, "config.toml"),
41
+ template_path: TEMPLATE_PATH,
42
+ anima_home: ANIMA_HOME)
43
+ @config_path = Pathname.new(config_path)
44
+ @template_path = Pathname.new(template_path)
45
+ @anima_home = anima_home
46
+ end
47
+
48
+ # Merge missing settings from the template into the user's config.
49
+ #
50
+ # @return [Result] status (:not_found, :up_to_date, :updated) and additions list
51
+ def run
52
+ return Result.new(status: :not_found, additions: []) unless @config_path.exist?
53
+
54
+ template_text = resolve_template
55
+ template_config = TomlRB.parse(template_text)
56
+ user_config = TomlRB.load_file(@config_path.to_s)
57
+
58
+ additions = find_additions(user_config, template_config)
59
+ return Result.new(status: :up_to_date, additions: []) if additions.empty?
60
+
61
+ apply_additions(additions, template_text)
62
+ Result.new(status: :updated, additions: additions)
63
+ end
64
+
65
+ private
66
+
67
+ # Replace template placeholders with actual paths.
68
+ def resolve_template
69
+ File.read(@template_path.to_s).gsub("{{ANIMA_HOME}}") { @anima_home }
70
+ end
71
+
72
+ # Compare user config against template defaults.
73
+ # Returns additions for keys present in template but absent from user config.
74
+ def find_additions(user, template)
75
+ template.flat_map do |section, keys|
76
+ missing_keys_in_section(user, section, keys)
77
+ end
78
+ end
79
+
80
+ def missing_keys_in_section(user, section, keys)
81
+ keys.filter_map do |key, value|
82
+ next if user.key?(section) && user[section].key?(key)
83
+
84
+ Addition.new(section: section, key: key, value: value)
85
+ end
86
+ end
87
+
88
+ # Write missing settings into the user's config file, preserving existing content.
89
+ def apply_additions(additions, template_text)
90
+ user_text = @config_path.read
91
+ template_blocks = parse_section_blocks(template_text)
92
+
93
+ missing_sections, missing_keys = additions.partition do |addition|
94
+ !user_text.match?(/^\[#{Regexp.escape(addition.section)}\]/)
95
+ end
96
+
97
+ user_text = append_missing_sections(user_text, missing_sections, template_blocks)
98
+ user_text = insert_missing_keys(user_text, missing_keys, template_text)
99
+
100
+ @config_path.write(user_text)
101
+ end
102
+
103
+ def append_missing_sections(user_text, missing_sections, template_blocks)
104
+ missing_sections.map(&:section).uniq.each do |section|
105
+ block = template_blocks[section]
106
+ next unless block
107
+
108
+ user_text = "#{user_text.rstrip}\n\n#{block.rstrip}\n"
109
+ end
110
+ user_text
111
+ end
112
+
113
+ def insert_missing_keys(user_text, missing_keys, template_text)
114
+ missing_keys.each do |addition|
115
+ section = addition.section
116
+ key_block = extract_key_block(template_text, section, addition.key)
117
+ next unless key_block
118
+
119
+ user_text = insert_key_in_section(user_text, section, key_block)
120
+ end
121
+ user_text
122
+ end
123
+
124
+ # Split template into section blocks keyed by TOML section name.
125
+ # Each block spans from its separator comment to the next separator (exclusive).
126
+ def parse_section_blocks(template_text)
127
+ lines = template_text.lines
128
+ separator_indices = lines.each_index.select { |idx| lines[idx].match?(SEPARATOR_PATTERN) }
129
+ block_ranges = build_block_ranges(separator_indices, lines.length)
130
+
131
+ block_ranges.each_with_object({}) do |(start_idx, end_idx), blocks|
132
+ block_lines = lines[start_idx..end_idx]
133
+ section_name = extract_section_name(block_lines)
134
+ blocks[section_name] = block_lines.join if section_name
135
+ end
136
+ end
137
+
138
+ # Find the TOML section name (e.g. "llm") within a block of lines.
139
+ def extract_section_name(block_lines)
140
+ header = block_lines.find { |line| line.match?(/^\[\w+\]/) }
141
+ header&.match(/^\[(\w+)\]/)&.[](1)
142
+ end
143
+
144
+ # Build [start, end] pairs from separator indices.
145
+ def build_block_ranges(separator_indices, total_lines)
146
+ separator_indices.each_with_index.map do |start_idx, position|
147
+ next_pos = position + 1
148
+ end_idx = (next_pos < separator_indices.length) ? separator_indices[next_pos] - 1 : total_lines - 1
149
+ [start_idx, end_idx]
150
+ end
151
+ end
152
+
153
+ # Extract a single key and its preceding comment lines from the template.
154
+ def extract_key_block(template_text, section, key)
155
+ lines = template_text.lines
156
+ in_section = false
157
+
158
+ lines.each_with_index do |line, line_idx|
159
+ if line.match?(/^\[#{Regexp.escape(section)}\]/)
160
+ in_section = true
161
+ elsif line.match?(/^\[/) && in_section
162
+ break
163
+ elsif in_section && line.match?(/^#{Regexp.escape(key)}\s*=/)
164
+ return build_key_block(lines, line_idx)
165
+ end
166
+ end
167
+ nil
168
+ end
169
+
170
+ # Walk backward from a key line to collect preceding comment lines.
171
+ def build_key_block(lines, key_idx)
172
+ comment_start = key_idx
173
+ scan_idx = key_idx - 1
174
+ while scan_idx >= 0 && lines[scan_idx].match?(/^#/)
175
+ comment_start = scan_idx
176
+ scan_idx -= 1
177
+ end
178
+ "\n#{lines[comment_start..key_idx].join}"
179
+ end
180
+
181
+ # Insert a key block at the end of an existing section
182
+ # (before the next separator comment or EOF).
183
+ def insert_key_in_section(user_text, section, key_block)
184
+ lines = user_text.lines
185
+ insert_at = find_section_end(lines, section)
186
+ insert_at -= 1 while insert_at > 0 && lines[insert_at - 1].strip.empty?
187
+
188
+ lines.insert(insert_at, key_block)
189
+ lines.join
190
+ end
191
+
192
+ # Find the line index where a section ends (next separator or section header).
193
+ def find_section_end(lines, section)
194
+ in_section = false
195
+ lines.each_with_index do |line, line_idx|
196
+ if line.match?(/^\[#{Regexp.escape(section)}\]/)
197
+ in_section = true
198
+ elsif in_section && (line.match?(SEPARATOR_PATTERN) || line.match?(/^\[/))
199
+ return line_idx
200
+ end
201
+ end
202
+ lines.length
203
+ end
204
+ end
205
+ end
@@ -75,124 +75,8 @@ module Anima
75
75
  config_path = anima_home.join("config.toml")
76
76
  return if config_path.exist?
77
77
 
78
- config_path.write(<<~TOML)
79
- # Anima Configuration
80
- #
81
- # Edit settings below to customize Anima's behavior.
82
- # Changes take effect immediately — no restart needed.
83
-
84
- # ─── LLM ───────────────────────────────────────────────────────
85
-
86
- [llm]
87
-
88
- # Primary model for conversations.
89
- model = "claude-sonnet-4-20250514"
90
-
91
- # Lightweight model for fast tasks (e.g. session naming).
92
- fast_model = "claude-haiku-4-5"
93
-
94
- # Maximum tokens per LLM response.
95
- max_tokens = 8192
96
-
97
- # Maximum consecutive tool execution rounds per request.
98
- max_tool_rounds = 25
99
-
100
- # Context window budget — tokens reserved for conversation history.
101
- # Set this based on your model's context window minus system prompt.
102
- token_budget = 190_000
103
-
104
- # ─── Timeouts (seconds) ─────────────────────────────────────────
105
-
106
- [timeouts]
107
-
108
- # LLM API request timeout.
109
- api = 30
110
-
111
- # Shell command execution timeout.
112
- command = 30
113
-
114
- # MCP server response timeout.
115
- mcp_response = 60
116
-
117
- # Web fetch request timeout.
118
- web_request = 10
119
-
120
- # ─── Shell ──────────────────────────────────────────────────────
121
-
122
- [shell]
123
-
124
- # Maximum bytes of command output before truncation.
125
- max_output_bytes = 100_000
126
-
127
- # ─── Tools ──────────────────────────────────────────────────────
128
-
129
- [tools]
130
-
131
- # Maximum file size for read/edit operations (bytes).
132
- max_file_size = 10_485_760
133
-
134
- # Maximum lines returned by the read tool.
135
- max_read_lines = 2_000
136
-
137
- # Maximum bytes returned by the read tool.
138
- max_read_bytes = 50_000
139
-
140
- # Maximum bytes from web GET responses.
141
- max_web_response_bytes = 100_000
142
-
143
- # ─── Environment ──────────────────────────────────────────────
144
-
145
- [environment]
146
-
147
- # Files to scan for in the working directory (at root and up to project_files_max_depth subdirectories deep).
148
- project_files = ["CLAUDE.md", "AGENTS.md", "README.md", "CONTRIBUTING.md"]
149
-
150
- # Maximum directory depth for project file scanning.
151
- project_files_max_depth = 3
152
-
153
- # ─── GitHub ─────────────────────────────────────────────────────
154
-
155
- [github]
156
-
157
- # Repository for agent feature requests (owner/repo format).
158
- # Falls back to parsing git remote origin when unset.
159
- repo = "hoblin/anima"
160
-
161
- # Label applied to agent-created feature request issues.
162
- label = "anima-wants"
163
-
164
- # ─── Paths ─────────────────────────────────────────────────────
165
-
166
- [paths]
167
-
168
- # The agent's self-authored identity file.
169
- soul = "#{anima_home.join("soul.md")}"
170
-
171
- # ─── Session ────────────────────────────────────────────────────
172
-
173
- [session]
174
-
175
- # Regenerate session name every N messages.
176
- name_generation_interval = 30
177
-
178
- # ─── Analytical Brain ─────────────────────────────────────────
179
-
180
- [analytical_brain]
181
-
182
- # Maximum tokens per analytical brain response.
183
- # Must accommodate multiple tool calls (rename + goals + skills + ready).
184
- max_tokens = 4096
185
-
186
- # Run the analytical brain synchronously before the main agent on user messages.
187
- # Ensures activated skills are available for the current response.
188
- blocking_on_user_message = true
189
-
190
- # Run the analytical brain asynchronously after the main agent completes.
191
- blocking_on_agent_message = false
192
-
193
- # Number of recent events to include in the analytical brain's context window.
194
- event_window = 20
195
- TOML
78
+ template = File.read(File.join(TEMPLATE_DIR, "config.toml"))
79
+ config_path.write(template.gsub("{{ANIMA_HOME}}") { anima_home.to_s })
196
80
  say " created #{config_path}"
197
81
  end
198
82
 
@@ -191,7 +191,7 @@ module Anima
191
191
  value = config.dig(section, key)
192
192
  if value.nil?
193
193
  raise MissingSettingError,
194
- "[#{section}] #{key} is not set in #{config_path}. Run `anima install` to create the config file."
194
+ "[#{section}] #{key} is not set in #{config_path}. Run `anima update` to add missing settings."
195
195
  end
196
196
  value
197
197
  end
data/lib/anima/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
  end