anima-core 1.1.2 → 1.1.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: 19dbc9d1eaa160cc21e98ad347f86968ad4ce5cb31f2bc3024844a1e0cd3c8e4
4
- data.tar.gz: 280f6848271d0c97958dddc467543bb4b747c5a1e8a2e417f0c88e21b9612f4e
3
+ metadata.gz: 583896eaa09036e71d6e6b6ee013a021965acc193340ba735e4c0a0a6be6e6dc
4
+ data.tar.gz: f1f173f5129c37785a01119daa3cce27a0b935e0fdac256ffb0b412aad87483c
5
5
  SHA512:
6
- metadata.gz: 9c43ec74eabd07e8ce2be3f8961e67f16858f2de20e828730f10b00dafe9cf2e3e9f3e96ed018718283d2093ae20bd0933931ce9807c29959b95b1f8c2a9a17c
7
- data.tar.gz: 6bcb6b84581afb310b98899a40d2aa04d87cbcbd7790970fa9053c817886580eab5fe73f39a37944c7421ef9c190e5be26ffd2abac28a0afd7306dae5e2f19da
6
+ metadata.gz: 312effa8e79b480bd33a78093f192d6a6997fc0430aac6fe09528da0c95461feade771b3ecbcd13c0b7ad036d8a1c4cd2e6a443da61332baf3a8b34c57626ca5
7
+ data.tar.gz: 7c3578adb7158f0c32caa7f26ec3ea4d8189502e0bab27b0070f7a98a6423c6d311ddcd429edb7d451dadecd4282b5c7e874b6ce302fccdd1337103034eee1d2
data/.reek.yml CHANGED
@@ -39,6 +39,10 @@ detectors:
39
39
  - "Tools::SubagentPrompts#assign_nickname_via_brain"
40
40
  # Validation methods naturally reference the validated value more than self.
41
41
  - "AnalyticalBrain::Tools::AssignNickname#validate"
42
+ # Tool execute methods naturally reference input hash and shell result hash.
43
+ - "Tools::Bash#execute"
44
+ - "Tools::Bash#execute_single"
45
+ - "Tools::Bash#execute_batch"
42
46
  # Delivery method orchestrates session, event, and agent_loop — inherent.
43
47
  - "AgentRequestJob#deliver_persisted_event"
44
48
  # Private helpers don't need instance state to be valid.
@@ -77,9 +81,11 @@ detectors:
77
81
  # Runner checks session type to compose responsibilities — the core dispatch.
78
82
  - "AnalyticalBrain::Runner"
79
83
  # EventDecorator holds shared rendering constants (icons, markers, dispatch maps).
84
+ # Event model holds domain type constants (TYPES, CONTEXT_TYPES, SPAWN_TOOLS, etc.).
80
85
  TooManyConstants:
81
86
  exclude:
82
87
  - "EventDecorator"
88
+ - "Event"
83
89
  # encode_utf8 is descriptive — the digit triggers a false positive.
84
90
  UncommunicativeMethodName:
85
91
  exclude:
@@ -51,6 +51,7 @@ class SessionChannel < ApplicationCable::Channel
51
51
  # until the current agent loop completes.
52
52
  #
53
53
  # @param data [Hash] must include "content" with the user's message text
54
+ # @see Session#enqueue_user_message
54
55
  def speak(data)
55
56
  content = data["content"].to_s.strip
56
57
  return if content.empty?
@@ -58,12 +59,7 @@ class SessionChannel < ApplicationCable::Channel
58
59
  session = Session.find_by(id: @current_session_id)
59
60
  return unless session
60
61
 
61
- if session.processing?
62
- Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
63
- else
64
- event = session.create_user_event(content)
65
- AgentRequestJob.perform_later(session.id, event_id: event.id)
66
- end
62
+ session.enqueue_user_message(content, bounce_back: true)
67
63
  end
68
64
 
69
65
  # Recalls the most recent pending message for editing. Deletes the
data/app/models/event.rb CHANGED
@@ -14,7 +14,9 @@
14
14
  # @!attribute token_count
15
15
  # @return [Integer] cached token count for this event's payload (0 until counted)
16
16
  # @!attribute tool_use_id
17
- # @return [String, nil] Anthropic-assigned ID correlating tool_call and tool_response
17
+ # @return [String] ID correlating tool_call and tool_response events
18
+ # (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
19
+ # required for tool_call and tool_response events)
18
20
  class Event < ApplicationRecord
19
21
  include Event::Broadcasting
20
22
 
@@ -23,8 +25,12 @@ class Event < ApplicationRecord
23
25
  CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
24
26
  CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
25
27
  THINK_TOOL = "think"
28
+ SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
26
29
  PENDING_STATUS = "pending"
27
30
 
31
+ # Event types that require a tool_use_id to pair call with response.
32
+ TOOL_TYPES = %w[tool_call tool_response].freeze
33
+
28
34
  ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
29
35
 
30
36
  # Heuristic: average bytes per token for English prose.
@@ -36,6 +42,8 @@ class Event < ApplicationRecord
36
42
  validates :event_type, presence: true, inclusion: {in: TYPES}
37
43
  validates :payload, presence: true
38
44
  validates :timestamp, presence: true
45
+ # Anthropic requires every tool_use to have a matching tool_result with the same ID
46
+ validates :tool_use_id, presence: true, if: -> { event_type.in?(TOOL_TYPES) }
39
47
 
40
48
  after_create :schedule_token_count, if: :llm_message?
41
49
 
@@ -60,6 +68,17 @@ class Event < ApplicationRecord
60
68
  # @return [ActiveRecord::Relation]
61
69
  scope :deliverable, -> { where(status: nil) }
62
70
 
71
+ # @!method self.excluding_spawn_events
72
+ # Excludes spawn_subagent/spawn_specialist tool_call and tool_response events.
73
+ # Used when building parent context for sub-agents — spawn events cause role
74
+ # confusion because the sub-agent sees sibling spawn results and mistakes
75
+ # itself for the parent.
76
+ # @return [ActiveRecord::Relation]
77
+ scope :excluding_spawn_events, -> {
78
+ where.not("event_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
79
+ TOOL_TYPES, SPAWN_TOOLS)
80
+ }
81
+
63
82
  # Maps event_type to the Anthropic Messages API role.
64
83
  # @return [String] "user" or "assistant"
65
84
  def api_role
@@ -94,7 +113,7 @@ class Event < ApplicationRecord
94
113
  #
95
114
  # @return [Integer] estimated token count (at least 1)
96
115
  def estimate_tokens
97
- text = if event_type.in?(%w[tool_call tool_response])
116
+ text = if event_type.in?(TOOL_TYPES)
98
117
  payload.to_json
99
118
  else
100
119
  payload["content"].to_s
@@ -294,8 +294,8 @@ class Session < ApplicationRecord
294
294
  # @return [Integer] number of synthetic responses created
295
295
  def heal_orphaned_tool_calls!
296
296
  now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
297
- responded_ids = events.where(event_type: "tool_response").where.not(tool_use_id: nil).select(:tool_use_id)
298
- unresponded = events.where(event_type: "tool_call").where.not(tool_use_id: nil)
297
+ responded_ids = events.where(event_type: "tool_response").select(:tool_use_id)
298
+ unresponded = events.where(event_type: "tool_call")
299
299
  .where.not(tool_use_id: responded_ids)
300
300
 
301
301
  healed = 0
@@ -321,8 +321,35 @@ class Session < ApplicationRecord
321
321
  healed
322
322
  end
323
323
 
324
- # Creates a user message event record directly (bypasses EventBus+Persister).
325
- # Used by {SessionChannel#speak} (immediate display), {AgentLoop#process},
324
+ # Delivers a user message respecting the session's processing state.
325
+ #
326
+ # When idle, persists the event directly and enqueues {AgentRequestJob}
327
+ # to process it. When mid-turn ({#processing?}), emits a pending
328
+ # {Events::UserMessage} via {Events::Bus} so it queues until the
329
+ # current agent loop completes — preventing interleaving between
330
+ # tool_use/tool_result pairs.
331
+ #
332
+ # @param content [String] user message text
333
+ # @param bounce_back [Boolean] when true, passes +event_id+ to the job
334
+ # so failed LLM delivery triggers a {Events::BounceBack} (used by
335
+ # {SessionChannel#speak} for immediate-display messages)
336
+ # @return [void]
337
+ def enqueue_user_message(content, bounce_back: false)
338
+ if processing?
339
+ Events::Bus.emit(Events::UserMessage.new(
340
+ content: content, session_id: id,
341
+ status: Event::PENDING_STATUS
342
+ ))
343
+ else
344
+ event = create_user_event(content)
345
+ job_args = bounce_back ? {event_id: event.id} : {}
346
+ AgentRequestJob.perform_later(id, **job_args)
347
+ end
348
+ end
349
+
350
+ # Persists a user message event directly, bypassing the pending queue.
351
+ #
352
+ # Used by {#enqueue_user_message} (idle path), {AgentLoop#process},
326
353
  # and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
327
354
  # because the global {Events::Subscribers::Persister} skips non-pending user
328
355
  # messages — these callers own the persistence lifecycle.
@@ -501,9 +528,14 @@ class Session < ApplicationRecord
501
528
  end
502
529
 
503
530
  # Scopes parent events created before this session's fork point.
531
+ # Excludes spawn tool events — sub-agents don't need to see sibling
532
+ # spawn pairs, which cause role confusion (the sub-agent mistakes
533
+ # itself for the parent when it sees "Specialist @sibling spawned...").
504
534
  # @return [ActiveRecord::Relation]
505
535
  def parent_event_scope(include_pending)
506
- scope = parent_session.events.context_events.where(created_at: ...created_at)
536
+ scope = parent_session.events.context_events
537
+ .excluding_spawn_events
538
+ .where(created_at: ...created_at)
507
539
  include_pending ? scope : scope.deliverable
508
540
  end
509
541
 
data/lib/agent_loop.rb CHANGED
@@ -129,6 +129,11 @@ class AgentLoop
129
129
  # @return [Array<Class<Tools::Base>>]
130
130
  STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
131
131
 
132
+ # Tools that bypass {Session#granted_tools} filtering.
133
+ # The agent's reasoning depends on these regardless of task scope.
134
+ # @return [Array<Class<Tools::Base>>]
135
+ ALWAYS_GRANTED_TOOLS = [Tools::Think].freeze
136
+
132
137
  # Name-to-class mapping for tool restriction validation and registry building.
133
138
  # @return [Hash{String => Class<Tools::Base>}]
134
139
  STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
@@ -194,12 +199,15 @@ class AgentLoop
194
199
 
195
200
  # Standard tools available to this session.
196
201
  # Returns all when {Session#granted_tools} is nil (no restriction).
197
- # Returns only matching tools when granted_tools is an array.
202
+ # Returns only matching tools when granted_tools is an array,
203
+ # always including {ALWAYS_GRANTED_TOOLS}.
198
204
  #
199
205
  # @return [Array<Class<Tools::Base>>] tool classes to register
200
206
  def granted_standard_tools
201
- return STANDARD_TOOLS unless @session.granted_tools
207
+ granted = @session.granted_tools
208
+ return STANDARD_TOOLS unless granted
202
209
 
203
- @session.granted_tools.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
210
+ explicitly_granted = granted.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
211
+ (ALWAYS_GRANTED_TOOLS + explicitly_granted).uniq
204
212
  end
205
213
  end
@@ -37,7 +37,7 @@ module Agents
37
37
  # @return [self]
38
38
  def load_all
39
39
  load_directory(BUILTIN_DIR)
40
- load_directory(USER_DIR)
40
+ load_directory(USER_DIR) unless Rails.env.test?
41
41
  self
42
42
  end
43
43
 
@@ -236,6 +236,10 @@ module Anima
236
236
  # @return [Integer]
237
237
  def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
238
238
 
239
+ # Recency decay factor for search ranking (0.0 = pure relevance).
240
+ # @return [Float]
241
+ def recall_recency_decay = get("recall", "recency_decay")
242
+
239
243
  private
240
244
 
241
245
  # Reads a setting from the config file.
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.1.2"
4
+ VERSION = "1.1.3"
5
5
  end
@@ -6,16 +6,19 @@ module Events
6
6
  # bidirectional @mention communication.
7
7
  #
8
8
  # **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
9
- # the router persists a {Events::UserMessage} in the parent session
10
- # with attribution prefix, then wakes the parent via {AgentRequestJob}.
9
+ # the router creates a {Events::UserMessage} in the parent session
10
+ # with attribution prefix. If the parent is idle, persists directly
11
+ # and wakes it via {AgentRequestJob}. If the parent is mid-turn,
12
+ # emits a pending message that is promoted after the current loop
13
+ # completes — same mechanism as {SessionChannel#speak}.
11
14
  #
12
15
  # **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
13
16
  # containing `@name` mentions, the router persists the message in each
14
17
  # matching child session and wakes them via {AgentRequestJob}.
15
18
  #
16
- # Both directions use direct persistence + job enqueue (same pattern as
17
- # {Tools::SpawnSubagent#spawn_child}) to avoid conflicts with the global
18
- # {Persister} which skips non-pending user messages.
19
+ # Both directions delegate to {Session#enqueue_user_message}, which
20
+ # respects the target session's processing state — persisting directly
21
+ # when idle, deferring via pending queue when mid-turn.
19
22
  #
20
23
  # This replaces the +return_result+ tool — sub-agents communicate
21
24
  # through natural text messages instead of structured tool calls.
@@ -60,9 +63,8 @@ module Events
60
63
 
61
64
  private
62
65
 
63
- # Forwards a sub-agent's text message to its parent session.
64
- # Persists directly and enqueues a job so the parent agent wakes
65
- # up to process the message.
66
+ # Forwards a sub-agent's text message to its parent session
67
+ # via {Session#enqueue_user_message}.
66
68
  #
67
69
  # @param child [Session] the sub-agent session
68
70
  # @param content [String] the sub-agent's message text
@@ -73,8 +75,7 @@ module Events
73
75
  name = child.name || "agent-#{child.id}"
74
76
  attributed = format(ATTRIBUTION_FORMAT, name, content)
75
77
 
76
- parent.create_user_event(attributed)
77
- AgentRequestJob.perform_later(parent.id)
78
+ parent.enqueue_user_message(attributed)
78
79
  end
79
80
 
80
81
  # Scans a parent agent's message for @mentions and routes the message
@@ -93,8 +94,7 @@ module Events
93
94
  child = active_children[name]
94
95
  next unless child
95
96
 
96
- child.create_user_event(content)
97
- AgentRequestJob.perform_later(child.id)
97
+ child.enqueue_user_message(content)
98
98
  end
99
99
  end
100
100
  end
data/lib/llm/client.rb CHANGED
@@ -177,9 +177,12 @@ module LLM
177
177
  # tool raises. Per the Anthropic tool-use protocol, every tool_use must
178
178
  # have a matching tool_result; a missing result permanently corrupts the
179
179
  # conversation history and breaks the session.
180
+ #
181
+ # Falls back to SecureRandom.uuid when Anthropic omits the tool_use id,
182
+ # ensuring the ToolCall/ToolResponse pair always shares a valid identifier.
180
183
  def execute_single_tool(tool_use, registry, session_id)
181
184
  name = tool_use["name"]
182
- id = tool_use["id"]
185
+ id = tool_use["id"] || SecureRandom.uuid
183
186
  input = tool_use["input"] || {}
184
187
  timeout = input["timeout"] || Anima::Settings.tool_timeout
185
188
 
@@ -231,7 +234,7 @@ module LLM
231
234
  # @return [Hash] tool_result content block
232
235
  def interrupt_tool(tool_use, session_id)
233
236
  name = tool_use["name"]
234
- id = tool_use["id"]
237
+ id = tool_use["id"] || SecureRandom.uuid
235
238
  input = tool_use["input"] || {}
236
239
 
237
240
  Events::Bus.emit(Events::ToolCall.new(
data/lib/mneme/search.rb CHANGED
@@ -21,7 +21,7 @@ module Mneme
21
21
  # @!attribute session_id [Integer] the session owning this event
22
22
  # @!attribute snippet [String] highlighted excerpt from the matching content
23
23
  # @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
24
- # @!attribute event_type [String] one of Event::TYPES
24
+ # @!attribute event_type [String] friendly label: human, anima, system, or thought
25
25
  Result = Struct.new(:event_id, :session_id, :snippet, :rank, :event_type, keyword_init: true)
26
26
 
27
27
  # Searches event history for the given terms.
@@ -38,6 +38,7 @@ module Mneme
38
38
  @terms = sanitize_query(terms)
39
39
  @session_id = session_id
40
40
  @limit = limit
41
+ @recency_decay = Anima::Settings.recall_recency_decay
41
42
  end
42
43
 
43
44
  # @return [Array<Result>] ranked by relevance (best first)
@@ -55,15 +56,23 @@ module Mneme
55
56
  #
56
57
  # @return [Array<Hash>] raw database rows
57
58
  def execute_fts_query
58
- if @session_id
59
- connection.select_all(scoped_sql, "Mneme::Search", [@terms, @session_id, @limit]).to_a
59
+ sql = if @session_id
60
+ Arel.sql(scoped_sql, @recency_decay, @terms, @session_id, @limit)
60
61
  else
61
- connection.select_all(global_sql, "Mneme::Search", [@terms, @limit]).to_a
62
+ Arel.sql(global_sql, @recency_decay, @terms, @limit)
62
63
  end
64
+
65
+ connection.select_all(sql, "Mneme::Search").to_a
63
66
  end
64
67
 
65
68
  # FTS5 query across all sessions.
66
69
  # Contentless FTS5 can't use snippet() — extract content from events directly.
70
+ #
71
+ # Ranking blends BM25 relevance with recency: rank is negative (more
72
+ # negative = better match), so dividing by a factor > 1 for older events
73
+ # moves them closer to zero (less relevant). At decay 0.3, a one-year-old
74
+ # result needs ~30% better keyword relevance to beat an identical match
75
+ # from today.
67
76
  def global_sql
68
77
  <<~SQL
69
78
  SELECT
@@ -76,7 +85,7 @@ module Mneme
76
85
  WHEN e.event_type = 'tool_call'
77
86
  THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
78
87
  END AS snippet,
79
- rank
88
+ rank / (1.0 + ? * (julianday('now') - julianday(e.created_at)) / 365.0) AS rank
80
89
  FROM events_fts
81
90
  JOIN events e ON e.id = events_fts.rowid
82
91
  WHERE events_fts MATCH ?
@@ -98,7 +107,7 @@ module Mneme
98
107
  WHEN e.event_type = 'tool_call'
99
108
  THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
100
109
  END AS snippet,
101
- rank
110
+ rank / (1.0 + ? * (julianday('now') - julianday(e.created_at)) / 365.0) AS rank
102
111
  FROM events_fts
103
112
  JOIN events e ON e.id = events_fts.rowid
104
113
  WHERE events_fts MATCH ?
@@ -108,17 +117,25 @@ module Mneme
108
117
  SQL
109
118
  end
110
119
 
120
+ FRIENDLY_EVENT_TYPES = {
121
+ "user_message" => "human",
122
+ "agent_message" => "anima",
123
+ "system_message" => "system",
124
+ "tool_call" => "thought"
125
+ }.freeze
126
+
111
127
  # Builds a Result from a raw database row.
112
128
  #
113
129
  # @param row [Hash]
114
130
  # @return [Result]
115
131
  def build_result(row)
132
+ raw_type = row["event_type"]
116
133
  Result.new(
117
134
  event_id: row["event_id"],
118
135
  session_id: row["session_id"],
119
136
  snippet: row["snippet"],
120
137
  rank: row["rank"],
121
- event_type: row["event_type"]
138
+ event_type: FRIENDLY_EVENT_TYPES.fetch(raw_type, raw_type)
122
139
  )
123
140
  end
124
141
 
@@ -40,7 +40,7 @@ module Skills
40
40
  # @return [self]
41
41
  def load_all
42
42
  load_directory(BUILTIN_DIR)
43
- load_directory(USER_DIR)
43
+ load_directory(USER_DIR) unless Rails.env.test?
44
44
  self
45
45
  end
46
46
 
data/lib/tools/bash.rb CHANGED
@@ -6,19 +6,42 @@ module Tools
6
6
  # conversation. Output is truncated and timeouts are enforced by the
7
7
  # underlying session.
8
8
  #
9
+ # Supports two modes:
10
+ # - Single command via +command+ (string) — backward compatible
11
+ # - Batch via +commands+ (array) with +mode+ controlling error handling
12
+ #
9
13
  # @see ShellSession#run
10
14
  class Bash < Base
11
15
  def self.tool_name = "bash"
12
16
 
13
- def self.description = "Execute a bash command. Working directory and environment persist across calls within a conversation."
17
+ def self.description
18
+ <<~DESC.squish
19
+ Execute a bash command. Working directory and environment persist across calls within a conversation.
20
+ Accepts either `command` (string) for a single command, or `commands` (array of strings) to run
21
+ multiple commands as a batch — each command gets its own timeout and result. Batch `mode` controls
22
+ error handling: "sequential" (default) stops on the first failure, "parallel" runs all regardless.
23
+ DESC
24
+ end
14
25
 
15
26
  def self.input_schema
16
27
  {
17
28
  type: "object",
18
29
  properties: {
19
- command: {type: "string", description: "The bash command to execute"}
20
- },
21
- required: ["command"]
30
+ command: {
31
+ type: "string",
32
+ description: "The bash command to execute"
33
+ },
34
+ commands: {
35
+ type: "array",
36
+ items: {type: "string"},
37
+ description: "Array of bash commands to execute as a batch. Each runs independently with its own timeout and result."
38
+ },
39
+ mode: {
40
+ type: "string",
41
+ enum: ["sequential", "parallel"],
42
+ description: 'Batch error handling: "sequential" (default) stops on first non-zero exit; "parallel" runs all commands regardless of failures.'
43
+ }
44
+ }
22
45
  }
23
46
  end
24
47
 
@@ -33,16 +56,76 @@ module Tools
33
56
  # @return [String] formatted output with stdout, stderr, and exit code
34
57
  # @return [Hash] with :error key on failure
35
58
  def execute(input)
36
- command = input["command"].to_s
59
+ timeout = input["timeout"]
60
+ has_command = input.key?("command")
61
+ has_commands = input.key?("commands")
62
+
63
+ if has_command && has_commands
64
+ {error: "Provide either 'command' or 'commands', not both"}
65
+ elsif has_commands
66
+ execute_batch(input["commands"], mode: input.fetch("mode", "sequential"), timeout: timeout)
67
+ elsif has_command
68
+ execute_single(input["command"], timeout: timeout)
69
+ else
70
+ {error: "Either 'command' (string) or 'commands' (array of strings) is required"}
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ # Executes a single command — the original code path, preserved for backward compatibility.
77
+ def execute_single(command, timeout: nil)
78
+ command = command.to_s
37
79
  return {error: "Command cannot be blank"} if command.strip.empty?
38
80
 
39
- result = @shell_session.run(command, timeout: input["timeout"])
81
+ result = @shell_session.run(command, timeout: timeout)
40
82
  return result if result.key?(:error)
41
83
 
42
84
  format_result(result[:stdout], result[:stderr], result[:exit_code])
43
85
  end
44
86
 
45
- private
87
+ # Executes an array of commands, returning a combined result string.
88
+ # @param commands [Array<String>] commands to execute
89
+ # @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
90
+ # @param timeout [Integer, nil] per-command timeout override
91
+ # @return [String] combined results with per-command headers
92
+ # @return [Hash] with :error key if commands array is invalid
93
+ def execute_batch(commands, mode:, timeout: nil)
94
+ return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
95
+
96
+ total = commands.size
97
+ results = []
98
+ failed = false
99
+
100
+ commands.each_with_index do |command, index|
101
+ position = "[#{index + 1}/#{total}]"
102
+
103
+ if failed && mode == "sequential"
104
+ results << "#{position} $ #{command}\n(skipped)"
105
+ next
106
+ end
107
+
108
+ command = command.to_s
109
+ if command.strip.empty?
110
+ results << "#{position} $ (blank)\n(skipped — blank command)"
111
+ next
112
+ end
113
+
114
+ result = @shell_session.run(command, timeout: timeout)
115
+
116
+ if result.key?(:error)
117
+ results << "#{position} $ #{command}\n#{result[:error]}"
118
+ failed = true
119
+ else
120
+ exit_code = result[:exit_code]
121
+ output = format_result(result[:stdout], result[:stderr], exit_code)
122
+ results << "#{position} $ #{command}\n#{output}"
123
+ failed = true if exit_code != 0
124
+ end
125
+ end
126
+
127
+ results.join("\n\n")
128
+ end
46
129
 
47
130
  def format_result(stdout, stderr, exit_code)
48
131
  parts = []
@@ -37,7 +37,7 @@ module Workflows
37
37
  # @return [self]
38
38
  def load_all
39
39
  load_directory(BUILTIN_DIR)
40
- load_directory(USER_DIR)
40
+ load_directory(USER_DIR) unless Rails.env.test?
41
41
  self
42
42
  end
43
43
 
@@ -161,3 +161,9 @@ budget_fraction = 0.05
161
161
 
162
162
  # Maximum tokens per individual recall snippet.
163
163
  max_snippet_tokens = 512
164
+
165
+ # Recency decay factor for search ranking. Blends FTS5 relevance with event
166
+ # age so recent memories win ties. 0.0 = pure relevance, higher = stronger
167
+ # recency bias. At 0.3 a one-year-old result needs ~30% better keyword
168
+ # relevance to beat an identical match from today.
169
+ recency_decay = 0.3
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anima-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin